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.phone': 'Phone',
'login.verifyCode': 'Code', 'login.verifyCode': 'Code',
'login.getCode': 'Get 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.title': 'Create Account',
'register.emailSubtitle': 'Sign up with your email', 'register.emailSubtitle': 'Sign up with your email',

View File

@ -49,6 +49,12 @@ const Map<String, String> ja = {
'login.phone': '電話番号', 'login.phone': '電話番号',
'login.verifyCode': '認証コード', 'login.verifyCode': '認証コード',
'login.getCode': '認証コードを取得', 'login.getCode': '認証コードを取得',
'login.noAccount': 'アカウントをお持ちでないですか?',
'login.registerNow': '新規登録',
'login.networkError': 'ネットワークエラーです。後でもう一度お試しください',
'login.errorPhoneRequired': '電話番号を入力してください',
'login.errorPasswordMin': 'パスワードは8文字以上必要です',
'login.errorCodeInvalid': '6桁の認証コードを入力してください',
'register.title': 'アカウント作成', 'register.title': 'アカウント作成',
'register.emailSubtitle': 'メールアドレスで Genex アカウントを登録', 'register.emailSubtitle': 'メールアドレスで Genex アカウントを登録',
@ -72,6 +78,12 @@ const Map<String, String> ja = {
'register.rule8chars': '8文字以上', 'register.rule8chars': '8文字以上',
'register.ruleLetter': '英字を含む', 'register.ruleLetter': '英字を含む',
'register.ruleNumber': '数字を含む', 'register.ruleNumber': '数字を含む',
'register.hasAccount': 'アカウントをお持ちですか?',
'register.loginNow': 'ログイン',
'register.errorPhoneRequired': '電話番号を入力してください',
'register.errorCodeInvalid': '6桁の認証コードを入力してください',
'register.errorPasswordWeak': 'パスワードは8文字以上で英字と数字を含む必要があります',
'register.errorTermsRequired': '利用規約に同意してください',
'forgot.title': 'パスワード再設定', 'forgot.title': 'パスワード再設定',
'forgot.inputAccount': '電話番号またはメールを入力', 'forgot.inputAccount': '電話番号またはメールを入力',
@ -91,6 +103,7 @@ const Map<String, String> ja = {
'forgot.success': 'パスワードの変更が完了しました', 'forgot.success': 'パスワードの変更が完了しました',
'forgot.successHint': '新しいパスワードでログインしてください', 'forgot.successHint': '新しいパスワードでログインしてください',
'forgot.backToLogin': 'ログインに戻る', 'forgot.backToLogin': 'ログインに戻る',
'forgot.errorPasswordMismatch': 'パスワードが一致しません',
// ============ Home ============ // ============ Home ============
'home.searchHint': 'クーポン、ブランド、カテゴリを検索...', 'home.searchHint': 'クーポン、ブランド、カテゴリを検索...',

View File

@ -49,6 +49,12 @@ const Map<String, String> zhCN = {
'login.phone': '手机号', 'login.phone': '手机号',
'login.verifyCode': '验证码', 'login.verifyCode': '验证码',
'login.getCode': '获取验证码', 'login.getCode': '获取验证码',
'login.noAccount': '还没有账号?',
'login.registerNow': '立即注册',
'login.networkError': '网络错误,请稍后重试',
'login.errorPhoneRequired': '请输入手机号',
'login.errorPasswordMin': '密码不能少于8位',
'login.errorCodeInvalid': '请输入6位验证码',
'register.title': '创建账号', 'register.title': '创建账号',
'register.emailSubtitle': '使用邮箱注册券信账号', 'register.emailSubtitle': '使用邮箱注册券信账号',
@ -72,6 +78,12 @@ const Map<String, String> zhCN = {
'register.rule8chars': '8位以上', 'register.rule8chars': '8位以上',
'register.ruleLetter': '含字母', 'register.ruleLetter': '含字母',
'register.ruleNumber': '含数字', 'register.ruleNumber': '含数字',
'register.hasAccount': '已有账号?',
'register.loginNow': '立即登录',
'register.errorPhoneRequired': '请输入手机号',
'register.errorCodeInvalid': '请输入6位验证码',
'register.errorPasswordWeak': '密码需要8位以上且包含字母和数字',
'register.errorTermsRequired': '请先阅读并同意用户协议',
'forgot.title': '找回密码', 'forgot.title': '找回密码',
'forgot.inputAccount': '输入手机号或邮箱', 'forgot.inputAccount': '输入手机号或邮箱',
@ -91,6 +103,7 @@ const Map<String, String> zhCN = {
'forgot.success': '密码修改成功', 'forgot.success': '密码修改成功',
'forgot.successHint': '请使用新密码登录', 'forgot.successHint': '请使用新密码登录',
'forgot.backToLogin': '返回登录', 'forgot.backToLogin': '返回登录',
'forgot.errorPasswordMismatch': '两次输入的密码不一致',
// ============ Home ============ // ============ Home ============
'home.searchHint': '搜索券、品牌、分类...', 'home.searchHint': '搜索券、品牌、分类...',

View File

@ -49,6 +49,12 @@ const Map<String, String> zhTW = {
'login.phone': '手機號', 'login.phone': '手機號',
'login.verifyCode': '驗證碼', 'login.verifyCode': '驗證碼',
'login.getCode': '取得驗證碼', 'login.getCode': '取得驗證碼',
'login.noAccount': '還沒有帳號?',
'login.registerNow': '立即註冊',
'login.networkError': '網路錯誤,請稍後重試',
'login.errorPhoneRequired': '請輸入手機號',
'login.errorPasswordMin': '密碼不能少於8位',
'login.errorCodeInvalid': '請輸入6位驗證碼',
'register.title': '建立帳號', 'register.title': '建立帳號',
'register.emailSubtitle': '使用信箱註冊券信帳號', 'register.emailSubtitle': '使用信箱註冊券信帳號',
@ -72,6 +78,12 @@ const Map<String, String> zhTW = {
'register.rule8chars': '8位以上', 'register.rule8chars': '8位以上',
'register.ruleLetter': '含字母', 'register.ruleLetter': '含字母',
'register.ruleNumber': '含數字', 'register.ruleNumber': '含數字',
'register.hasAccount': '已有帳號?',
'register.loginNow': '立即登入',
'register.errorPhoneRequired': '請輸入手機號',
'register.errorCodeInvalid': '請輸入6位驗證碼',
'register.errorPasswordWeak': '密碼需要8位以上且包含字母和數字',
'register.errorTermsRequired': '請先閱讀並同意使用者協議',
'forgot.title': '找回密碼', 'forgot.title': '找回密碼',
'forgot.inputAccount': '輸入手機號或信箱', 'forgot.inputAccount': '輸入手機號或信箱',
@ -91,6 +103,7 @@ const Map<String, String> zhTW = {
'forgot.success': '密碼修改成功', 'forgot.success': '密碼修改成功',
'forgot.successHint': '請使用新密碼登入', 'forgot.successHint': '請使用新密碼登入',
'forgot.backToLogin': '返回登入', 'forgot.backToLogin': '返回登入',
'forgot.errorPasswordMismatch': '兩次輸入的密碼不一致',
// ============ Home ============ // ============ Home ============
'home.searchHint': '搜尋券、品牌、分類...', '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:flutter/material.dart';
import 'package:dio/dio.dart';
import '../../../../app/theme/app_colors.dart'; import '../../../../app/theme/app_colors.dart';
import '../../../../app/theme/app_typography.dart'; import '../../../../app/theme/app_typography.dart';
import '../../../../app/theme/app_spacing.dart'; import '../../../../app/theme/app_spacing.dart';
import '../../../../shared/widgets/genex_button.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'; import '../../../../app/i18n/app_localizations.dart';
/// A1. - / /// A1. -
///
/// Step1 Step2 Step3 Step4
class ForgotPasswordPage extends StatefulWidget { class ForgotPasswordPage extends StatefulWidget {
const ForgotPasswordPage({super.key}); const ForgotPasswordPage({super.key});
@ -23,6 +24,10 @@ class _ForgotPasswordPageState extends State<ForgotPasswordPage> {
final _confirmController = TextEditingController(); final _confirmController = TextEditingController();
bool _obscurePassword = true; bool _obscurePassword = true;
bool _obscureConfirm = true; bool _obscureConfirm = true;
bool _loading = false;
String? _errorMessage;
final _authService = AuthService.instance;
@override @override
void dispose() { void dispose() {
@ -33,6 +38,84 @@ class _ForgotPasswordPageState extends State<ForgotPasswordPage> {
super.dispose(); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@ -44,7 +127,7 @@ class _ForgotPasswordPageState extends State<ForgotPasswordPage> {
icon: const Icon(Icons.arrow_back_ios_rounded, size: 20), icon: const Icon(Icons.arrow_back_ios_rounded, size: 20),
onPressed: () { onPressed: () {
if (_step > 0 && _step < 3) { if (_step > 0 && _step < 3) {
setState(() => _step--); setState(() { _step--; _errorMessage = null; });
} else { } else {
Navigator.pop(context); Navigator.pop(context);
} }
@ -54,7 +137,36 @@ class _ForgotPasswordPageState extends State<ForgotPasswordPage> {
body: SafeArea( body: SafeArea(
child: Padding( child: Padding(
padding: AppSpacing.pagePadding, 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), const SizedBox(height: 24),
Text(context.t('forgot.inputAccount'), style: AppTypography.h1), Text(context.t('forgot.inputAccount'), style: AppTypography.h1),
const SizedBox(height: 8), 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), const SizedBox(height: 32),
TextField( TextField(
controller: _phoneController, controller: _phoneController,
@ -93,15 +208,20 @@ class _ForgotPasswordPageState extends State<ForgotPasswordPage> {
), ),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
GenexButton( CountdownButton(
label: context.t('forgot.getCode'), label: context.t('forgot.getCode'),
onPressed: () => setState(() => _step = 1), onPressed: _handleSendCode,
), ),
], ],
); );
} }
Widget _buildStepCode() { Widget _buildStepCode() {
final phone = _phoneController.text.trim();
final masked = phone.length >= 7
? '${phone.substring(0, 3)}****${phone.substring(phone.length - 4)}'
: phone;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -109,30 +229,32 @@ class _ForgotPasswordPageState extends State<ForgotPasswordPage> {
Text(context.t('forgot.inputCode'), style: AppTypography.h1), Text(context.t('forgot.inputCode'), style: AppTypography.h1),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'${context.t('forgot.codeSentTo')} ${_phoneController.text.isNotEmpty ? _phoneController.text : '***'}', '${context.t('forgot.codeSentTo')} $masked',
style: AppTypography.bodyMedium.copyWith(color: AppColors.textSecondary), style: AppTypography.bodyMedium.copyWith(color: AppColors.textSecondary),
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
TextField( TextField(
controller: _codeController, controller: _codeController,
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
maxLength: 6,
decoration: InputDecoration( decoration: InputDecoration(
hintText: context.t('forgot.codeHint'), hintText: context.t('forgot.codeHint'),
counterText: '',
prefixIcon: const Icon(Icons.lock_outline_rounded), prefixIcon: const Icon(Icons.lock_outline_rounded),
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Align( Align(
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
child: TextButton( child: CountdownButton(
onPressed: () {}, label: context.t('forgot.resend'),
child: Text(context.t('forgot.resend')), onPressed: _handleResend,
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
GenexButton( GenexButton(
label: context.t('forgot.next'), label: context.t('forgot.next'),
onPressed: () => setState(() => _step = 2), onPressed: _handleCodeNext,
), ),
], ],
); );
@ -145,7 +267,10 @@ class _ForgotPasswordPageState extends State<ForgotPasswordPage> {
const SizedBox(height: 24), const SizedBox(height: 24),
Text(context.t('forgot.setNewPassword'), style: AppTypography.h1), Text(context.t('forgot.setNewPassword'), style: AppTypography.h1),
const SizedBox(height: 8), 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), const SizedBox(height: 32),
TextField( TextField(
controller: _passwordController, controller: _passwordController,
@ -175,7 +300,8 @@ class _ForgotPasswordPageState extends State<ForgotPasswordPage> {
const SizedBox(height: 24), const SizedBox(height: 24),
GenexButton( GenexButton(
label: context.t('forgot.confirmChange'), 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), const SizedBox(height: 24),
Text(context.t('forgot.success'), style: AppTypography.h1), Text(context.t('forgot.success'), style: AppTypography.h1),
const SizedBox(height: 8), 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), const SizedBox(height: 40),
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,

View File

@ -1,11 +1,14 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:dio/dio.dart';
import '../../../../app/theme/app_colors.dart'; import '../../../../app/theme/app_colors.dart';
import '../../../../app/theme/app_typography.dart'; import '../../../../app/theme/app_typography.dart';
import '../../../../app/theme/app_spacing.dart'; import '../../../../app/theme/app_spacing.dart';
import '../../../../shared/widgets/genex_button.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'; import '../../../../app/i18n/app_localizations.dart';
/// A1. - /+ / /// A1. - /
class LoginPage extends StatefulWidget { class LoginPage extends StatefulWidget {
const LoginPage({super.key}); const LoginPage({super.key});
@ -17,13 +20,21 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
late TabController _tabController; late TabController _tabController;
final _phoneController = TextEditingController(); final _phoneController = TextEditingController();
final _passwordController = TextEditingController(); final _passwordController = TextEditingController();
final _smsPhoneController = TextEditingController();
final _codeController = TextEditingController(); final _codeController = TextEditingController();
bool _obscurePassword = true; bool _obscurePassword = true;
bool _loading = false;
String? _errorMessage;
final _authService = AuthService.instance;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_tabController = TabController(length: 2, vsync: this); _tabController = TabController(length: 2, vsync: this);
_tabController.addListener(() {
if (mounted) setState(() => _errorMessage = null);
});
} }
@override @override
@ -31,10 +42,96 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
_tabController.dispose(); _tabController.dispose();
_phoneController.dispose(); _phoneController.dispose();
_passwordController.dispose(); _passwordController.dispose();
_smsPhoneController.dispose();
_codeController.dispose(); _codeController.dispose();
super.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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@ -85,6 +182,31 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
), ),
const SizedBox(height: 24), 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( Expanded(
child: TabBarView( child: TabBarView(
controller: _tabController, 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() { Widget _buildPasswordLogin() {
return Column( return SingleChildScrollView(
child: Column(
children: [ children: [
// Phone/Email Input
TextField( TextField(
controller: _phoneController, controller: _phoneController,
keyboardType: TextInputType.phone, keyboardType: TextInputType.phone,
@ -115,7 +257,6 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// Password Input
TextField( TextField(
controller: _passwordController, controller: _passwordController,
obscureText: _obscurePassword, obscureText: _obscurePassword,
@ -134,36 +275,34 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
// Forgot Password
Align( Align(
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
child: GestureDetector( child: GestureDetector(
onTap: () { onTap: () => Navigator.pushNamed(context, '/forgot-password'),
Navigator.pushNamed(context, '/forgot-password'); child: Text(
}, context.t('login.forgotPassword'),
child: Text(context.t('login.forgotPassword'), style: AppTypography.labelSmall.copyWith( style: AppTypography.labelSmall.copyWith(color: AppColors.primary),
color: AppColors.primary, ),
)),
), ),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
// Login Button
GenexButton( GenexButton(
label: context.t('login.submit'), label: context.t('login.submit'),
onPressed: () { isLoading: _loading,
Navigator.pushReplacementNamed(context, '/main'); onPressed: _handlePasswordLogin,
},
), ),
], ],
),
); );
} }
Widget _buildCodeLogin() { Widget _buildCodeLogin() {
return Column( return SingleChildScrollView(
child: Column(
children: [ children: [
// Phone Input
TextField( TextField(
controller: _smsPhoneController,
keyboardType: TextInputType.phone, keyboardType: TextInputType.phone,
decoration: InputDecoration( decoration: InputDecoration(
hintText: context.t('login.phone'), hintText: context.t('login.phone'),
@ -172,31 +311,24 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// Code Input + Send Button
Row( Row(
children: [ children: [
Expanded( Expanded(
child: TextField( child: TextField(
controller: _codeController, controller: _codeController,
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
maxLength: 6,
decoration: InputDecoration( decoration: InputDecoration(
hintText: context.t('login.verifyCode'), hintText: context.t('login.verifyCode'),
counterText: '',
prefixIcon: const Icon(Icons.shield_outlined, color: AppColors.textTertiary), prefixIcon: const Icon(Icons.shield_outlined, color: AppColors.textTertiary),
), ),
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
SizedBox( CountdownButton(
height: AppSpacing.inputHeight,
child: GenexButton(
label: context.t('login.getCode'), label: context.t('login.getCode'),
variant: GenexButtonVariant.secondary, onPressed: _handleSendLoginCode,
size: GenexButtonSize.medium,
fullWidth: false,
onPressed: () {
// SMS: send verification code
},
),
), ),
], ],
), ),
@ -204,11 +336,11 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
GenexButton( GenexButton(
label: context.t('login.submit'), label: context.t('login.submit'),
onPressed: () { isLoading: _loading,
Navigator.pushReplacementNamed(context, '/main'); onPressed: _handleCodeLogin,
},
), ),
], ],
),
); );
} }
} }

View File

@ -1,14 +1,16 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:dio/dio.dart';
import '../../../../app/theme/app_colors.dart'; import '../../../../app/theme/app_colors.dart';
import '../../../../app/theme/app_typography.dart'; import '../../../../app/theme/app_typography.dart';
import '../../../../app/theme/app_spacing.dart'; import '../../../../app/theme/app_spacing.dart';
import '../../../../shared/widgets/genex_button.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'; import '../../../../app/i18n/app_localizations.dart';
/// A1. /// A1.
/// ///
/// /// 3: +
/// MPC钱包
class RegisterPage extends StatefulWidget { class RegisterPage extends StatefulWidget {
final bool isEmail; final bool isEmail;
@ -24,6 +26,16 @@ class _RegisterPageState extends State<RegisterPage> {
final _passwordController = TextEditingController(); final _passwordController = TextEditingController();
bool _obscurePassword = true; bool _obscurePassword = true;
bool _agreeTerms = false; bool _agreeTerms = false;
bool _loading = false;
String? _errorMessage;
final _authService = AuthService.instance;
@override
void initState() {
super.initState();
_passwordController.addListener(() => setState(() {}));
}
@override @override
void dispose() { void dispose() {
@ -33,6 +45,72 @@ class _RegisterPageState extends State<RegisterPage> {
super.dispose(); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@ -52,7 +130,9 @@ class _RegisterPageState extends State<RegisterPage> {
Text(context.t('register.title'), style: AppTypography.displayMedium), Text(context.t('register.title'), style: AppTypography.displayMedium),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( 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), style: AppTypography.bodyLarge.copyWith(color: AppColors.textSecondary),
), ),
const SizedBox(height: 40), const SizedBox(height: 40),
@ -61,9 +141,36 @@ class _RegisterPageState extends State<RegisterPage> {
_buildStepIndicator(), _buildStepIndicator(),
const SizedBox(height: 32), 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( Text(
widget.isEmail ? context.t('register.email') : context.t('register.phone'), widget.isEmail
? context.t('register.email')
: context.t('register.phone'),
style: AppTypography.labelMedium, style: AppTypography.labelMedium,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@ -72,7 +179,9 @@ class _RegisterPageState extends State<RegisterPage> {
keyboardType: keyboardType:
widget.isEmail ? TextInputType.emailAddress : TextInputType.phone, widget.isEmail ? TextInputType.emailAddress : TextInputType.phone,
decoration: InputDecoration( 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( prefixIcon: Icon(
widget.isEmail ? Icons.email_outlined : Icons.phone_android_rounded, widget.isEmail ? Icons.email_outlined : Icons.phone_android_rounded,
color: AppColors.textTertiary, color: AppColors.textTertiary,
@ -81,7 +190,6 @@ class _RegisterPageState extends State<RegisterPage> {
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
// Verification Code
Text(context.t('register.code'), style: AppTypography.labelMedium), Text(context.t('register.code'), style: AppTypography.labelMedium),
const SizedBox(height: 8), const SizedBox(height: 8),
Row( Row(
@ -99,21 +207,15 @@ class _RegisterPageState extends State<RegisterPage> {
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
SizedBox( CountdownButton(
height: AppSpacing.inputHeight,
child: GenexButton(
label: context.t('register.getCode'), label: context.t('register.getCode'),
variant: GenexButtonVariant.secondary, onPressed: _handleSendCode,
size: GenexButtonSize.medium,
fullWidth: false,
onPressed: () {},
),
), ),
], ],
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
// Password // Step 2: Password
Text(context.t('register.setPassword'), style: AppTypography.labelMedium), Text(context.t('register.setPassword'), style: AppTypography.labelMedium),
const SizedBox(height: 8), const SizedBox(height: 8),
TextField( TextField(
@ -138,7 +240,7 @@ class _RegisterPageState extends State<RegisterPage> {
_buildPasswordStrength(), _buildPasswordStrength(),
const SizedBox(height: 32), const SizedBox(height: 32),
// Terms Agreement // Step 3: Terms + Submit
Row( Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -179,12 +281,30 @@ class _RegisterPageState extends State<RegisterPage> {
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
// Register Button
GenexButton( GenexButton(
label: context.t('register.submit'), label: context.t('register.submit'),
onPressed: _agreeTerms ? () { isLoading: _loading,
Navigator.pushReplacementNamed(context, '/main'); onPressed: _agreeTerms ? _handleRegister : null,
} : 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), const SizedBox(height: 40),
], ],
@ -195,13 +315,16 @@ class _RegisterPageState extends State<RegisterPage> {
} }
Widget _buildStepIndicator() { Widget _buildStepIndicator() {
final hasCode = _codeController.text.length == 6;
final hasPassword = _passwordController.text.length >= 8;
return Row( return Row(
children: [ children: [
_buildStep(1, context.t('register.stepVerify'), true), _buildStep(1, context.t('register.stepVerify'), true),
_buildStepLine(true), _buildStepLine(hasCode),
_buildStep(2, context.t('register.stepPassword'), true), _buildStep(2, context.t('register.stepPassword'), hasCode),
_buildStepLine(false), _buildStepLine(hasCode && hasPassword),
_buildStep(3, context.t('register.stepDone'), false), _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),
),
),
);
}),
);
}
}