import 'package:flutter/material.dart'; import 'package:flutter/semantics.dart'; import 'package:flutter/services.dart'; import 'package:patbond_flutter/core/navigation/fade_route.dart'; import 'package:patbond_flutter/core/network/api_exception.dart'; import 'package:patbond_flutter/core/theme/app_theme.dart'; import 'package:patbond_flutter/core/widgets/app_text_field.dart'; import 'package:patbond_flutter/core/widgets/auth_scaffold.dart'; import 'package:patbond_flutter/core/widgets/brand_mark.dart'; import 'package:patbond_flutter/core/widgets/inline_error_banner.dart'; import 'package:patbond_flutter/core/widgets/primary_button.dart'; import 'package:patbond_flutter/features/auth/auth_repository.dart'; import 'package:patbond_flutter/features/auth/register_page.dart'; enum _Field { account, password } /// 登录页(12 号组装稿 §5)。登录成功后由认证状态机切入主壳。 class LoginPage extends StatefulWidget { const LoginPage({required this.authRepository, super.key}); final AuthRepository authRepository; @override State createState() => _LoginPageState(); } class _LoginPageState extends State { final _accountCtrl = TextEditingController(); final _passwordCtrl = TextEditingController(); String? _accountError; String? _passwordError; String? _formError; bool _submitting = false; @override void dispose() { _accountCtrl.dispose(); _passwordCtrl.dispose(); super.dispose(); } String? _validateAccount() => _accountCtrl.text.trim().isEmpty ? '请输入用户名或手机号' : null; String? _validatePassword() => _passwordCtrl.text.trim().isEmpty ? '请输入密码' : null; void _validateAccountOnBlur() { if (!mounted) return; setState(() => _accountError = _validateAccount()); } void _validatePasswordOnBlur() { if (!mounted) return; setState(() => _passwordError = _validatePassword()); } /// 字段变更即清除本字段错误与表单级横幅。 void _clearErrors({required _Field field}) { if (_formError == null && (field == _Field.account ? _accountError : _passwordError) == null) { return; } setState(() { _formError = null; if (field == _Field.account) _accountError = null; if (field == _Field.password) _passwordError = null; }); } void _showFormError(String message) { setState(() => _formError = message); SemanticsService.sendAnnouncement( View.of(context), message, TextDirection.ltr, ); } void _showNetworkSnackBar() { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: const Text('网络异常,请检查网络后重试'), action: SnackBarAction(label: '重试', onPressed: _submit), ), ); } Future _submit() async { if (_submitting) return; final accountError = _validateAccount(); final passwordError = _validatePassword(); if (accountError != null || passwordError != null) { setState(() { _accountError = accountError; _passwordError = passwordError; }); return; } setState(() { _submitting = true; _formError = null; }); try { await widget.authRepository.login( username: _accountCtrl.text.trim(), password: _passwordCtrl.text.trim(), ); TextInput.finishAutofillContext(); // 认证状态机随 saveSession 切换,根路由 300ms fade 进主壳。 } on ApiBusinessException catch (error) { if (!mounted) return; _showFormError( error.code == ApiCodes.badCredentials ? '用户名或密码错误' : '登录失败,请稍后重试', ); } on ApiRateLimitException { if (!mounted) return; _showFormError('尝试次数过多,请稍后再试'); } on ApiNetworkException { if (!mounted) return; _showNetworkSnackBar(); } on SessionExpiredException { // 登录请求不带会话,理论不可达;静默留在登录页。 } finally { if (mounted) setState(() => _submitting = false); } } void _goRegister() { Navigator.of(context).push( fadePageRoute(RegisterPage(authRepository: widget.authRepository)), ); } @override Widget build(BuildContext context) { return AuthScaffold( child: AutofillGroup( child: Column( // AuthScaffold 内禁用 Spacer,垂直居中用 mainAxisAlignment(§4 总则)。 mainAxisAlignment: MainAxisAlignment.center, children: [ const SizedBox(height: 48), const BrandMark(), const SizedBox(height: 48), Focus( onFocusChange: (hasFocus) { if (!hasFocus) _validateAccountOnBlur(); }, child: AppTextField( label: '用户名 / 手机号', controller: _accountCtrl, prefixIcon: Icons.person_outline_rounded, errorText: _accountError, enabled: !_submitting, keyboardType: TextInputType.text, textInputAction: TextInputAction.next, autofillHints: const [AutofillHints.username], onChanged: (_) => _clearErrors(field: _Field.account), ), ), const SizedBox(height: 16), Focus( onFocusChange: (hasFocus) { if (!hasFocus) _validatePasswordOnBlur(); }, child: AppTextField( label: '密码', controller: _passwordCtrl, prefixIcon: Icons.lock_outline_rounded, obscurable: true, errorText: _passwordError, enabled: !_submitting, textInputAction: TextInputAction.done, autofillHints: const [AutofillHints.password], onChanged: (_) => _clearErrors(field: _Field.password), onSubmitted: (_) => _submit(), ), ), if (_formError != null) ...[ const SizedBox(height: 16), InlineErrorBanner(message: _formError!), ], const SizedBox(height: 24), PrimaryButton( label: '登录', isLoading: _submitting, onPressed: _submit, ), const SizedBox(height: 16), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ const Text( '还没有账号?', style: TextStyle(color: AppColors.muted, fontSize: 14), ), TextButton( style: TextButton.styleFrom( foregroundColor: AppColors.primaryStrong, minimumSize: const Size(44, 44), padding: const EdgeInsets.symmetric(horizontal: 8), textStyle: const TextStyle( fontSize: 14, fontWeight: FontWeight.w700, ), ), onPressed: _submitting ? null : _goRegister, child: const Text('立即注册'), ), ], ), const SizedBox(height: 24), // 协议行:v1 协议页未就绪,整行不渲染(组装稿 §5)。 // 预留区(短信验证码/第三方登录):不渲染任何占位(ADR-004)。 ], ), ), ); } }