feat(genex-mobile): Flutter 前端对接 SMS 认证 API

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<AuthResult?> 状态管理

页面重写 (对接真实 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 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-02-23 19:23:25 -08:00
parent e89ec82406
commit 4b1cdf9fb3
10 changed files with 1026 additions and 134 deletions

View File

@ -49,6 +49,12 @@ const Map<String, String> 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',

View File

@ -49,6 +49,12 @@ const Map<String, String> 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<String, String> 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<String, String> ja = {
'forgot.success': 'パスワードの変更が完了しました',
'forgot.successHint': '新しいパスワードでログインしてください',
'forgot.backToLogin': 'ログインに戻る',
'forgot.errorPasswordMismatch': 'パスワードが一致しません',
// ============ Home ============
'home.searchHint': 'クーポン、ブランド、カテゴリを検索...',

View File

@ -49,6 +49,12 @@ const Map<String, String> 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<String, String> 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<String, String> zhCN = {
'forgot.success': '密码修改成功',
'forgot.successHint': '请使用新密码登录',
'forgot.backToLogin': '返回登录',
'forgot.errorPasswordMismatch': '两次输入的密码不一致',
// ============ Home ============
'home.searchHint': '搜索券、品牌、分类...',

View File

@ -49,6 +49,12 @@ const Map<String, String> 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<String, String> 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<String, String> zhTW = {
'forgot.success': '密碼修改成功',
'forgot.successHint': '請使用新密碼登入',
'forgot.backToLogin': '返回登入',
'forgot.errorPasswordMismatch': '兩次輸入的密碼不一致',
// ============ Home ============
'home.searchHint': '搜尋券、品牌、分類...',

View File

@ -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<String, dynamic> 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<String, dynamic> json) {
final tokens = json['tokens'] as Map<String, dynamic>;
return AuthResult(
user: json['user'] as Map<String, dynamic>,
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<AuthResult?> authState = ValueNotifier(null);
bool get isLoggedIn => authState.value != null;
/* ── SMS 验证码 ── */
///
/// expiresIn ()
Future<int> 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<String, dynamic>;
return data['expiresIn'] as int;
}
/* ── 注册 ── */
/// ( REGISTER )
Future<AuthResult> 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<AuthResult> 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<AuthResult> 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<void> 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<void> 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<void> changePhone({
required String newPhone,
required String newSmsCode,
}) async {
await _api.post('/api/v1/auth/change-phone', data: {
'newPhone': newPhone,
'newSmsCode': newSmsCode,
});
}
/* ── Token 管理 ── */
/// Token
Future<void> 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<String, dynamic>;
final newResult = AuthResult(
user: current.user,
accessToken: tokens['accessToken'] as String,
refreshToken: tokens['refreshToken'] as String,
expiresIn: tokens['expiresIn'] as int,
);
_setAuth(newResult);
}
///
Future<void> 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);
}
}

View File

@ -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<ForgotPasswordPage> {
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<ForgotPasswordPage> {
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<void> _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<void> _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<void> _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<ForgotPasswordPage> {
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<ForgotPasswordPage> {
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<ForgotPasswordPage> {
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<ForgotPasswordPage> {
),
),
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<ForgotPasswordPage> {
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<ForgotPasswordPage> {
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<ForgotPasswordPage> {
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<ForgotPasswordPage> {
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,

View File

@ -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<LoginPage> 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<LoginPage> 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<void> _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<void> _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<void> _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<LoginPage> 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<LoginPage> 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,9 +244,9 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
}
Widget _buildPasswordLogin() {
return Column(
return SingleChildScrollView(
child: Column(
children: [
// Phone/Email Input
TextField(
controller: _phoneController,
keyboardType: TextInputType.phone,
@ -115,7 +257,6 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
),
const SizedBox(height: 16),
// Password Input
TextField(
controller: _passwordController,
obscureText: _obscurePassword,
@ -134,36 +275,34 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
),
const SizedBox(height: 12),
// 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,
)),
onTap: () => Navigator.pushNamed(context, '/forgot-password'),
child: Text(
context.t('login.forgotPassword'),
style: AppTypography.labelSmall.copyWith(color: AppColors.primary),
),
),
),
const SizedBox(height: 24),
// Login Button
GenexButton(
label: context.t('login.submit'),
onPressed: () {
Navigator.pushReplacementNamed(context, '/main');
},
isLoading: _loading,
onPressed: _handlePasswordLogin,
),
],
),
);
}
Widget _buildCodeLogin() {
return Column(
return SingleChildScrollView(
child: Column(
children: [
// Phone Input
TextField(
controller: _smsPhoneController,
keyboardType: TextInputType.phone,
decoration: InputDecoration(
hintText: context.t('login.phone'),
@ -172,31 +311,24 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
),
const SizedBox(height: 16),
// Code Input + Send Button
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(
CountdownButton(
label: context.t('login.getCode'),
variant: GenexButtonVariant.secondary,
size: GenexButtonSize.medium,
fullWidth: false,
onPressed: () {
// SMS: send verification code
},
),
onPressed: _handleSendLoginCode,
),
],
),
@ -204,11 +336,11 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
GenexButton(
label: context.t('login.submit'),
onPressed: () {
Navigator.pushReplacementNamed(context, '/main');
},
isLoading: _loading,
onPressed: _handleCodeLogin,
),
],
),
);
}
}

View File

@ -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<RegisterPage> {
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<RegisterPage> {
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<void> _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<void> _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<RegisterPage> {
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<RegisterPage> {
_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<RegisterPage> {
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<RegisterPage> {
),
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<RegisterPage> {
),
),
const SizedBox(width: 12),
SizedBox(
height: AppSpacing.inputHeight,
child: GenexButton(
CountdownButton(
label: context.t('register.getCode'),
variant: GenexButtonVariant.secondary,
size: GenexButtonSize.medium,
fullWidth: false,
onPressed: () {},
),
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<RegisterPage> {
_buildPasswordStrength(),
const SizedBox(height: 32),
// Terms Agreement
// Step 3: Terms + Submit
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -179,12 +281,30 @@ class _RegisterPageState extends State<RegisterPage> {
),
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<RegisterPage> {
}
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),
],
);
}

View File

@ -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<void> Function() onPressed;
final bool enabled;
const CountdownButton({
super.key,
required this.label,
this.seconds = 60,
required this.onPressed,
this.enabled = true,
});
@override
State<CountdownButton> createState() => _CountdownButtonState();
}
class _CountdownButtonState extends State<CountdownButton> {
Timer? _timer;
int _remaining = 0;
bool _loading = false;
bool get _isCounting => _remaining > 0;
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
Future<void> _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),
),
);
}
}

View File

@ -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<String> onCompleted;
final ValueChanged<String>? 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<OtpInput> createState() => _OtpInputState();
}
class _OtpInputState extends State<OtpInput> {
late List<TextEditingController> _controllers;
late List<FocusNode> _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),
),
),
);
}),
);
}
}