From 4b1cdf9fb3307586c97695ff084a86e590c92011 Mon Sep 17 00:00:00 2001 From: hailin Date: Mon, 23 Feb 2026 19:23:25 -0800 Subject: [PATCH] =?UTF-8?q?feat(genex-mobile):=20Flutter=20=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E5=AF=B9=E6=8E=A5=20SMS=20=E8=AE=A4=E8=AF=81=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 6 — Flutter 前端完整对接后端 SMS 认证系统: 新增组件: - OtpInput: 6位验证码输入框,支持自动跳转、粘贴、错误状态 - CountdownButton: 短信验证码倒计时按钮(60s),发送失败不启动倒计时 新增服务: - AuthService: 单例认证服务,封装全部 auth API · sendSmsCode (REGISTER/LOGIN/RESET_PASSWORD/CHANGE_PHONE) · register / loginByPassword / loginByPhone · resetPassword / changePassword / changePhone · refreshToken / logout · ValueNotifier 状态管理 页面重写 (对接真实 API): - LoginPage: 双 Tab (密码/验证码登录),错误提示 Banner,账户锁定展示 - RegisterPage: 三步注册流程,CountdownButton 集成,密码强度检查 - ForgotPasswordPage: 四步找回密码,验证码重发,密码一致性校验 i18n 补充 (4语言 × 13 新 key): - login: noAccount, registerNow, networkError, errorPhoneRequired, errorPasswordMin, errorCodeInvalid - register: hasAccount, loginNow, errorPhoneRequired, errorCodeInvalid, errorPasswordWeak, errorTermsRequired - forgot: errorPasswordMismatch Co-Authored-By: Claude Opus 4.6 --- .../genex-mobile/lib/app/i18n/strings/en.dart | 6 + .../genex-mobile/lib/app/i18n/strings/ja.dart | 13 + .../lib/app/i18n/strings/zh_cn.dart | 13 + .../lib/app/i18n/strings/zh_tw.dart | 13 + .../lib/core/services/auth_service.dart | 201 +++++++++++ .../pages/forgot_password_page.dart | 161 ++++++++- .../auth/presentation/pages/login_page.dart | 316 +++++++++++++----- .../presentation/pages/register_page.dart | 175 ++++++++-- .../lib/shared/widgets/countdown_button.dart | 102 ++++++ .../lib/shared/widgets/otp_input.dart | 160 +++++++++ 10 files changed, 1026 insertions(+), 134 deletions(-) create mode 100644 frontend/genex-mobile/lib/core/services/auth_service.dart create mode 100644 frontend/genex-mobile/lib/shared/widgets/countdown_button.dart create mode 100644 frontend/genex-mobile/lib/shared/widgets/otp_input.dart diff --git a/frontend/genex-mobile/lib/app/i18n/strings/en.dart b/frontend/genex-mobile/lib/app/i18n/strings/en.dart index 2840a21..06f302d 100644 --- a/frontend/genex-mobile/lib/app/i18n/strings/en.dart +++ b/frontend/genex-mobile/lib/app/i18n/strings/en.dart @@ -49,6 +49,12 @@ const Map en = { 'login.phone': 'Phone', 'login.verifyCode': 'Code', 'login.getCode': 'Get Code', + 'login.noAccount': 'Don\'t have an account? ', + 'login.registerNow': 'Sign Up', + 'login.networkError': 'Network error, please try again', + 'login.errorPhoneRequired': 'Please enter your phone number', + 'login.errorPasswordMin': 'Password must be at least 8 characters', + 'login.errorCodeInvalid': 'Please enter a 6-digit code', 'register.title': 'Create Account', 'register.emailSubtitle': 'Sign up with your email', diff --git a/frontend/genex-mobile/lib/app/i18n/strings/ja.dart b/frontend/genex-mobile/lib/app/i18n/strings/ja.dart index 966535f..3ce5130 100644 --- a/frontend/genex-mobile/lib/app/i18n/strings/ja.dart +++ b/frontend/genex-mobile/lib/app/i18n/strings/ja.dart @@ -49,6 +49,12 @@ const Map ja = { 'login.phone': '電話番号', 'login.verifyCode': '認証コード', 'login.getCode': '認証コードを取得', + 'login.noAccount': 'アカウントをお持ちでないですか?', + 'login.registerNow': '新規登録', + 'login.networkError': 'ネットワークエラーです。後でもう一度お試しください', + 'login.errorPhoneRequired': '電話番号を入力してください', + 'login.errorPasswordMin': 'パスワードは8文字以上必要です', + 'login.errorCodeInvalid': '6桁の認証コードを入力してください', 'register.title': 'アカウント作成', 'register.emailSubtitle': 'メールアドレスで Genex アカウントを登録', @@ -72,6 +78,12 @@ const Map ja = { 'register.rule8chars': '8文字以上', 'register.ruleLetter': '英字を含む', 'register.ruleNumber': '数字を含む', + 'register.hasAccount': 'アカウントをお持ちですか?', + 'register.loginNow': 'ログイン', + 'register.errorPhoneRequired': '電話番号を入力してください', + 'register.errorCodeInvalid': '6桁の認証コードを入力してください', + 'register.errorPasswordWeak': 'パスワードは8文字以上で英字と数字を含む必要があります', + 'register.errorTermsRequired': '利用規約に同意してください', 'forgot.title': 'パスワード再設定', 'forgot.inputAccount': '電話番号またはメールを入力', @@ -91,6 +103,7 @@ const Map ja = { 'forgot.success': 'パスワードの変更が完了しました', 'forgot.successHint': '新しいパスワードでログインしてください', 'forgot.backToLogin': 'ログインに戻る', + 'forgot.errorPasswordMismatch': 'パスワードが一致しません', // ============ Home ============ 'home.searchHint': 'クーポン、ブランド、カテゴリを検索...', diff --git a/frontend/genex-mobile/lib/app/i18n/strings/zh_cn.dart b/frontend/genex-mobile/lib/app/i18n/strings/zh_cn.dart index 82127d2..9418272 100644 --- a/frontend/genex-mobile/lib/app/i18n/strings/zh_cn.dart +++ b/frontend/genex-mobile/lib/app/i18n/strings/zh_cn.dart @@ -49,6 +49,12 @@ const Map zhCN = { 'login.phone': '手机号', 'login.verifyCode': '验证码', 'login.getCode': '获取验证码', + 'login.noAccount': '还没有账号?', + 'login.registerNow': '立即注册', + 'login.networkError': '网络错误,请稍后重试', + 'login.errorPhoneRequired': '请输入手机号', + 'login.errorPasswordMin': '密码不能少于8位', + 'login.errorCodeInvalid': '请输入6位验证码', 'register.title': '创建账号', 'register.emailSubtitle': '使用邮箱注册券信账号', @@ -72,6 +78,12 @@ const Map zhCN = { 'register.rule8chars': '8位以上', 'register.ruleLetter': '含字母', 'register.ruleNumber': '含数字', + 'register.hasAccount': '已有账号?', + 'register.loginNow': '立即登录', + 'register.errorPhoneRequired': '请输入手机号', + 'register.errorCodeInvalid': '请输入6位验证码', + 'register.errorPasswordWeak': '密码需要8位以上且包含字母和数字', + 'register.errorTermsRequired': '请先阅读并同意用户协议', 'forgot.title': '找回密码', 'forgot.inputAccount': '输入手机号或邮箱', @@ -91,6 +103,7 @@ const Map zhCN = { 'forgot.success': '密码修改成功', 'forgot.successHint': '请使用新密码登录', 'forgot.backToLogin': '返回登录', + 'forgot.errorPasswordMismatch': '两次输入的密码不一致', // ============ Home ============ 'home.searchHint': '搜索券、品牌、分类...', diff --git a/frontend/genex-mobile/lib/app/i18n/strings/zh_tw.dart b/frontend/genex-mobile/lib/app/i18n/strings/zh_tw.dart index 6581f4a..a40469f 100644 --- a/frontend/genex-mobile/lib/app/i18n/strings/zh_tw.dart +++ b/frontend/genex-mobile/lib/app/i18n/strings/zh_tw.dart @@ -49,6 +49,12 @@ const Map zhTW = { 'login.phone': '手機號', 'login.verifyCode': '驗證碼', 'login.getCode': '取得驗證碼', + 'login.noAccount': '還沒有帳號?', + 'login.registerNow': '立即註冊', + 'login.networkError': '網路錯誤,請稍後重試', + 'login.errorPhoneRequired': '請輸入手機號', + 'login.errorPasswordMin': '密碼不能少於8位', + 'login.errorCodeInvalid': '請輸入6位驗證碼', 'register.title': '建立帳號', 'register.emailSubtitle': '使用信箱註冊券信帳號', @@ -72,6 +78,12 @@ const Map zhTW = { 'register.rule8chars': '8位以上', 'register.ruleLetter': '含字母', 'register.ruleNumber': '含數字', + 'register.hasAccount': '已有帳號?', + 'register.loginNow': '立即登入', + 'register.errorPhoneRequired': '請輸入手機號', + 'register.errorCodeInvalid': '請輸入6位驗證碼', + 'register.errorPasswordWeak': '密碼需要8位以上且包含字母和數字', + 'register.errorTermsRequired': '請先閱讀並同意使用者協議', 'forgot.title': '找回密碼', 'forgot.inputAccount': '輸入手機號或信箱', @@ -91,6 +103,7 @@ const Map zhTW = { 'forgot.success': '密碼修改成功', 'forgot.successHint': '請使用新密碼登入', 'forgot.backToLogin': '返回登入', + 'forgot.errorPasswordMismatch': '兩次輸入的密碼不一致', // ============ Home ============ 'home.searchHint': '搜尋券、品牌、分類...', diff --git a/frontend/genex-mobile/lib/core/services/auth_service.dart b/frontend/genex-mobile/lib/core/services/auth_service.dart new file mode 100644 index 0000000..b09de09 --- /dev/null +++ b/frontend/genex-mobile/lib/core/services/auth_service.dart @@ -0,0 +1,201 @@ +import 'package:flutter/foundation.dart'; +import '../network/api_client.dart'; + +/// SMS 验证码类型 +enum SmsCodeType { + register('REGISTER'), + login('LOGIN'), + resetPassword('RESET_PASSWORD'), + changePhone('CHANGE_PHONE'); + + final String value; + const SmsCodeType(this.value); +} + +/// 认证结果 +class AuthResult { + final Map user; + final String accessToken; + final String refreshToken; + final int expiresIn; + + AuthResult({ + required this.user, + required this.accessToken, + required this.refreshToken, + required this.expiresIn, + }); + + factory AuthResult.fromJson(Map json) { + final tokens = json['tokens'] as Map; + return AuthResult( + user: json['user'] as Map, + accessToken: tokens['accessToken'] as String, + refreshToken: tokens['refreshToken'] as String, + expiresIn: tokens['expiresIn'] as int, + ); + } +} + +/// Auth Service — 对接后端 auth-service API +class AuthService { + static final AuthService _instance = AuthService._(); + static AuthService get instance => _instance; + AuthService._(); + + final _api = ApiClient.instance; + + // 登录状态 + final ValueNotifier authState = ValueNotifier(null); + bool get isLoggedIn => authState.value != null; + + /* ── SMS 验证码 ── */ + + /// 发送短信验证码 + /// 返回 expiresIn (秒) + Future sendSmsCode(String phone, SmsCodeType type) async { + final resp = await _api.post('/api/v1/auth/sms/send', data: { + 'phone': phone, + 'type': type.value, + }); + final data = resp.data['data'] as Map; + return data['expiresIn'] as int; + } + + /* ── 注册 ── */ + + /// 手机号注册 (需先获取 REGISTER 类型验证码) + Future register({ + required String phone, + required String smsCode, + required String password, + String? nickname, + }) async { + final resp = await _api.post('/api/v1/auth/register', data: { + 'phone': phone, + 'smsCode': smsCode, + 'password': password, + if (nickname != null) 'nickname': nickname, + }); + final result = AuthResult.fromJson(resp.data['data']); + _setAuth(result); + return result; + } + + /* ── 登录 ── */ + + /// 密码登录 + Future loginByPassword({ + required String identifier, + required String password, + String? deviceInfo, + }) async { + final resp = await _api.post('/api/v1/auth/login', data: { + 'identifier': identifier, + 'password': password, + if (deviceInfo != null) 'deviceInfo': deviceInfo, + }); + final result = AuthResult.fromJson(resp.data['data']); + _setAuth(result); + return result; + } + + /// 短信验证码登录 (需先获取 LOGIN 类型验证码) + Future loginByPhone({ + required String phone, + required String smsCode, + String? deviceInfo, + }) async { + final resp = await _api.post('/api/v1/auth/login-phone', data: { + 'phone': phone, + 'smsCode': smsCode, + if (deviceInfo != null) 'deviceInfo': deviceInfo, + }); + final result = AuthResult.fromJson(resp.data['data']); + _setAuth(result); + return result; + } + + /* ── 密码管理 ── */ + + /// 重置密码 (需先获取 RESET_PASSWORD 类型验证码) + Future resetPassword({ + required String phone, + required String smsCode, + required String newPassword, + }) async { + await _api.post('/api/v1/auth/reset-password', data: { + 'phone': phone, + 'smsCode': smsCode, + 'newPassword': newPassword, + }); + } + + /// 修改密码 (需登录) + Future changePassword({ + required String oldPassword, + required String newPassword, + }) async { + await _api.post('/api/v1/auth/change-password', data: { + 'oldPassword': oldPassword, + 'newPassword': newPassword, + }); + // 修改密码后强制重新登录 + logout(); + } + + /* ── 手机号管理 ── */ + + /// 换绑手机号 (需登录 + CHANGE_PHONE 类型验证码) + Future changePhone({ + required String newPhone, + required String newSmsCode, + }) async { + await _api.post('/api/v1/auth/change-phone', data: { + 'newPhone': newPhone, + 'newSmsCode': newSmsCode, + }); + } + + /* ── Token 管理 ── */ + + /// 刷新 Token + Future refreshToken() async { + final current = authState.value; + if (current == null) return; + + final resp = await _api.post('/api/v1/auth/refresh', data: { + 'refreshToken': current.refreshToken, + }); + final tokens = resp.data['data'] as Map; + final newResult = AuthResult( + user: current.user, + accessToken: tokens['accessToken'] as String, + refreshToken: tokens['refreshToken'] as String, + expiresIn: tokens['expiresIn'] as int, + ); + _setAuth(newResult); + } + + /// 登出 + Future logout() async { + try { + await _api.post('/api/v1/auth/logout'); + } catch (_) { + // 即使请求失败也清除本地状态 + } + _clearAuth(); + } + + /* ── Private ── */ + + void _setAuth(AuthResult result) { + authState.value = result; + _api.setToken(result.accessToken); + } + + void _clearAuth() { + authState.value = null; + _api.setToken(null); + } +} diff --git a/frontend/genex-mobile/lib/features/auth/presentation/pages/forgot_password_page.dart b/frontend/genex-mobile/lib/features/auth/presentation/pages/forgot_password_page.dart index 9936d56..26534a2 100644 --- a/frontend/genex-mobile/lib/features/auth/presentation/pages/forgot_password_page.dart +++ b/frontend/genex-mobile/lib/features/auth/presentation/pages/forgot_password_page.dart @@ -1,13 +1,14 @@ import 'package:flutter/material.dart'; +import 'package:dio/dio.dart'; import '../../../../app/theme/app_colors.dart'; import '../../../../app/theme/app_typography.dart'; import '../../../../app/theme/app_spacing.dart'; import '../../../../shared/widgets/genex_button.dart'; +import '../../../../shared/widgets/countdown_button.dart'; +import '../../../../core/services/auth_service.dart'; import '../../../../app/i18n/app_localizations.dart'; -/// A1. 忘记密码 - 手机号/邮箱验证 → 输入验证码 → 设置新密码 → 成功 -/// -/// 分步骤流程:Step1 输入账号 → Step2 验证码 → Step3 新密码 → Step4 成功 +/// A1. 忘记密码 - 手机号验证 → 输入验证码 → 设置新密码 → 成功 class ForgotPasswordPage extends StatefulWidget { const ForgotPasswordPage({super.key}); @@ -23,6 +24,10 @@ class _ForgotPasswordPageState extends State { final _confirmController = TextEditingController(); bool _obscurePassword = true; bool _obscureConfirm = true; + bool _loading = false; + String? _errorMessage; + + final _authService = AuthService.instance; @override void dispose() { @@ -33,6 +38,84 @@ class _ForgotPasswordPageState extends State { super.dispose(); } + String _extractError(DioException e) { + final data = e.response?.data; + if (data is Map && data['message'] != null) { + return data['message'].toString(); + } + return context.t('login.networkError'); + } + + /* ── Step 0: 发送验证码 ── */ + Future _handleSendCode() async { + final phone = _phoneController.text.trim(); + if (phone.isEmpty) { + setState(() => _errorMessage = context.t('register.errorPhoneRequired')); + throw Exception('phone required'); + } + setState(() => _errorMessage = null); + try { + await _authService.sendSmsCode(phone, SmsCodeType.resetPassword); + setState(() => _step = 1); + } on DioException catch (e) { + setState(() => _errorMessage = _extractError(e)); + rethrow; + } + } + + /* ── Step 1 → Step 2 ── */ + void _handleCodeNext() { + if (_codeController.text.trim().length != 6) { + setState(() => _errorMessage = context.t('register.errorCodeInvalid')); + return; + } + setState(() { _step = 2; _errorMessage = null; }); + } + + /* ── Step 2: 重设密码 ── */ + Future _handleResetPassword() async { + final password = _passwordController.text; + final confirm = _confirmController.text; + + if (password.length < 8) { + setState(() => _errorMessage = context.t('login.errorPasswordMin')); + return; + } + if (password != confirm) { + setState(() => _errorMessage = context.t('forgot.errorPasswordMismatch')); + return; + } + + setState(() { _loading = true; _errorMessage = null; }); + try { + await _authService.resetPassword( + phone: _phoneController.text.trim(), + smsCode: _codeController.text.trim(), + newPassword: password, + ); + setState(() => _step = 3); + } on DioException catch (e) { + setState(() => _errorMessage = _extractError(e)); + } catch (e) { + setState(() => _errorMessage = e.toString()); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + /* ── Step 1: 重新发送 ── */ + Future _handleResend() async { + try { + await _authService.sendSmsCode( + _phoneController.text.trim(), + SmsCodeType.resetPassword, + ); + } on DioException catch (e) { + setState(() => _errorMessage = _extractError(e)); + rethrow; + } + } + @override Widget build(BuildContext context) { return Scaffold( @@ -44,7 +127,7 @@ class _ForgotPasswordPageState extends State { icon: const Icon(Icons.arrow_back_ios_rounded, size: 20), onPressed: () { if (_step > 0 && _step < 3) { - setState(() => _step--); + setState(() { _step--; _errorMessage = null; }); } else { Navigator.pop(context); } @@ -54,7 +137,36 @@ class _ForgotPasswordPageState extends State { body: SafeArea( child: Padding( padding: AppSpacing.pagePadding, - child: _buildStep(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Error message + if (_errorMessage != null) ...[ + const SizedBox(height: 8), + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: AppColors.errorLight, + borderRadius: AppSpacing.borderRadiusSm, + ), + child: Row( + children: [ + const Icon(Icons.error_outline, color: AppColors.error, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( + _errorMessage!, + style: AppTypography.bodySmall.copyWith(color: AppColors.error), + ), + ), + ], + ), + ), + ], + Expanded(child: _buildStep()), + ], + ), ), ), ); @@ -82,7 +194,10 @@ class _ForgotPasswordPageState extends State { const SizedBox(height: 24), Text(context.t('forgot.inputAccount'), style: AppTypography.h1), const SizedBox(height: 8), - Text(context.t('forgot.sendHint'), style: AppTypography.bodyMedium.copyWith(color: AppColors.textSecondary)), + Text( + context.t('forgot.sendHint'), + style: AppTypography.bodyMedium.copyWith(color: AppColors.textSecondary), + ), const SizedBox(height: 32), TextField( controller: _phoneController, @@ -93,15 +208,20 @@ class _ForgotPasswordPageState extends State { ), ), const SizedBox(height: 24), - GenexButton( + CountdownButton( label: context.t('forgot.getCode'), - onPressed: () => setState(() => _step = 1), + onPressed: _handleSendCode, ), ], ); } Widget _buildStepCode() { + final phone = _phoneController.text.trim(); + final masked = phone.length >= 7 + ? '${phone.substring(0, 3)}****${phone.substring(phone.length - 4)}' + : phone; + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -109,30 +229,32 @@ class _ForgotPasswordPageState extends State { Text(context.t('forgot.inputCode'), style: AppTypography.h1), const SizedBox(height: 8), Text( - '${context.t('forgot.codeSentTo')} ${_phoneController.text.isNotEmpty ? _phoneController.text : '***'}', + '${context.t('forgot.codeSentTo')} $masked', style: AppTypography.bodyMedium.copyWith(color: AppColors.textSecondary), ), const SizedBox(height: 32), TextField( controller: _codeController, keyboardType: TextInputType.number, + maxLength: 6, decoration: InputDecoration( hintText: context.t('forgot.codeHint'), + counterText: '', prefixIcon: const Icon(Icons.lock_outline_rounded), ), ), const SizedBox(height: 16), Align( alignment: Alignment.centerRight, - child: TextButton( - onPressed: () {}, - child: Text(context.t('forgot.resend')), + child: CountdownButton( + label: context.t('forgot.resend'), + onPressed: _handleResend, ), ), const SizedBox(height: 8), GenexButton( label: context.t('forgot.next'), - onPressed: () => setState(() => _step = 2), + onPressed: _handleCodeNext, ), ], ); @@ -145,7 +267,10 @@ class _ForgotPasswordPageState extends State { const SizedBox(height: 24), Text(context.t('forgot.setNewPassword'), style: AppTypography.h1), const SizedBox(height: 8), - Text(context.t('forgot.newPasswordHint'), style: AppTypography.bodyMedium.copyWith(color: AppColors.textSecondary)), + Text( + context.t('forgot.newPasswordHint'), + style: AppTypography.bodyMedium.copyWith(color: AppColors.textSecondary), + ), const SizedBox(height: 32), TextField( controller: _passwordController, @@ -175,7 +300,8 @@ class _ForgotPasswordPageState extends State { const SizedBox(height: 24), GenexButton( label: context.t('forgot.confirmChange'), - onPressed: () => setState(() => _step = 3), + isLoading: _loading, + onPressed: _handleResetPassword, ), ], ); @@ -198,7 +324,10 @@ class _ForgotPasswordPageState extends State { const SizedBox(height: 24), Text(context.t('forgot.success'), style: AppTypography.h1), const SizedBox(height: 8), - Text(context.t('forgot.successHint'), style: AppTypography.bodyMedium.copyWith(color: AppColors.textSecondary)), + Text( + context.t('forgot.successHint'), + style: AppTypography.bodyMedium.copyWith(color: AppColors.textSecondary), + ), const SizedBox(height: 40), SizedBox( width: double.infinity, diff --git a/frontend/genex-mobile/lib/features/auth/presentation/pages/login_page.dart b/frontend/genex-mobile/lib/features/auth/presentation/pages/login_page.dart index dc6d887..30af2f8 100644 --- a/frontend/genex-mobile/lib/features/auth/presentation/pages/login_page.dart +++ b/frontend/genex-mobile/lib/features/auth/presentation/pages/login_page.dart @@ -1,11 +1,14 @@ import 'package:flutter/material.dart'; +import 'package:dio/dio.dart'; import '../../../../app/theme/app_colors.dart'; import '../../../../app/theme/app_typography.dart'; import '../../../../app/theme/app_spacing.dart'; import '../../../../shared/widgets/genex_button.dart'; +import '../../../../shared/widgets/countdown_button.dart'; +import '../../../../core/services/auth_service.dart'; import '../../../../app/i18n/app_localizations.dart'; -/// A1. 登录页 - 手机号/邮箱+密码 / 验证码快捷登录 +/// A1. 登录页 - 密码登录 / 验证码快捷登录 class LoginPage extends StatefulWidget { const LoginPage({super.key}); @@ -17,13 +20,21 @@ class _LoginPageState extends State with SingleTickerProviderStateMix late TabController _tabController; final _phoneController = TextEditingController(); final _passwordController = TextEditingController(); + final _smsPhoneController = TextEditingController(); final _codeController = TextEditingController(); bool _obscurePassword = true; + bool _loading = false; + String? _errorMessage; + + final _authService = AuthService.instance; @override void initState() { super.initState(); _tabController = TabController(length: 2, vsync: this); + _tabController.addListener(() { + if (mounted) setState(() => _errorMessage = null); + }); } @override @@ -31,10 +42,96 @@ class _LoginPageState extends State with SingleTickerProviderStateMix _tabController.dispose(); _phoneController.dispose(); _passwordController.dispose(); + _smsPhoneController.dispose(); _codeController.dispose(); super.dispose(); } + void _showError(String message) { + setState(() => _errorMessage = message); + } + + String _extractError(DioException e) { + final data = e.response?.data; + if (data is Map && data['message'] != null) { + return data['message'].toString(); + } + return context.t('login.networkError'); + } + + /* ── 密码登录 ── */ + Future _handlePasswordLogin() async { + final identifier = _phoneController.text.trim(); + final password = _passwordController.text; + + if (identifier.isEmpty) { + _showError(context.t('login.errorPhoneRequired')); + return; + } + if (password.isEmpty || password.length < 8) { + _showError(context.t('login.errorPasswordMin')); + return; + } + + setState(() { _loading = true; _errorMessage = null; }); + try { + await _authService.loginByPassword( + identifier: identifier, + password: password, + ); + if (mounted) Navigator.pushReplacementNamed(context, '/main'); + } on DioException catch (e) { + _showError(_extractError(e)); + } catch (e) { + _showError(e.toString()); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + /* ── 发送 SMS 验证码 ── */ + Future _handleSendLoginCode() async { + final phone = _smsPhoneController.text.trim(); + if (phone.isEmpty) { + _showError(context.t('login.errorPhoneRequired')); + throw Exception('phone required'); // 阻止倒计时启动 + } + setState(() => _errorMessage = null); + try { + await _authService.sendSmsCode(phone, SmsCodeType.login); + } on DioException catch (e) { + _showError(_extractError(e)); + rethrow; + } + } + + /* ── 验证码登录 ── */ + Future _handleCodeLogin() async { + final phone = _smsPhoneController.text.trim(); + final code = _codeController.text.trim(); + + if (phone.isEmpty) { + _showError(context.t('login.errorPhoneRequired')); + return; + } + if (code.length != 6) { + _showError(context.t('login.errorCodeInvalid')); + return; + } + + setState(() { _loading = true; _errorMessage = null; }); + try { + await _authService.loginByPhone(phone: phone, smsCode: code); + if (mounted) Navigator.pushReplacementNamed(context, '/main'); + } on DioException catch (e) { + _showError(_extractError(e)); + } catch (e) { + _showError(e.toString()); + } finally { + if (mounted) setState(() => _loading = false); + } + } + @override Widget build(BuildContext context) { return Scaffold( @@ -85,6 +182,31 @@ class _LoginPageState extends State with SingleTickerProviderStateMix ), const SizedBox(height: 24), + // Error message + if (_errorMessage != null) ...[ + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: AppColors.errorLight, + borderRadius: AppSpacing.borderRadiusSm, + ), + child: Row( + children: [ + const Icon(Icons.error_outline, color: AppColors.error, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( + _errorMessage!, + style: AppTypography.bodySmall.copyWith(color: AppColors.error), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + ], + Expanded( child: TabBarView( controller: _tabController, @@ -94,6 +216,26 @@ class _LoginPageState extends State with SingleTickerProviderStateMix ], ), ), + + // Register link + Center( + child: GestureDetector( + onTap: () => Navigator.pushNamed(context, '/register'), + child: RichText( + text: TextSpan( + style: AppTypography.bodySmall.copyWith(color: AppColors.textSecondary), + children: [ + TextSpan(text: context.t('login.noAccount')), + TextSpan( + text: context.t('login.registerNow'), + style: const TextStyle(color: AppColors.primary, fontWeight: FontWeight.w600), + ), + ], + ), + ), + ), + ), + const SizedBox(height: 16), ], ), ), @@ -102,113 +244,103 @@ class _LoginPageState extends State with SingleTickerProviderStateMix } Widget _buildPasswordLogin() { - return Column( - children: [ - // Phone/Email Input - TextField( - controller: _phoneController, - keyboardType: TextInputType.phone, - decoration: InputDecoration( - hintText: context.t('login.phoneOrEmail'), - prefixIcon: const Icon(Icons.person_outline_rounded, color: AppColors.textTertiary), - ), - ), - const SizedBox(height: 16), - - // Password Input - TextField( - controller: _passwordController, - obscureText: _obscurePassword, - decoration: InputDecoration( - hintText: context.t('login.password'), - prefixIcon: const Icon(Icons.lock_outline_rounded, color: AppColors.textTertiary), - suffixIcon: IconButton( - icon: Icon( - _obscurePassword ? Icons.visibility_off_outlined : Icons.visibility_outlined, - color: AppColors.textTertiary, - size: 20, - ), - onPressed: () => setState(() => _obscurePassword = !_obscurePassword), + return SingleChildScrollView( + child: Column( + children: [ + TextField( + controller: _phoneController, + keyboardType: TextInputType.phone, + decoration: InputDecoration( + hintText: context.t('login.phoneOrEmail'), + prefixIcon: const Icon(Icons.person_outline_rounded, color: AppColors.textTertiary), ), ), - ), - const SizedBox(height: 12), + const SizedBox(height: 16), - // Forgot Password - Align( - alignment: Alignment.centerRight, - child: GestureDetector( - onTap: () { - Navigator.pushNamed(context, '/forgot-password'); - }, - child: Text(context.t('login.forgotPassword'), style: AppTypography.labelSmall.copyWith( - color: AppColors.primary, - )), + TextField( + controller: _passwordController, + obscureText: _obscurePassword, + decoration: InputDecoration( + hintText: context.t('login.password'), + prefixIcon: const Icon(Icons.lock_outline_rounded, color: AppColors.textTertiary), + suffixIcon: IconButton( + icon: Icon( + _obscurePassword ? Icons.visibility_off_outlined : Icons.visibility_outlined, + color: AppColors.textTertiary, + size: 20, + ), + onPressed: () => setState(() => _obscurePassword = !_obscurePassword), + ), + ), ), - ), - const SizedBox(height: 24), + const SizedBox(height: 12), - // Login Button - GenexButton( - label: context.t('login.submit'), - onPressed: () { - Navigator.pushReplacementNamed(context, '/main'); - }, - ), - ], + Align( + alignment: Alignment.centerRight, + child: GestureDetector( + onTap: () => Navigator.pushNamed(context, '/forgot-password'), + child: Text( + context.t('login.forgotPassword'), + style: AppTypography.labelSmall.copyWith(color: AppColors.primary), + ), + ), + ), + const SizedBox(height: 24), + + GenexButton( + label: context.t('login.submit'), + isLoading: _loading, + onPressed: _handlePasswordLogin, + ), + ], + ), ); } Widget _buildCodeLogin() { - return Column( - children: [ - // Phone Input - TextField( - keyboardType: TextInputType.phone, - decoration: InputDecoration( - hintText: context.t('login.phone'), - prefixIcon: const Icon(Icons.phone_android_rounded, color: AppColors.textTertiary), + return SingleChildScrollView( + child: Column( + children: [ + TextField( + controller: _smsPhoneController, + keyboardType: TextInputType.phone, + decoration: InputDecoration( + hintText: context.t('login.phone'), + prefixIcon: const Icon(Icons.phone_android_rounded, color: AppColors.textTertiary), + ), ), - ), - const SizedBox(height: 16), + const SizedBox(height: 16), - // Code Input + Send Button - Row( - children: [ - Expanded( - child: TextField( - controller: _codeController, - keyboardType: TextInputType.number, - decoration: InputDecoration( - hintText: context.t('login.verifyCode'), - prefixIcon: const Icon(Icons.shield_outlined, color: AppColors.textTertiary), + Row( + children: [ + Expanded( + child: TextField( + controller: _codeController, + keyboardType: TextInputType.number, + maxLength: 6, + decoration: InputDecoration( + hintText: context.t('login.verifyCode'), + counterText: '', + prefixIcon: const Icon(Icons.shield_outlined, color: AppColors.textTertiary), + ), ), ), - ), - const SizedBox(width: 12), - SizedBox( - height: AppSpacing.inputHeight, - child: GenexButton( + const SizedBox(width: 12), + CountdownButton( label: context.t('login.getCode'), - variant: GenexButtonVariant.secondary, - size: GenexButtonSize.medium, - fullWidth: false, - onPressed: () { - // SMS: send verification code - }, + onPressed: _handleSendLoginCode, ), - ), - ], - ), - const SizedBox(height: 24), + ], + ), + const SizedBox(height: 24), - GenexButton( - label: context.t('login.submit'), - onPressed: () { - Navigator.pushReplacementNamed(context, '/main'); - }, - ), - ], + GenexButton( + label: context.t('login.submit'), + isLoading: _loading, + onPressed: _handleCodeLogin, + ), + ], + ), ); } } diff --git a/frontend/genex-mobile/lib/features/auth/presentation/pages/register_page.dart b/frontend/genex-mobile/lib/features/auth/presentation/pages/register_page.dart index deb067e..8f58c29 100644 --- a/frontend/genex-mobile/lib/features/auth/presentation/pages/register_page.dart +++ b/frontend/genex-mobile/lib/features/auth/presentation/pages/register_page.dart @@ -1,14 +1,16 @@ import 'package:flutter/material.dart'; +import 'package:dio/dio.dart'; import '../../../../app/theme/app_colors.dart'; import '../../../../app/theme/app_typography.dart'; import '../../../../app/theme/app_spacing.dart'; import '../../../../shared/widgets/genex_button.dart'; +import '../../../../shared/widgets/countdown_button.dart'; +import '../../../../core/services/auth_service.dart'; import '../../../../app/i18n/app_localizations.dart'; /// A1. 手机号注册页 /// -/// 手机号输入、获取验证码、设置密码、用户协议勾选 -/// 注册成功后后台静默创建MPC钱包 +/// 3步流程: 输入手机号+验证码 → 设置密码 → 完成注册 class RegisterPage extends StatefulWidget { final bool isEmail; @@ -24,6 +26,16 @@ class _RegisterPageState extends State { final _passwordController = TextEditingController(); bool _obscurePassword = true; bool _agreeTerms = false; + bool _loading = false; + String? _errorMessage; + + final _authService = AuthService.instance; + + @override + void initState() { + super.initState(); + _passwordController.addListener(() => setState(() {})); + } @override void dispose() { @@ -33,6 +45,72 @@ class _RegisterPageState extends State { super.dispose(); } + String _extractError(DioException e) { + final data = e.response?.data; + if (data is Map && data['message'] != null) { + return data['message'].toString(); + } + return context.t('login.networkError'); + } + + /* ── 发送注册验证码 ── */ + Future _handleSendCode() async { + final phone = _accountController.text.trim(); + if (phone.isEmpty) { + setState(() => _errorMessage = context.t('register.errorPhoneRequired')); + throw Exception('phone required'); + } + setState(() => _errorMessage = null); + try { + await _authService.sendSmsCode(phone, SmsCodeType.register); + } on DioException catch (e) { + setState(() => _errorMessage = _extractError(e)); + rethrow; + } + } + + /* ── 提交注册 ── */ + Future _handleRegister() async { + final phone = _accountController.text.trim(); + final code = _codeController.text.trim(); + final password = _passwordController.text; + + if (phone.isEmpty) { + setState(() => _errorMessage = context.t('register.errorPhoneRequired')); + return; + } + if (code.length != 6) { + setState(() => _errorMessage = context.t('register.errorCodeInvalid')); + return; + } + if (password.length < 8 || + !RegExp(r'[a-zA-Z]').hasMatch(password) || + !RegExp(r'\d').hasMatch(password)) { + setState(() => _errorMessage = context.t('register.errorPasswordWeak')); + return; + } + if (!_agreeTerms) { + setState(() => _errorMessage = context.t('register.errorTermsRequired')); + return; + } + + setState(() { _loading = true; _errorMessage = null; }); + try { + await _authService.register( + phone: phone, + smsCode: code, + password: password, + ); + if (mounted) Navigator.pushReplacementNamed(context, '/main'); + } on DioException catch (e) { + setState(() => _errorMessage = _extractError(e)); + } catch (e) { + setState(() => _errorMessage = e.toString()); + } finally { + if (mounted) setState(() => _loading = false); + } + } + @override Widget build(BuildContext context) { return Scaffold( @@ -52,7 +130,9 @@ class _RegisterPageState extends State { Text(context.t('register.title'), style: AppTypography.displayMedium), const SizedBox(height: 8), Text( - widget.isEmail ? context.t('register.emailSubtitle') : context.t('register.phoneSubtitle'), + widget.isEmail + ? context.t('register.emailSubtitle') + : context.t('register.phoneSubtitle'), style: AppTypography.bodyLarge.copyWith(color: AppColors.textSecondary), ), const SizedBox(height: 40), @@ -61,9 +141,36 @@ class _RegisterPageState extends State { _buildStepIndicator(), const SizedBox(height: 32), - // Account Input (Phone/Email) + // Error message + if (_errorMessage != null) ...[ + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: AppColors.errorLight, + borderRadius: AppSpacing.borderRadiusSm, + ), + child: Row( + children: [ + const Icon(Icons.error_outline, color: AppColors.error, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( + _errorMessage!, + style: AppTypography.bodySmall.copyWith(color: AppColors.error), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + ], + + // Step 1: Phone + Code Text( - widget.isEmail ? context.t('register.email') : context.t('register.phone'), + widget.isEmail + ? context.t('register.email') + : context.t('register.phone'), style: AppTypography.labelMedium, ), const SizedBox(height: 8), @@ -72,7 +179,9 @@ class _RegisterPageState extends State { keyboardType: widget.isEmail ? TextInputType.emailAddress : TextInputType.phone, decoration: InputDecoration( - hintText: widget.isEmail ? context.t('register.emailHint') : context.t('register.phoneHint'), + hintText: widget.isEmail + ? context.t('register.emailHint') + : context.t('register.phoneHint'), prefixIcon: Icon( widget.isEmail ? Icons.email_outlined : Icons.phone_android_rounded, color: AppColors.textTertiary, @@ -81,7 +190,6 @@ class _RegisterPageState extends State { ), const SizedBox(height: 20), - // Verification Code Text(context.t('register.code'), style: AppTypography.labelMedium), const SizedBox(height: 8), Row( @@ -99,21 +207,15 @@ class _RegisterPageState extends State { ), ), const SizedBox(width: 12), - SizedBox( - height: AppSpacing.inputHeight, - child: GenexButton( - label: context.t('register.getCode'), - variant: GenexButtonVariant.secondary, - size: GenexButtonSize.medium, - fullWidth: false, - onPressed: () {}, - ), + CountdownButton( + label: context.t('register.getCode'), + onPressed: _handleSendCode, ), ], ), const SizedBox(height: 20), - // Password + // Step 2: Password Text(context.t('register.setPassword'), style: AppTypography.labelMedium), const SizedBox(height: 8), TextField( @@ -138,7 +240,7 @@ class _RegisterPageState extends State { _buildPasswordStrength(), const SizedBox(height: 32), - // Terms Agreement + // Step 3: Terms + Submit Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -179,12 +281,30 @@ class _RegisterPageState extends State { ), const SizedBox(height: 32), - // Register Button GenexButton( label: context.t('register.submit'), - onPressed: _agreeTerms ? () { - Navigator.pushReplacementNamed(context, '/main'); - } : null, + isLoading: _loading, + onPressed: _agreeTerms ? _handleRegister : null, + ), + + // Login link + const SizedBox(height: 24), + Center( + child: GestureDetector( + onTap: () => Navigator.pushReplacementNamed(context, '/login'), + child: RichText( + text: TextSpan( + style: AppTypography.bodySmall.copyWith(color: AppColors.textSecondary), + children: [ + TextSpan(text: context.t('register.hasAccount')), + TextSpan( + text: context.t('register.loginNow'), + style: const TextStyle(color: AppColors.primary, fontWeight: FontWeight.w600), + ), + ], + ), + ), + ), ), const SizedBox(height: 40), ], @@ -195,13 +315,16 @@ class _RegisterPageState extends State { } Widget _buildStepIndicator() { + final hasCode = _codeController.text.length == 6; + final hasPassword = _passwordController.text.length >= 8; + return Row( children: [ _buildStep(1, context.t('register.stepVerify'), true), - _buildStepLine(true), - _buildStep(2, context.t('register.stepPassword'), true), - _buildStepLine(false), - _buildStep(3, context.t('register.stepDone'), false), + _buildStepLine(hasCode), + _buildStep(2, context.t('register.stepPassword'), hasCode), + _buildStepLine(hasCode && hasPassword), + _buildStep(3, context.t('register.stepDone'), hasCode && hasPassword), ], ); } diff --git a/frontend/genex-mobile/lib/shared/widgets/countdown_button.dart b/frontend/genex-mobile/lib/shared/widgets/countdown_button.dart new file mode 100644 index 0000000..fc6560d --- /dev/null +++ b/frontend/genex-mobile/lib/shared/widgets/countdown_button.dart @@ -0,0 +1,102 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import '../../app/theme/app_colors.dart'; +import '../../app/theme/app_typography.dart'; +import '../../app/theme/app_spacing.dart'; + +/// 短信验证码倒计时按钮 +/// +/// - 初始: 显示 [label] (如 "获取验证码") +/// - 点击后: 显示 "XXs" 倒计时,禁用 +/// - 倒计时结束: 恢复可点击 +class CountdownButton extends StatefulWidget { + final String label; + final int seconds; + final Future Function() onPressed; + final bool enabled; + + const CountdownButton({ + super.key, + required this.label, + this.seconds = 60, + required this.onPressed, + this.enabled = true, + }); + + @override + State createState() => _CountdownButtonState(); +} + +class _CountdownButtonState extends State { + Timer? _timer; + int _remaining = 0; + bool _loading = false; + + bool get _isCounting => _remaining > 0; + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + Future _handlePress() async { + if (_isCounting || _loading || !widget.enabled) return; + + setState(() => _loading = true); + try { + await widget.onPressed(); + _startCountdown(); + } catch (_) { + // 发送失败不启动倒计时,让用户重试 + } finally { + if (mounted) setState(() => _loading = false); + } + } + + void _startCountdown() { + setState(() => _remaining = widget.seconds); + _timer?.cancel(); + _timer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (!mounted) { + timer.cancel(); + return; + } + setState(() { + _remaining--; + if (_remaining <= 0) { + timer.cancel(); + } + }); + }); + } + + @override + Widget build(BuildContext context) { + final disabled = _isCounting || _loading || !widget.enabled; + final text = _isCounting ? '${_remaining}s' : widget.label; + + return SizedBox( + height: AppSpacing.buttonHeightSm, + child: TextButton( + onPressed: disabled ? null : _handlePress, + style: TextButton.styleFrom( + foregroundColor: AppColors.primary, + disabledForegroundColor: AppColors.textTertiary, + padding: const EdgeInsets.symmetric(horizontal: 16), + shape: RoundedRectangleBorder( + borderRadius: AppSpacing.borderRadiusSm, + ), + textStyle: AppTypography.labelMedium, + ), + child: _loading + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(text), + ), + ); + } +} diff --git a/frontend/genex-mobile/lib/shared/widgets/otp_input.dart b/frontend/genex-mobile/lib/shared/widgets/otp_input.dart new file mode 100644 index 0000000..c9347f9 --- /dev/null +++ b/frontend/genex-mobile/lib/shared/widgets/otp_input.dart @@ -0,0 +1,160 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import '../../app/theme/app_colors.dart'; +import '../../app/theme/app_spacing.dart'; + +/// 6 位 OTP 验证码输入组件 +/// +/// 特性: 自动跳转、支持粘贴、错误状态、自动聚焦 +class OtpInput extends StatefulWidget { + final int length; + final ValueChanged onCompleted; + final ValueChanged? onChanged; + final bool hasError; + final bool autofocus; + + const OtpInput({ + super.key, + this.length = 6, + required this.onCompleted, + this.onChanged, + this.hasError = false, + this.autofocus = true, + }); + + @override + State createState() => _OtpInputState(); +} + +class _OtpInputState extends State { + late List _controllers; + late List _focusNodes; + + @override + void initState() { + super.initState(); + _controllers = List.generate(widget.length, (_) => TextEditingController()); + _focusNodes = List.generate(widget.length, (_) => FocusNode()); + } + + @override + void dispose() { + for (final c in _controllers) { + c.dispose(); + } + for (final n in _focusNodes) { + n.dispose(); + } + super.dispose(); + } + + String get _code => _controllers.map((c) => c.text).join(); + + void _onChanged(int index, String value) { + // 粘贴完整验证码 + if (value.length > 1) { + final digits = value.replaceAll(RegExp(r'[^\d]'), ''); + for (int i = 0; i < widget.length && i < digits.length; i++) { + _controllers[i].text = digits[i]; + } + final focusIdx = digits.length.clamp(0, widget.length - 1); + _focusNodes[focusIdx].requestFocus(); + _notifyChange(); + return; + } + + if (value.isNotEmpty && index < widget.length - 1) { + _focusNodes[index + 1].requestFocus(); + } + _notifyChange(); + } + + void _onKeyEvent(int index, KeyEvent event) { + if (event is KeyDownEvent && + event.logicalKey == LogicalKeyboardKey.backspace && + _controllers[index].text.isEmpty && + index > 0) { + _controllers[index - 1].clear(); + _focusNodes[index - 1].requestFocus(); + _notifyChange(); + } + } + + void _notifyChange() { + final code = _code; + widget.onChanged?.call(code); + if (code.length == widget.length) { + widget.onCompleted(code); + } + } + + void clear() { + for (final c in _controllers) { + c.clear(); + } + _focusNodes[0].requestFocus(); + } + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(widget.length, (i) { + final isActive = _focusNodes[i].hasFocus; + final hasFilled = _controllers[i].text.isNotEmpty; + + return Container( + width: 48, + height: 56, + margin: EdgeInsets.symmetric(horizontal: i == 0 || i == widget.length - 1 ? 0 : 4), + child: KeyboardListener( + focusNode: FocusNode(), + onKeyEvent: (event) => _onKeyEvent(i, event), + child: TextField( + controller: _controllers[i], + focusNode: _focusNodes[i], + autofocus: widget.autofocus && i == 0, + textAlign: TextAlign.center, + keyboardType: TextInputType.number, + maxLength: 1, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(1), + ], + decoration: InputDecoration( + counterText: '', + contentPadding: EdgeInsets.zero, + filled: true, + fillColor: widget.hasError + ? AppColors.errorLight + : hasFilled + ? AppColors.primarySurface + : AppColors.gray50, + enabledBorder: OutlineInputBorder( + borderRadius: AppSpacing.borderRadiusMd, + borderSide: BorderSide( + color: widget.hasError ? AppColors.error : AppColors.border, + width: 1.5, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: AppSpacing.borderRadiusMd, + borderSide: BorderSide( + color: widget.hasError ? AppColors.error : AppColors.primary, + width: 2, + ), + ), + ), + onChanged: (v) => _onChanged(i, v), + ), + ), + ); + }), + ); + } +}