Files
photo-live/apps/mobile/lib/pages/login_page.dart
T
浅念 8be21b856b feat(mobile): login, password change and logout screens
P3 Task 3 移动端(对接 Task 2 api client + 本任务新增的后端契约):

- 登录页实装:账号/密码表单(空值客户端校验)→ api.login(会话自动
  持久化)→ pushReplacement /home;错误码本地化(AUTH_INVALID_
  CREDENTIALS / AUTH_RATE_LIMITED(含稍后再试文案)/ 网络 / 校验);
  提交加载态防重复提交。
- 修改密码页(设置页入口):当前/新/确认三字段,客户端校验(≥8 位、
  两次一致)→ api.changePassword → toast + pop;成功后本地会话不变
  (服务端保留当前会话);旧密码错误(401 AUTH_INVALID_CREDENTIALS)
  显示「当前密码错误」且不触发 401 驱逐。
- 退出登录:设置页按钮 → api.logout 尽力而为 → 清会话 → 清栈回
  /login;App 壳 initState 注册 onUnauthorized(业务 401 → 清栈 /login),
  根 navigatorKey 接线;password-change 的 401 豁免会话驱逐(拦截器
  豁免集合与令牌附加分离)。
- l10n:新增 16 组 zh/en 文案;错误码 → 文案解析扩展
  (core/error_l10n.dart,errorCode→AppLocalizations)。
- 测试:ApiClient.changePassword 单测 ×2(Bearer+请求体;旧密码 401
  不驱逐);widget 测试 ×12(登录 5:空值/成功跳转/凭据错误/限速文案/
  加载禁用;改密 4:过短/不一致/旧密码错误文案+会话保留/成功 toast+pop
  且令牌不变;设置 2:改密入口跳转/登出清会话回登录;App 壳 1:401
  清栈回 /login)。flutter analyze 0 issue;flutter test 81/81。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-19 01:19:57 +08:00

134 lines
4.7 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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_l10n.dart';
import '../l10n/app_localizations.dart';
/// 登录页(P3 计划 Task 3)。
///
/// 账号/密码表单 → [ApiClient.login](会话由客户端自动持久化到
/// SessionStore)→ 成功 pushReplacement /home;失败按错误码本地化提示
/// (AUTH_RATE_LIMITED 文案自带「稍后再试」;网络/校验错误各有专属文案)。
/// 提交期间按钮进入加载态防重复提交。
class LoginPage extends ConsumerStatefulWidget {
const LoginPage({super.key});
@override
ConsumerState<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends ConsumerState<LoginPage> {
final _formKey = GlobalKey<FormState>();
final _accountController = TextEditingController();
final _passwordController = TextEditingController();
bool _submitting = false;
@override
void dispose() {
_accountController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _submitting = true);
final l10n = AppLocalizations.of(context);
final messenger = ScaffoldMessenger.of(context);
final navigator = Navigator.of(context);
try {
await ref.read(apiClientProvider).login(
_accountController.text.trim(),
_passwordController.text,
);
navigator.pushReplacementNamed('/home');
} on AppException catch (error) {
// 429(AUTH_RATE_LIMITED)等文案经错误码映射;登录页的
// AUTH_INVALID_CREDENTIALS 即「账号或密码错误」。
messenger.showSnackBar(
SnackBar(content: Text(l10n.messageForError(error))),
);
} on Object {
messenger.showSnackBar(
SnackBar(content: Text(l10n.errorGeneric)),
);
} finally {
if (mounted) setState(() => _submitting = false);
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Scaffold(
appBar: AppBar(title: Text(l10n.appName)),
body: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Padding(
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
autovalidateMode: AutovalidateMode.onUserInteraction,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextFormField(
controller: _accountController,
autofillHints: const [AutofillHints.username],
decoration: InputDecoration(
labelText: l10n.username,
border: const OutlineInputBorder(),
),
validator: (value) =>
(value == null || value.trim().isEmpty)
? l10n.fieldRequired
: null,
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
obscureText: true,
enableSuggestions: false,
autocorrect: false,
autofillHints: const [AutofillHints.password],
onFieldSubmitted: (_) => _submitting ? null : _submit(),
decoration: InputDecoration(
labelText: l10n.password,
border: const OutlineInputBorder(),
),
validator: (value) => (value == null || value.isEmpty)
? l10n.fieldRequired
: null,
),
const SizedBox(height: 24),
FilledButton(
onPressed: _submitting ? null : _submit,
child: _submitting
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(l10n.login),
),
const SizedBox(height: 12),
Text(
l10n.loginHint,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
),
),
),
);
}
}