feat(auth): 对接后端 open 模块认证接口并完善登录页
- 更新接口路径、字段和短信验证码场景,新增忘记密码/重置密码流程 - 增加 401 自动刷新 token 与统一错误处理 - 重构登录页 UI,支持退出确认、协议与法律文档入口
This commit is contained in:
@@ -2,6 +2,8 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/network/api_exception.dart';
|
||||
import '../../../core/network/error_message.dart';
|
||||
import '../../../core/storage/token_storage.dart';
|
||||
import '../data/auth_providers.dart';
|
||||
import '../domain/auth_models.dart';
|
||||
@@ -64,26 +66,64 @@ class AuthController extends Notifier<AuthState> {
|
||||
}
|
||||
|
||||
/// 发送短信验证码并启动 60s 倒计时。
|
||||
///
|
||||
/// 发送前先做本地校验:手机号为空或格式不正确时直接提示,不发起请求、
|
||||
/// 不启动倒计时,避免出现「空号也能点但弹报错」的体验问题。
|
||||
Future<void> sendSmsCode({
|
||||
required String phone,
|
||||
required SmsScene scene,
|
||||
}) async {
|
||||
if (state.isSendingCode || state.countdownSeconds > 0) return;
|
||||
final trimmed = phone.trim();
|
||||
final phoneError = _validatePhone(trimmed);
|
||||
if (phoneError != null) {
|
||||
state = state.copyWith(alert: phoneError);
|
||||
return;
|
||||
}
|
||||
state = state.copyWith(isSendingCode: true, clearAlert: true);
|
||||
try {
|
||||
await ref.read(authRepositoryProvider).sendSmsCode(
|
||||
phone: phone,
|
||||
phone: trimmed,
|
||||
scene: scene,
|
||||
);
|
||||
_startCountdown();
|
||||
} catch (e) {
|
||||
state = state.copyWith(
|
||||
isSendingCode: false,
|
||||
alert: _messageOf(e),
|
||||
alert: userMessageOf(e),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 校验手机号:空或不符合 11 位中国大陆手机号规则时返回提示文案。
|
||||
String? _validatePhone(String phone) {
|
||||
if (phone.isEmpty) return '请输入手机号';
|
||||
// 1 开头的 11 位手机号。
|
||||
if (!RegExp(r'^1\d{10}$').hasMatch(phone)) return '手机号格式不正确';
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 重置密码(忘记密码流程)。
|
||||
Future<bool> resetPassword({
|
||||
required String phone,
|
||||
required String code,
|
||||
required String password,
|
||||
}) async {
|
||||
state = state.copyWith(isLoading: true, clearAlert: true);
|
||||
try {
|
||||
await ref.read(authRepositoryProvider).resetPassword(
|
||||
phone: phone,
|
||||
code: code,
|
||||
password: password,
|
||||
);
|
||||
state = state.copyWith(isLoading: false);
|
||||
return true;
|
||||
} catch (e) {
|
||||
state = state.copyWith(isLoading: false, alert: userMessageOf(e));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 消费一次性提示。
|
||||
void consumeAlert() {
|
||||
if (state.alert != null) {
|
||||
@@ -105,11 +145,21 @@ class AuthController extends Notifier<AuthState> {
|
||||
state = state.copyWith(isLoading: false);
|
||||
return true;
|
||||
} catch (e) {
|
||||
state = state.copyWith(isLoading: false, alert: _messageOf(e));
|
||||
state = state.copyWith(isLoading: false, alert: userMessageOf(e));
|
||||
// 鉴权失效:本地令牌可能已作废,主动登出清理。
|
||||
if (_isUnauthorized(e)) {
|
||||
logout();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 判断异常是否为鉴权失效(401)。
|
||||
bool _isUnauthorized(Object e) {
|
||||
if (e is ApiException) return e.code == '401';
|
||||
return false;
|
||||
}
|
||||
|
||||
void _startCountdown() {
|
||||
state = state.copyWith(isSendingCode: false, countdownSeconds: 60);
|
||||
_countdownTimer?.cancel();
|
||||
@@ -125,10 +175,7 @@ class AuthController extends Notifier<AuthState> {
|
||||
}
|
||||
|
||||
String _messageOf(Object e) {
|
||||
if (e is Exception) {
|
||||
return e.toString().replaceFirst('Exception: ', '');
|
||||
}
|
||||
return '操作失败,请稍后重试';
|
||||
return userMessageOf(e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
236
lib/features/auth/presentation/forgot_password_page.dart
Normal file
236
lib/features/auth/presentation/forgot_password_page.dart
Normal file
@@ -0,0 +1,236 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import '../domain/auth_models.dart';
|
||||
import 'auth_controller.dart';
|
||||
|
||||
/// 忘记密码页。
|
||||
///
|
||||
/// 业务动作委托给 [authControllerProvider]:发送验证码走 resetPassword 场景,
|
||||
/// 提交走 resetPassword。UI 仅负责输入收集与导航。
|
||||
class ForgotPasswordPage extends ConsumerStatefulWidget {
|
||||
const ForgotPasswordPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ForgotPasswordPage> createState() => _ForgotPasswordPageState();
|
||||
}
|
||||
|
||||
class _ForgotPasswordPageState extends ConsumerState<ForgotPasswordPage> {
|
||||
final _phoneController = TextEditingController();
|
||||
final _codeController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_phoneController.dispose();
|
||||
_codeController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
final phone = _phoneController.text.trim();
|
||||
final phoneError = _validatePhone(phone);
|
||||
if (phoneError != null) {
|
||||
_showAlert(phoneError);
|
||||
return;
|
||||
}
|
||||
if (_codeController.text.trim().isEmpty) {
|
||||
_showAlert(l10n.codeEmpty);
|
||||
return;
|
||||
}
|
||||
if (_passwordController.text.isEmpty) {
|
||||
_showAlert(l10n.passwordEmpty);
|
||||
return;
|
||||
}
|
||||
final ok = await ref.read(authControllerProvider.notifier).resetPassword(
|
||||
phone: phone,
|
||||
code: _codeController.text.trim(),
|
||||
password: _passwordController.text,
|
||||
);
|
||||
if (ok && mounted) {
|
||||
_showAlert(l10n.resetPasswordSuccess);
|
||||
// 重置成功后返回登录页。
|
||||
context.go('/login');
|
||||
}
|
||||
}
|
||||
|
||||
String? _validatePhone(String phone) {
|
||||
if (phone.isEmpty) return l10n.phoneEmpty;
|
||||
if (!RegExp(r'^1\d{10}$').hasMatch(phone)) return l10n.phoneInvalid;
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
ref.listen<String?>(authControllerProvider.select((s) => s.alert),
|
||||
(_, alert) {
|
||||
if (alert != null) {
|
||||
_showAlert(alert);
|
||||
ref.read(authControllerProvider.notifier).consumeAlert();
|
||||
}
|
||||
});
|
||||
|
||||
final state = ref.watch(authControllerProvider);
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
||||
return PopScope(
|
||||
canPop: true,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (!didPop) context.pop();
|
||||
},
|
||||
child: CupertinoPageScaffold(
|
||||
backgroundColor: CupertinoColors.systemGroupedBackground,
|
||||
navigationBar: CupertinoNavigationBar(
|
||||
middle: Text(l10n.forgotPasswordTitle),
|
||||
leading: CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
child: const Icon(CupertinoIcons.back),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: 24),
|
||||
CupertinoTextField(
|
||||
controller: _phoneController,
|
||||
placeholder: l10n.phoneHint,
|
||||
keyboardType: TextInputType.phone,
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: CupertinoColors.white,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_CodeField(
|
||||
controller: _codeController,
|
||||
countdownSeconds: state.countdownSeconds,
|
||||
isSending: state.isSendingCode,
|
||||
codeHint: l10n.codeHint,
|
||||
getCodeLabel: l10n.getCode,
|
||||
sendingLabel: l10n.sending,
|
||||
onSend: () async {
|
||||
final phone = _phoneController.text.trim();
|
||||
final phoneError = _validatePhone(phone);
|
||||
if (phoneError != null) {
|
||||
_showAlert(phoneError);
|
||||
return;
|
||||
}
|
||||
await ref.read(authControllerProvider.notifier).sendSmsCode(
|
||||
phone: phone,
|
||||
scene: SmsScene.resetPassword,
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
CupertinoTextField(
|
||||
controller: _passwordController,
|
||||
placeholder: l10n.newPasswordHint,
|
||||
obscureText: true,
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: CupertinoColors.white,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
CupertinoButton.filled(
|
||||
onPressed: state.isLoading ? null : _submit,
|
||||
child: state.isLoading
|
||||
? const CupertinoActivityIndicator()
|
||||
: Text(l10n.resetPassword),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
CupertinoButton(
|
||||
onPressed: () => context.go('/login'),
|
||||
child: Text(l10n.hasAccount),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
AppLocalizations get l10n => AppLocalizations.of(context);
|
||||
|
||||
void _showAlert(String message) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
debugPrint('[Dialog] ForgotPasswordPage._showAlert 即将弹出提示弹窗'
|
||||
' title=${l10n.alertTitle} message=$message');
|
||||
showCupertinoDialog<void>(
|
||||
context: context,
|
||||
builder: (_) => CupertinoAlertDialog(
|
||||
title: Text(l10n.alertTitle),
|
||||
content: Text(message),
|
||||
actions: [
|
||||
CupertinoDialogAction(
|
||||
child: Text(l10n.confirm),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 验证码输入 + 发送按钮(独立小组件)。提取为私有无状态组件避免冗长内联。
|
||||
class _CodeField extends StatelessWidget {
|
||||
const _CodeField({
|
||||
required this.controller,
|
||||
required this.countdownSeconds,
|
||||
required this.isSending,
|
||||
required this.codeHint,
|
||||
required this.getCodeLabel,
|
||||
required this.sendingLabel,
|
||||
required this.onSend,
|
||||
});
|
||||
|
||||
final TextEditingController controller;
|
||||
final int countdownSeconds;
|
||||
final bool isSending;
|
||||
final String codeHint;
|
||||
final String getCodeLabel;
|
||||
final String sendingLabel;
|
||||
final Future<void> Function() onSend;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final counting = countdownSeconds > 0;
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: CupertinoTextField(
|
||||
controller: controller,
|
||||
placeholder: codeHint,
|
||||
keyboardType: TextInputType.number,
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: CupertinoColors.white,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
CupertinoButton(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
onPressed: (counting || isSending) ? null : () => onSend(),
|
||||
child: Text(
|
||||
counting
|
||||
? '${countdownSeconds}s'
|
||||
: (isSending ? sendingLabel : getCodeLabel),
|
||||
style: const TextStyle(color: CupertinoColors.activeBlue),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
68
lib/features/auth/presentation/legal_document_page.dart
Normal file
68
lib/features/auth/presentation/legal_document_page.dart
Normal file
@@ -0,0 +1,68 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
|
||||
/// 法律文档类型。
|
||||
enum LegalDocumentType {
|
||||
userAgreement,
|
||||
privacyPolicy,
|
||||
}
|
||||
|
||||
/// 用户协议 / 隐私政策内容页。
|
||||
///
|
||||
/// 通过路由 [extra] 的 `type` 字段区分展示内容。标题与正文均由 l10n 提供,
|
||||
/// 随系统语言自动切换(见 [AppLocalizations])。
|
||||
class LegalDocumentPage extends StatelessWidget {
|
||||
const LegalDocumentPage({super.key, required this.type});
|
||||
|
||||
final LegalDocumentType type;
|
||||
|
||||
static LegalDocumentPage fromExtra(Object? extra) {
|
||||
final map = extra as Map<String, dynamic>?;
|
||||
final raw = map?['type'] as String? ?? 'userAgreement';
|
||||
final type = LegalDocumentType.values.firstWhere(
|
||||
(e) => e.name == raw,
|
||||
orElse: () => LegalDocumentType.userAgreement,
|
||||
);
|
||||
return LegalDocumentPage(type: type);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final (title, content) = switch (type) {
|
||||
LegalDocumentType.userAgreement =>
|
||||
(l10n.userAgreement, l10n.userAgreementContent),
|
||||
LegalDocumentType.privacyPolicy =>
|
||||
(l10n.privacyPolicy, l10n.privacyPolicyContent),
|
||||
};
|
||||
|
||||
return CupertinoPageScaffold(
|
||||
backgroundColor: CupertinoColors.systemGroupedBackground,
|
||||
navigationBar: CupertinoNavigationBar(
|
||||
middle: Text(title),
|
||||
leading: CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
child: const Icon(CupertinoIcons.back),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: SingleChildScrollView(
|
||||
child: Text(
|
||||
content,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
height: 1.6,
|
||||
color: CupertinoColors.label,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../app/theme/app_theme.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import '../domain/auth_models.dart';
|
||||
import 'auth_controller.dart';
|
||||
@@ -24,6 +26,9 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
/// true: 验证码登录;false: 密码登录。
|
||||
bool _isSmsLogin = false;
|
||||
|
||||
/// 密码是否明文显示。
|
||||
bool _isPasswordVisible = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_phoneController.dispose();
|
||||
@@ -32,6 +37,37 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 拦截系统返回手势:登录页为初始路由,返回时弹确认退出弹窗。
|
||||
Future<bool> _onWillPop() async {
|
||||
final shouldExit = await _showExitConfirm();
|
||||
return shouldExit ?? false;
|
||||
}
|
||||
|
||||
/// 弹出「确认退出应用」对话框,返回 true 表示用户确认退出。
|
||||
Future<bool?> _showExitConfirm() {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
debugPrint('[Dialog] LoginPage._showExitConfirm 即将弹出退出确认弹窗'
|
||||
' title=${l10n.exitConfirmTitle} content=${l10n.exitConfirmContent}');
|
||||
return showCupertinoDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => CupertinoAlertDialog(
|
||||
title: Text(l10n.exitConfirmTitle),
|
||||
content: Text(l10n.exitConfirmContent),
|
||||
actions: [
|
||||
CupertinoDialogAction(
|
||||
child: Text(l10n.cancel),
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
),
|
||||
CupertinoDialogAction(
|
||||
isDestructiveAction: true,
|
||||
child: Text(l10n.exitApp),
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
final phone = _phoneController.text.trim();
|
||||
final ok = _isSmsLogin
|
||||
@@ -46,108 +82,10 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
if (ok && mounted) context.go('/home');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
ref.listen<String?>(authControllerProvider.select((s) => s.alert),
|
||||
(_, alert) {
|
||||
if (alert != null) {
|
||||
_showAlert(alert);
|
||||
ref.read(authControllerProvider.notifier).consumeAlert();
|
||||
}
|
||||
});
|
||||
|
||||
final state = ref.watch(authControllerProvider);
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
||||
return CupertinoPageScaffold(
|
||||
backgroundColor: CupertinoColors.systemGroupedBackground,
|
||||
navigationBar: CupertinoNavigationBar(
|
||||
middle: Text(l10n.loginTitle),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: 24),
|
||||
CupertinoSlidingSegmentedControl<bool>(
|
||||
groupValue: _isSmsLogin,
|
||||
children: {
|
||||
false: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(l10n.passwordLogin),
|
||||
),
|
||||
true: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(l10n.smsLogin),
|
||||
),
|
||||
},
|
||||
onValueChanged: (value) {
|
||||
if (value != null) setState(() => _isSmsLogin = value);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
CupertinoTextField(
|
||||
controller: _phoneController,
|
||||
placeholder: l10n.phoneHint,
|
||||
keyboardType: TextInputType.phone,
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: CupertinoColors.white,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (_isSmsLogin)
|
||||
_CodeField(
|
||||
controller: _codeController,
|
||||
countdownSeconds: state.countdownSeconds,
|
||||
isSending: state.isSendingCode,
|
||||
codeHint: l10n.codeHint,
|
||||
getCodeLabel: l10n.getCode,
|
||||
sendingLabel: l10n.sending,
|
||||
onSend: () async {
|
||||
await ref
|
||||
.read(authControllerProvider.notifier)
|
||||
.sendSmsCode(
|
||||
phone: _phoneController.text.trim(),
|
||||
scene: SmsScene.login,
|
||||
);
|
||||
},
|
||||
)
|
||||
else
|
||||
CupertinoTextField(
|
||||
controller: _passwordController,
|
||||
placeholder: l10n.passwordHint,
|
||||
obscureText: true,
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: CupertinoColors.white,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
CupertinoButton.filled(
|
||||
onPressed: state.isLoading ? null : _submit,
|
||||
child: state.isLoading
|
||||
? const CupertinoActivityIndicator()
|
||||
: Text(l10n.login),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
CupertinoButton(
|
||||
onPressed: () => context.go('/register'),
|
||||
child: Text(l10n.noAccount),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showAlert(String message) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
debugPrint('[Dialog] LoginPage._showAlert 即将弹出提示弹窗'
|
||||
' title=${l10n.alertTitle} message=$message');
|
||||
showCupertinoDialog<void>(
|
||||
context: context,
|
||||
builder: (_) => CupertinoAlertDialog(
|
||||
@@ -162,9 +100,470 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showPlaceholderAlert(String message) {
|
||||
_showAlert(message);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
ref.listen<String?>(authControllerProvider.select((s) => s.alert),
|
||||
(_, alert) {
|
||||
if (alert != null) {
|
||||
_showAlert(alert);
|
||||
ref.read(authControllerProvider.notifier).consumeAlert();
|
||||
}
|
||||
});
|
||||
|
||||
final state = ref.watch(authControllerProvider);
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, _) async {
|
||||
if (didPop) return;
|
||||
await _onWillPop();
|
||||
},
|
||||
child: CupertinoPageScaffold(
|
||||
backgroundColor: CupertinoColors.white,
|
||||
child: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: 48),
|
||||
_buildHeader(l10n),
|
||||
const SizedBox(height: 40),
|
||||
_LoginTypeToggle(
|
||||
isSmsLogin: _isSmsLogin,
|
||||
passwordLabel: l10n.passwordLogin,
|
||||
smsLabel: l10n.smsLogin,
|
||||
onChanged: (value) {
|
||||
if (value != _isSmsLogin) {
|
||||
setState(() => _isSmsLogin = value);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
_InputField(
|
||||
controller: _phoneController,
|
||||
placeholder: l10n.phoneHintDetailed,
|
||||
prefixIcon: CupertinoIcons.device_phone_portrait,
|
||||
keyboardType: TextInputType.phone,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (_isSmsLogin)
|
||||
_CodeField(
|
||||
controller: _codeController,
|
||||
countdownSeconds: state.countdownSeconds,
|
||||
isSending: state.isSendingCode,
|
||||
codeHint: l10n.codeHint,
|
||||
getCodeLabel: l10n.getCode,
|
||||
sendingLabel: l10n.sending,
|
||||
onSend: () async {
|
||||
final phone = _phoneController.text.trim();
|
||||
if (phone.isEmpty) {
|
||||
_showAlert(l10n.phoneEmpty);
|
||||
return;
|
||||
}
|
||||
if (!RegExp(r'^1\d{10}$').hasMatch(phone)) {
|
||||
_showAlert(l10n.phoneInvalid);
|
||||
return;
|
||||
}
|
||||
await ref
|
||||
.read(authControllerProvider.notifier)
|
||||
.sendSmsCode(
|
||||
phone: phone,
|
||||
scene: SmsScene.login,
|
||||
);
|
||||
},
|
||||
)
|
||||
else
|
||||
_PasswordField(
|
||||
controller: _passwordController,
|
||||
placeholder: l10n.passwordHintDetailed,
|
||||
isVisible: _isPasswordVisible,
|
||||
onVisibilityChanged: (visible) {
|
||||
setState(() => _isPasswordVisible = visible);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
SizedBox(
|
||||
height: 48,
|
||||
child: CupertinoButton.filled(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
padding: EdgeInsets.zero,
|
||||
onPressed: state.isLoading ? null : _submit,
|
||||
child: state.isLoading
|
||||
? const CupertinoActivityIndicator(
|
||||
color: CupertinoColors.white,
|
||||
)
|
||||
: Text(
|
||||
l10n.login,
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
minimumSize: Size.zero,
|
||||
child: Text(
|
||||
l10n.registerTitle,
|
||||
style: const TextStyle(
|
||||
color: AppTheme.primary,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
onPressed: () => context.push('/register'),
|
||||
),
|
||||
CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
minimumSize: Size.zero,
|
||||
child: Text(
|
||||
l10n.forgotPassword,
|
||||
style: const TextStyle(
|
||||
color: CupertinoColors.secondaryLabel,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
onPressed: () => context.push('/forgot-password'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
_buildDivider(l10n.otherLoginMethods),
|
||||
const SizedBox(height: 20),
|
||||
_buildWeChatLoginButton(l10n),
|
||||
const SizedBox(height: 28),
|
||||
_buildAgreement(l10n),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(AppLocalizations l10n) {
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primary,
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
),
|
||||
child: const Icon(
|
||||
CupertinoIcons.lock_fill,
|
||||
color: CupertinoColors.white,
|
||||
size: 36,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
l10n.welcomeLoginTitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: CupertinoColors.black,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
l10n.welcomeLoginSubtitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: CupertinoColors.secondaryLabel,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDivider(String label) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 1,
|
||||
color: CupertinoColors.systemGrey5,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: CupertinoColors.tertiaryLabel,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 1,
|
||||
color: CupertinoColors.systemGrey5,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWeChatLoginButton(AppLocalizations l10n) {
|
||||
return SizedBox(
|
||||
height: 48,
|
||||
child: CupertinoButton(
|
||||
color: const Color(0xFF07C160),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
padding: EdgeInsets.zero,
|
||||
minimumSize: Size.zero,
|
||||
onPressed: () => _showPlaceholderAlert('TODO: 调起微信登录'),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
CupertinoIcons.chat_bubble_fill,
|
||||
color: CupertinoColors.white,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
l10n.wechatLogin,
|
||||
style: const TextStyle(
|
||||
color: CupertinoColors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAgreement(AppLocalizations l10n) {
|
||||
return Center(
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
text: l10n.userAgreementPrefix,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: CupertinoColors.tertiaryLabel,
|
||||
),
|
||||
children: [
|
||||
TextSpan(
|
||||
text: l10n.userAgreement,
|
||||
style: const TextStyle(
|
||||
color: AppTheme.primary,
|
||||
),
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = () => context.push(
|
||||
'/legal',
|
||||
extra: {'type': 'userAgreement'},
|
||||
),
|
||||
),
|
||||
TextSpan(text: l10n.andConnector),
|
||||
TextSpan(
|
||||
text: l10n.privacyPolicy,
|
||||
style: const TextStyle(
|
||||
color: AppTheme.primary,
|
||||
),
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = () => context.push(
|
||||
'/legal',
|
||||
extra: {'type': 'privacyPolicy'},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 验证码输入 + 发送按钮(独立小组件,避免内联冗长)。提取为私有无状态组件。
|
||||
/// 登录方式切换(密码 / 验证码)。
|
||||
class _LoginTypeToggle extends StatelessWidget {
|
||||
const _LoginTypeToggle({
|
||||
required this.isSmsLogin,
|
||||
required this.passwordLabel,
|
||||
required this.smsLabel,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final bool isSmsLogin;
|
||||
final String passwordLabel;
|
||||
final String smsLabel;
|
||||
final ValueChanged<bool> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF5F7FA),
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _ToggleItem(
|
||||
label: passwordLabel,
|
||||
selected: !isSmsLogin,
|
||||
onTap: () => onChanged(false),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _ToggleItem(
|
||||
label: smsLabel,
|
||||
selected: isSmsLogin,
|
||||
onTap: () => onChanged(true),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ToggleItem extends StatelessWidget {
|
||||
const _ToggleItem({
|
||||
required this.label,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? AppTheme.primary : null,
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: selected
|
||||
? CupertinoColors.white
|
||||
: CupertinoColors.secondaryLabel,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 带前缀图标的输入框。
|
||||
class _InputField extends StatelessWidget {
|
||||
const _InputField({
|
||||
required this.controller,
|
||||
required this.placeholder,
|
||||
required this.prefixIcon,
|
||||
this.keyboardType,
|
||||
this.obscureText = false,
|
||||
this.suffix,
|
||||
});
|
||||
|
||||
final TextEditingController controller;
|
||||
final String placeholder;
|
||||
final IconData prefixIcon;
|
||||
final TextInputType? keyboardType;
|
||||
final bool obscureText;
|
||||
final Widget? suffix;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF5F7FA),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: CupertinoTextField(
|
||||
controller: controller,
|
||||
placeholder: placeholder,
|
||||
keyboardType: keyboardType,
|
||||
obscureText: obscureText,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: const BoxDecoration(),
|
||||
prefix: Padding(
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
child: Icon(
|
||||
prefixIcon,
|
||||
color: CupertinoColors.tertiaryLabel,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
suffix: suffix,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
color: CupertinoColors.label,
|
||||
),
|
||||
placeholderStyle: const TextStyle(
|
||||
fontSize: 15,
|
||||
color: CupertinoColors.tertiaryLabel,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 密码输入框(含显隐切换)。
|
||||
class _PasswordField extends StatelessWidget {
|
||||
const _PasswordField({
|
||||
required this.controller,
|
||||
required this.placeholder,
|
||||
required this.isVisible,
|
||||
required this.onVisibilityChanged,
|
||||
});
|
||||
|
||||
final TextEditingController controller;
|
||||
final String placeholder;
|
||||
final bool isVisible;
|
||||
final ValueChanged<bool> onVisibilityChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _InputField(
|
||||
controller: controller,
|
||||
placeholder: placeholder,
|
||||
prefixIcon: CupertinoIcons.lock,
|
||||
obscureText: !isVisible,
|
||||
suffix: CupertinoButton(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
minimumSize: Size.zero,
|
||||
onPressed: () => onVisibilityChanged(!isVisible),
|
||||
child: Icon(
|
||||
isVisible ? CupertinoIcons.eye_slash : CupertinoIcons.eye,
|
||||
color: CupertinoColors.tertiaryLabel,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 验证码输入 + 发送按钮(独立小组件,避免内联冗长)。
|
||||
class _CodeField extends StatelessWidget {
|
||||
const _CodeField({
|
||||
required this.controller,
|
||||
@@ -190,15 +589,11 @@ class _CodeField extends StatelessWidget {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: CupertinoTextField(
|
||||
child: _InputField(
|
||||
controller: controller,
|
||||
placeholder: codeHint,
|
||||
prefixIcon: CupertinoIcons.lock,
|
||||
keyboardType: TextInputType.number,
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: CupertinoColors.white,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
@@ -209,7 +604,7 @@ class _CodeField extends StatelessWidget {
|
||||
counting
|
||||
? '${countdownSeconds}s'
|
||||
: (isSending ? sendingLabel : getCodeLabel),
|
||||
style: const TextStyle(color: CupertinoColors.activeBlue),
|
||||
style: const TextStyle(color: AppTheme.primary),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -51,12 +51,22 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
|
||||
final state = ref.watch(authControllerProvider);
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
||||
return CupertinoPageScaffold(
|
||||
backgroundColor: CupertinoColors.systemGroupedBackground,
|
||||
navigationBar: CupertinoNavigationBar(
|
||||
middle: Text(l10n.registerTitle),
|
||||
),
|
||||
child: SafeArea(
|
||||
return PopScope(
|
||||
canPop: true,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (!didPop) context.pop();
|
||||
},
|
||||
child: CupertinoPageScaffold(
|
||||
backgroundColor: CupertinoColors.systemGroupedBackground,
|
||||
navigationBar: CupertinoNavigationBar(
|
||||
middle: Text(l10n.registerTitle),
|
||||
leading: CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
child: const Icon(CupertinoIcons.back),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
@@ -82,8 +92,17 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
|
||||
getCodeLabel: l10n.getCode,
|
||||
sendingLabel: l10n.sending,
|
||||
onSend: () async {
|
||||
final phone = _phoneController.text.trim();
|
||||
if (phone.isEmpty) {
|
||||
_showAlert(l10n.phoneEmpty);
|
||||
return;
|
||||
}
|
||||
if (!RegExp(r'^1\d{10}$').hasMatch(phone)) {
|
||||
_showAlert(l10n.phoneInvalid);
|
||||
return;
|
||||
}
|
||||
await ref.read(authControllerProvider.notifier).sendSmsCode(
|
||||
phone: _phoneController.text.trim(),
|
||||
phone: phone,
|
||||
scene: SmsScene.register,
|
||||
);
|
||||
},
|
||||
@@ -115,11 +134,14 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showAlert(String message) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
debugPrint('[Dialog] RegisterPage._showAlert 即将弹出提示弹窗'
|
||||
' title=${l10n.alertTitle} message=$message');
|
||||
showCupertinoDialog<void>(
|
||||
context: context,
|
||||
builder: (_) => CupertinoAlertDialog(
|
||||
|
||||
Reference in New Issue
Block a user