/// 修改密码页(UI-03 页面 03 重设计,图01;入口在设置页)。 /// /// 返回标题 + 简短安全提示 + 当前/新/确认三字段 + Save password。 /// 客户端校验(新密码 ≥8 位、两次一致)先行——新密码变更时若确认字段 /// 已填写会即时复验(不匹配当场提示);服务端 /// [ApiClient.changePassword] 成功后 204——当前会话被服务端保留, /// 无需重登录:toast 提示后直接 pop 回设置页。失败以内联 StateBadge /// (图标 + 文案 + 错误色)展示;AUTH_INVALID_CREDENTIALS 在本页语义为 /// 「当前密码错误」(覆写通用映射)。提交中防重复。 library; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../core/api_client.dart'; import '../core/app_exception.dart'; import '../core/error_codes.dart'; import '../core/error_l10n.dart'; import '../l10n/app_localizations.dart'; import '../ui/components/state_badge.dart'; import '../ui/tokens.dart'; /// 修改密码页。 class PasswordChangePage extends ConsumerStatefulWidget { const PasswordChangePage({super.key}); @override ConsumerState createState() => _PasswordChangePageState(); } class _PasswordChangePageState extends ConsumerState { final _formKey = GlobalKey(); final _oldController = TextEditingController(); final _newController = TextEditingController(); final _confirmController = TextEditingController(); bool _obscureOld = true; bool _obscureNew = true; bool _obscureConfirm = true; bool _submitting = false; /// 最近一次提交失败文案(内联 StateBadge 展示;null = 无错误)。 String? _errorText; @override void initState() { super.initState(); // 新密码变更 → 确认字段已填写时即时复验(不匹配当场提示,不等提交)。 _newController.addListener(_revalidateConfirm); } void _revalidateConfirm() { if (_confirmController.text.isEmpty) return; _formKey.currentState?.validate(); } @override void dispose() { _newController.removeListener(_revalidateConfirm); _oldController.dispose(); _newController.dispose(); _confirmController.dispose(); super.dispose(); } Future _submit() async { if (!_formKey.currentState!.validate()) return; setState(() { _submitting = true; _errorText = null; }); final l10n = AppLocalizations.of(context); final messenger = ScaffoldMessenger.of(context); final navigator = Navigator.of(context); try { await ref.read(apiClientProvider).changePassword( _oldController.text, _newController.text, ); // 成功路径:先弹提示再 pop;pop 后 SnackBar 仍挂在根 messenger 上可见。 messenger.showSnackBar( SnackBar(content: Text(l10n.passwordChangeSuccess)), ); navigator.pop(); } on AppException catch (error) { if (mounted) { setState( () => _errorText = error.code == ErrorCodes.authInvalidCredentials ? l10n.errorOldPasswordIncorrect : l10n.messageForError(error), ); } } on Object { if (mounted) setState(() => _errorText = l10n.errorGeneric); } finally { if (mounted) setState(() => _submitting = false); } } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final theme = Theme.of(context); return Scaffold( appBar: AppBar(title: Text(l10n.passwordChangeTitle)), body: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 420), child: SingleChildScrollView( child: Padding( padding: const EdgeInsets.all(kSpace24), child: Form( key: _formKey, autovalidateMode: AutovalidateMode.onUserInteraction, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( l10n.passwordSecurityHint, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), const SizedBox(height: kSpace16), TextFormField( controller: _oldController, obscureText: _obscureOld, enableSuggestions: false, autocorrect: false, textInputAction: TextInputAction.next, autofillHints: const [AutofillHints.password], decoration: InputDecoration( labelText: l10n.currentPassword, suffixIcon: _visibilityToggle( obscure: _obscureOld, onPressed: () => setState( () => _obscureOld = !_obscureOld, ), ), ), validator: (value) => (value == null || value.isEmpty) ? l10n.fieldRequired : null, ), const SizedBox(height: kSpace16), TextFormField( controller: _newController, obscureText: _obscureNew, enableSuggestions: false, autocorrect: false, textInputAction: TextInputAction.next, autofillHints: const [AutofillHints.newPassword], decoration: InputDecoration( labelText: l10n.newPassword, suffixIcon: _visibilityToggle( obscure: _obscureNew, onPressed: () => setState( () => _obscureNew = !_obscureNew, ), ), ), validator: (value) { if (value == null || value.isEmpty) { return l10n.fieldRequired; } if (value.length < 8) return l10n.passwordMinLength; return null; }, ), const SizedBox(height: kSpace16), TextFormField( controller: _confirmController, obscureText: _obscureConfirm, enableSuggestions: false, autocorrect: false, textInputAction: TextInputAction.done, autofillHints: const [AutofillHints.newPassword], onFieldSubmitted: (_) => _submitting ? null : _submit(), decoration: InputDecoration( labelText: l10n.confirmNewPassword, suffixIcon: _visibilityToggle( obscure: _obscureConfirm, onPressed: () => setState( () => _obscureConfirm = !_obscureConfirm, ), ), ), validator: (value) { if (value == null || value.isEmpty) { return l10n.fieldRequired; } if (value != _newController.text) { return l10n.passwordMismatch; } return null; }, ), if (_errorText != null) ...[ const SizedBox(height: kSpace16), StateBadge( variant: BadgeVariant.error, label: _errorText!, ), ], const SizedBox(height: kSpace24), FilledButton( onPressed: _submitting ? null : _submit, child: _submitting ? const SizedBox( height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2), ) : Text(l10n.passwordSave), ), ], ), ), ), ), ), ), ); } /// 显示/隐藏切换(Tooltip 提供语义标签;触控目标 48)。 Widget _visibilityToggle({ required bool obscure, required VoidCallback onPressed, }) { final l10n = AppLocalizations.of(context); return Tooltip( message: obscure ? l10n.loginShowPassword : l10n.loginHidePassword, child: IconButton( onPressed: onPressed, icon: Icon( obscure ? Icons.visibility_outlined : Icons.visibility_off_outlined, size: 24, ), ), ); } }