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:
parent
e89ec82406
commit
4b1cdf9fb3
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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': 'クーポン、ブランド、カテゴリを検索...',
|
||||
|
|
|
|||
|
|
@ -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': '搜索券、品牌、分类...',
|
||||
|
|
|
|||
|
|
@ -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': '搜尋券、品牌、分類...',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue