Files
pangolin/client/lib/widgets/auth_screen.dart
T
wangjia aee9ba7b72
ci-pangolin / Lint — shellcheck (push) Has been cancelled
ci-pangolin / OpenAPI Sync Check (push) Has been cancelled
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Has been cancelled
ci-pangolin / Flutter — analyze + test (push) Has been cancelled
feat(client): 登录体验 — 记住邮箱 + 密码显隐 + 7天免登陆
1. 记住邮箱:登录页预填上次邮箱(TokenStore.saveLastEmail,登录/注册成功时存,
   退出登录不清除)。
2. 密码显隐:登录/注册密码框加眼睛按钮切换明文(PangolinIcons.eye/eyeOff)。
3. 7天免登陆:RootFlow 改为响应 authProvider——启动时有有效会话直接进主界面;
   AuthNotifier.refresh() 在服务端拒绝(refresh 过期/超 7 天)时 logout 回登录页,
   网络错误则保留会话。会话恢复期间显示极简启动屏。

flutter analyze 0 error;114 tests passed。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 06:28:49 +08:00

284 lines
11 KiB
Dart

// auth_screen.dart — 登录 / 注册页(邮箱 + 验证码流程)
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../l10n/app_text.dart';
import '../pangolin_theme.dart';
import '../services/auth_api.dart';
import '../state/auth_provider.dart';
import 'pangolin_button.dart';
import 'pangolin_icons.dart';
import 'pangolin_logo.dart';
// API base URL(由 --dart-define 注入)
const _kApiUrl = String.fromEnvironment(
'PANGOLIN_API_URL',
defaultValue: 'http://localhost:8080',
);
enum _AuthMode { login, register }
class AuthScreen extends ConsumerStatefulWidget {
const AuthScreen({super.key, required this.onDone, required this.t});
final VoidCallback onDone;
final AppText t;
@override
ConsumerState<AuthScreen> createState() => _AuthScreenState();
}
class _AuthScreenState extends ConsumerState<AuthScreen> {
_AuthMode _mode = _AuthMode.login;
int _step = 0;
bool _sent = false;
bool _loading = false;
bool _pwVisible = false;
String? _errorZh;
final _email = TextEditingController();
final _code = TextEditingController();
final _pw = TextEditingController();
late final AuthApi _api = AuthApi(baseUrl: _kApiUrl);
bool get _emailValid => RegExp(r'\S+@\S+\.\S+').hasMatch(_email.text);
@override
void initState() {
super.initState();
// 预填上次登录邮箱。
ref.read(tokenStoreProvider).loadLastEmail().then((email) {
if (email != null && email.isNotEmpty && mounted) {
setState(() => _email.text = email);
}
});
}
@override
void dispose() {
_email.dispose();
_code.dispose();
_pw.dispose();
_api.dispose();
super.dispose();
}
// ── 认证操作 ──────────────────────────────────────────────────
Future<void> _sendCode() async {
setState(() { _loading = true; _errorZh = null; });
try {
await _api.sendCode(_email.text.trim());
if (mounted) setState(() { _sent = true; _loading = false; });
} on AuthApiException catch (e) {
if (mounted) setState(() { _errorZh = e.messageZh; _loading = false; });
}
}
Future<void> _doRegister() async {
setState(() { _loading = true; _errorZh = null; });
try {
final tokens = await _api.register(
email: _email.text.trim(),
code: _code.text.trim(),
password: _pw.text,
);
await ref.read(tokenStoreProvider).saveLastEmail(_email.text.trim());
await ref.read(authProvider.notifier).saveTokens(tokens);
if (mounted) widget.onDone();
} on AuthApiException catch (e) {
if (mounted) setState(() { _errorZh = e.messageZh; _loading = false; });
}
}
Future<void> _doLogin() async {
setState(() { _loading = true; _errorZh = null; });
try {
final tokens = await _api.login(
email: _email.text.trim(),
password: _pw.text,
);
await ref.read(tokenStoreProvider).saveLastEmail(_email.text.trim());
await ref.read(authProvider.notifier).saveTokens(tokens);
if (mounted) widget.onDone();
} on AuthApiException catch (e) {
if (mounted) setState(() { _errorZh = e.messageZh; _loading = false; });
}
}
// ── UI ──────────────────────────────────────────────────────
@override
Widget build(BuildContext context) {
final c = context.pangolin;
final t = widget.t;
return Scaffold(
backgroundColor: c.bg,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 28),
child: Column(
children: [
const SizedBox(height: 18),
Column(children: [
const PangolinMark(size: 52),
const SizedBox(height: 12),
Text(t.brand, style: PangolinText.h1.copyWith(color: c.fg1)),
const SizedBox(height: 4),
Text(t.authTagline, style: PangolinText.sm.copyWith(color: c.accent, fontWeight: FontWeight.w600)),
]),
const SizedBox(height: 28),
Row(children: [
_tab(_AuthMode.login, t.tabLogin, c),
_tab(_AuthMode.register, t.tabRegister, c),
]),
Divider(height: 1, color: c.border),
const SizedBox(height: 22),
if (_errorZh != null) ...[
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: c.danger.withAlpha(25),
borderRadius: BorderRadius.circular(PangolinRadius.md),
border: Border.all(color: c.danger.withAlpha(80)),
),
child: Row(children: [
Icon(PangolinIcons.x, size: 16, color: c.danger),
const SizedBox(width: 8),
Expanded(child: Text(_errorZh!, style: PangolinText.sm.copyWith(color: c.danger))),
]),
),
const SizedBox(height: 14),
],
Expanded(child: _mode == _AuthMode.login ? _login(c, t) : _register(c, t)),
Padding(
padding: const EdgeInsets.symmetric(vertical: 20),
child: Text(t.tos, textAlign: TextAlign.center, style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)),
),
],
),
),
),
);
}
Widget _tab(_AuthMode m, String label, PangolinScheme c) {
final on = _mode == m;
return Expanded(
child: GestureDetector(
onTap: () => setState(() {
_mode = m;
_step = 0;
_sent = false;
_errorZh = null;
}),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(border: Border(bottom: BorderSide(color: on ? c.accent : Colors.transparent, width: 2))),
child: Center(child: Text(label, style: PangolinText.body.copyWith(color: on ? c.fg1 : c.fg3, fontWeight: on ? FontWeight.w700 : FontWeight.w500))),
),
),
);
}
Widget _field(PangolinScheme c, {required IconData icon, required String label, required Widget child}) {
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(label, style: PangolinText.caption.copyWith(color: c.fg2, fontWeight: FontWeight.w600)),
const SizedBox(height: 7),
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 2),
decoration: BoxDecoration(color: c.surface, borderRadius: BorderRadius.circular(PangolinRadius.md), border: Border.all(color: c.borderStrong, width: 1.5)),
child: Row(children: [Icon(icon, size: 18, color: c.fg3), const SizedBox(width: 10), Expanded(child: child)]),
),
]);
}
InputDecoration get _bare =>
const InputDecoration(border: InputBorder.none, isCollapsed: true, contentPadding: EdgeInsets.symmetric(vertical: 12));
// 密码输入 + 显示/隐藏切换(眼睛按钮)。
Widget _pwInput(PangolinScheme c, String hint) => Row(children: [
Expanded(
child: TextField(
controller: _pw,
obscureText: !_pwVisible,
decoration: _bare.copyWith(hintText: hint),
onChanged: (_) => setState(() {}),
),
),
GestureDetector(
onTap: () => setState(() => _pwVisible = !_pwVisible),
child: Padding(
padding: const EdgeInsets.only(left: 6),
child: Icon(_pwVisible ? PangolinIcons.eyeOff : PangolinIcons.eye, size: 18, color: c.fg3),
),
),
]);
Widget _login(PangolinScheme c, AppText t) {
return Column(children: [
_field(c, icon: PangolinIcons.mail, label: t.emailLabel,
child: TextField(controller: _email, decoration: _bare.copyWith(hintText: t.emailPh), onChanged: (_) => setState(() {}))),
const SizedBox(height: 16),
_field(c, icon: PangolinIcons.lock, label: t.pwLabel, child: _pwInput(c, t.pwPh)),
Align(
alignment: Alignment.centerRight,
child: Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(t.forgotPw, style: PangolinText.sm.copyWith(color: c.accent, fontWeight: FontWeight.w600)),
),
),
const SizedBox(height: 18),
PangolinButton(
label: _loading ? '...' : t.doLogin,
expand: true,
onPressed: (!_loading && _emailValid && _pw.text.isNotEmpty) ? _doLogin : null,
),
]);
}
Widget _register(PangolinScheme c, AppText t) {
if (_step == 0) {
return Column(children: [
_field(c, icon: PangolinIcons.mail, label: t.emailLabel,
child: Row(children: [
Expanded(child: TextField(controller: _email, decoration: _bare.copyWith(hintText: t.emailPh), onChanged: (_) => setState(() {}))),
GestureDetector(
onTap: (!_loading && _emailValid) ? _sendCode : null,
child: Text(_sent ? t.resend : t.sendCode,
style: PangolinText.caption.copyWith(color: (!_loading && _emailValid) ? c.accent : c.fg3, fontWeight: FontWeight.w700)),
),
])),
if (_sent)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(children: [
Icon(PangolinIcons.checkCircle, size: 14, color: c.success),
const SizedBox(width: 6),
Expanded(child: Text(t.codeSentTo(_email.text), style: PangolinText.caption.copyWith(color: c.success, fontWeight: FontWeight.w400))),
]),
),
const SizedBox(height: 16),
_field(c, icon: PangolinIcons.shieldCheck, label: t.codeLabel,
child: TextField(controller: _code, keyboardType: TextInputType.number, maxLength: 6,
style: PangolinText.mono.copyWith(letterSpacing: 6, color: c.fg1),
decoration: _bare.copyWith(counterText: '', hintText: ''), onChanged: (_) => setState(() {}))),
const SizedBox(height: 8),
PangolinButton(label: t.doNext, expand: true, onPressed: (_sent && _code.text.length == 6) ? () => setState(() => _step = 1) : null),
]);
}
// Step 1: set password
return Column(children: [
Row(children: [Icon(PangolinIcons.checkCircle, size: 15, color: c.success), const SizedBox(width: 7), Text(_email.text, style: PangolinText.sm.copyWith(color: c.fg2))]),
const SizedBox(height: 16),
_field(c, icon: PangolinIcons.lock, label: t.pwLabel, child: _pwInput(c, t.setPwPh)),
const SizedBox(height: 18),
PangolinButton(
label: _loading ? '...' : t.doCreate,
expand: true,
onPressed: (!_loading && _pw.text.length >= 6) ? _doRegister : null,
),
]);
}
}