Files
pangolin/client/lib/widgets/auth_screen.dart
T
wangjia 617b43083d feat(client/i18n): B1 硬编码文案 6 语化 + B2 语言持久化
B2 持久化:localeProvider 由 StateProvider 改 StateNotifier(LocaleNotifier),
默认英文,选择存 shared_preferences(key pg_lang),重启保留;新增 appTextFor(lang)
helper 供非 Consumer 场景复用。两处切换 .state= 改 .set()。

B1 硬编码 UI 文案 6 语化(AppLangMisc 扩展,exhaustive switch 不会漏):
- 不可用 / 流媒体优化 / 节点未就绪 / 连接失败 / 加载失败 / 暂无设备 / 改邮箱
- 相对时间(刚刚/X分钟前/…)relativeTime(Duration)
- 修 auth_screen 错误消息 bug:原 _errorZh 永远显示中文(连英文用户都中招)→
  按当前语言取 messageZh/En(非中文显英文)

仍回退英文(服务端数据,需服务端多语字段):节点/套餐名(nameZh/nameEn)、
API 错误消息(messageZh/En)。flutter analyze 仅 2 个既有 withOpacity info。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-07 14:32:13 +08:00

473 lines
18 KiB
Dart

// auth_screen.dart — 登录 / 注册页(居中卡片 + 暖光晕重设计)
//
// 视觉:暖色径向光晕背景 + 居中卡片;hero 用 clay 渐变 app-icon + 品牌;胶囊分段
// tab;输入框聚焦态(accent 边 + 光环);主按钮微光;进入错落淡入。全部走 token。
// 逻辑零回归:登录/注册两段式、发码、邮箱预填、密码显隐、错误提示均保留。
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../l10n/app_text.dart';
import '../pangolin_theme.dart';
import '../services/api_config.dart';
import '../services/auth_api.dart';
import '../services/device_identity.dart';
import '../state/auth_provider.dart';
import 'pangolin_button.dart';
import 'pangolin_field.dart';
import 'pangolin_icons.dart';
import 'pangolin_logo.dart';
// API base URL 统一用 api_config.dart 的 kApiBaseUrl(单一来源,勿再重复声明)。
const _easeOut = Cubic(0.22, 1, 0.36, 1);
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>
with SingleTickerProviderStateMixin {
_AuthMode _mode = _AuthMode.login;
int _step = 0;
bool _sent = false;
bool _loading = false;
bool _pwVisible = false;
String? _error;
final _email = TextEditingController();
final _code = TextEditingController();
final _pw = TextEditingController();
final _emailFocus = FocusNode();
final _pwFocus = FocusNode();
final _codeFocus = FocusNode();
late final AnimationController _intro = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 620),
);
late final AuthApi _api = AuthApi(baseUrl: kApiBaseUrl);
bool get _emailValid => RegExp(r'\S+@\S+\.\S+').hasMatch(_email.text);
@override
void initState() {
super.initState();
// 聚焦态驱动输入框边框变化。
for (final f in [_emailFocus, _pwFocus, _codeFocus]) {
f.addListener(() => setState(() {}));
}
// 预填上次登录邮箱。
ref.read(tokenStoreProvider).loadLastEmail().then((email) {
if (email != null && email.isNotEmpty && mounted) {
setState(() => _email.text = email);
}
});
_intro.forward();
}
@override
void dispose() {
_email.dispose();
_code.dispose();
_pw.dispose();
_emailFocus.dispose();
_pwFocus.dispose();
_codeFocus.dispose();
_intro.dispose();
_api.dispose();
super.dispose();
}
// ── 认证操作(逻辑不变)──────────────────────────────────────────
Future<void> _sendCode() async {
setState(() { _loading = true; _error = null; });
try {
await _api.sendCode(_email.text.trim());
if (mounted) setState(() { _sent = true; _loading = false; });
} on AuthApiException catch (e) {
if (mounted) setState(() { _error = widget.t.lang == AppLang.zh ? e.messageZh : e.messageEn; _loading = false; });
}
}
Future<void> _doRegister() async {
setState(() { _loading = true; _error = null; });
try {
final device = (await ref.read(deviceIdentityProvider).meta()).toJson();
final tokens = await _api.register(
email: _email.text.trim(),
code: _code.text.trim(),
password: _pw.text,
device: device,
);
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(() { _error = widget.t.lang == AppLang.zh ? e.messageZh : e.messageEn; _loading = false; });
}
}
Future<void> _doLogin() async {
setState(() { _loading = true; _error = null; });
try {
final device = (await ref.read(deviceIdentityProvider).meta()).toJson();
final tokens = await _api.login(
email: _email.text.trim(),
password: _pw.text,
device: device,
);
await ref.read(tokenStoreProvider).saveLastEmail(_email.text.trim());
await ref.read(authProvider.notifier).saveTokens(tokens);
// 超限信号(非 null 时顶层路由挡到「移除设备」页;null 清除旧状态)。
ref.read(deviceLimitProvider.notifier).state = tokens.deviceLimit;
if (mounted) widget.onDone();
} on AuthApiException catch (e) {
if (mounted) setState(() { _error = widget.t.lang == AppLang.zh ? e.messageZh : e.messageEn; _loading = false; });
}
}
// ── Enter 键导航 / 提交 ───────────────────────────────────────────
// 登录:邮箱回车 → 跳密码;密码回车 → 直接登录(校验条件同主按钮 enabled)。
void _submitLogin() {
if (!_loading && _emailValid && _pw.text.isNotEmpty) _doLogin();
}
// 注册:邮箱回车 → 发码并跳验证码;验证码(6位)回车 → 进设密码步并聚焦密码;
// 密码(≥6位)回车 → 直接注册。
void _onRegisterEmailSubmit() {
if (_loading || !_emailValid) return;
if (!_sent) _sendCode();
_codeFocus.requestFocus();
}
void _onRegisterCodeSubmit() {
if (!_sent || _code.text.length != 6) return;
setState(() => _step = 1);
// step 1 的密码框在本帧之后才挂载,延到下一帧再聚焦。
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _pwFocus.requestFocus();
});
}
void _submitRegister() {
if (!_loading && _pw.text.length >= 6) _doRegister();
}
void _setMode(_AuthMode m) => setState(() {
_mode = m;
_step = 0;
_sent = false;
_error = null;
});
// ── UI ──────────────────────────────────────────────────────
@override
Widget build(BuildContext context) {
final c = context.pangolin;
final t = widget.t;
final dark = Theme.of(context).brightness == Brightness.dark;
final glow = c.accent.withValues(alpha: dark ? 0.16 : 0.13);
return Scaffold(
backgroundColor: c.bg,
body: Stack(children: [
_atmosphere(glow),
SafeArea(
child: Center(
child: SingleChildScrollView(
// 无弹性回弹:内容放得下就不滚(大窗口不出现上下拖动/滚动条),
// 仅窗口太矮放不下时才滚动。
physics: const ClampingScrollPhysics(),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Column(mainAxisSize: MainAxisSize.min, children: [
_reveal(0.0, 0.55, _hero(c, t)),
const SizedBox(height: 26),
_reveal(0.22, 1.0, _card(c, t)),
const SizedBox(height: 18),
_reveal(0.42, 1.0, Text(t.tos,
textAlign: TextAlign.center,
style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400))),
]),
),
),
),
),
]),
);
}
// 暖色径向光晕(顶部 + 右下两团)。
Widget _atmosphere(Color glow) => Positioned.fill(
child: IgnorePointer(
child: Stack(children: [
DecoratedBox(
decoration: BoxDecoration(
gradient: RadialGradient(
center: const Alignment(0, -1.15),
radius: 1.0,
colors: [glow, Colors.transparent],
stops: const [0, 0.72],
),
),
),
DecoratedBox(
decoration: BoxDecoration(
gradient: RadialGradient(
center: const Alignment(0.85, 1.05),
radius: 0.7,
colors: [glow, Colors.transparent],
stops: const [0, 0.72],
),
),
),
]),
),
);
// 进入错落淡入 + 上移。
Widget _reveal(double start, double end, Widget child) {
final anim = CurvedAnimation(parent: _intro, curve: Interval(start, end, curve: _easeOut));
return AnimatedBuilder(
animation: anim,
builder: (_, c) => Opacity(
opacity: anim.value.clamp(0.0, 1.0),
child: Transform.translate(offset: Offset(0, 14 * (1 - anim.value)), child: c),
),
child: child,
);
}
Widget _hero(PangolinScheme c, AppText t) => Column(children: [
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(PangolinRadius.xl),
boxShadow: [
BoxShadow(color: c.accent.withValues(alpha: 0.30), blurRadius: 44, spreadRadius: 2),
...PangolinShadow.lg,
],
),
child: const PangolinAppIcon(size: 64),
),
const SizedBox(height: 16),
Text(t.brand, style: PangolinText.h1.copyWith(color: c.fg1)),
const SizedBox(height: 6),
Text(t.authTagline, style: PangolinText.sm.copyWith(color: c.accent, fontWeight: FontWeight.w600)),
]);
Widget _card(PangolinScheme c, AppText t) => Container(
padding: const EdgeInsets.fromLTRB(24, 26, 24, 24),
decoration: BoxDecoration(
color: c.surface,
borderRadius: BorderRadius.circular(PangolinRadius.xxl),
border: Border.all(color: c.border),
boxShadow: [...PangolinShadow.md, ...PangolinShadow.lg],
),
child: Column(children: [
_segTabs(c, t),
const SizedBox(height: 22),
if (_error != null) ...[
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: c.dangerSubtle,
borderRadius: BorderRadius.circular(PangolinRadius.md),
border: Border.all(color: c.danger),
),
child: Row(children: [
Icon(PangolinIcons.x, size: 16, color: c.danger),
const SizedBox(width: 8),
Expanded(child: Text(_error!, style: PangolinText.sm.copyWith(color: c.danger))),
]),
),
const SizedBox(height: 14),
],
_mode == _AuthMode.login ? _login(c, t) : _register(c, t),
]),
);
// 胶囊分段 tab(滑块滑动)。
Widget _segTabs(PangolinScheme c, AppText t) {
final reg = _mode == _AuthMode.register;
return Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(color: c.bgSubtle, borderRadius: BorderRadius.circular(PangolinRadius.full)),
child: Stack(children: [
Positioned.fill(
child: AnimatedAlign(
duration: const Duration(milliseconds: 260),
curve: _easeOut,
alignment: reg ? Alignment.centerRight : Alignment.centerLeft,
child: FractionallySizedBox(
widthFactor: 0.5,
heightFactor: 1.0,
child: DecoratedBox(
decoration: BoxDecoration(
color: c.accent,
borderRadius: BorderRadius.circular(PangolinRadius.full),
boxShadow: PangolinShadow.md,
),
),
),
),
),
Row(children: [
_seg(c, t.tabLogin, !reg, () => _setMode(_AuthMode.login)),
_seg(c, t.tabRegister, reg, () => _setMode(_AuthMode.register)),
]),
]),
);
}
Widget _seg(PangolinScheme c, String label, bool active, VoidCallback onTap) => Expanded(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 9),
alignment: Alignment.center,
child: Text(label,
style: PangolinText.body.copyWith(
color: active ? c.fgOnAccent : c.fg3, fontWeight: FontWeight.w600, fontSize: 15)),
),
),
);
// 输入字段:标签 + 图标 + 聚焦态(accent 边 + 光环)。统一走 PangolinFieldBox。
Widget _field(PangolinScheme c, {required IconData icon, required String label, required bool focused, required Widget child}) =>
PangolinFieldBox(label: label, leading: icon, focused: focused, child: child);
// 内层裸装饰(无边框无填充,底色交给 PangolinFieldBox)。
InputDecoration get _bare =>
bareInputDecoration(contentPadding: const EdgeInsets.symmetric(vertical: 13));
// 密码输入 + 显示/隐藏切换(眼睛按钮)。
Widget _pwInput(PangolinScheme c, String hint, {VoidCallback? onSubmit}) => Row(children: [
Expanded(
child: TextField(
controller: _pw,
focusNode: _pwFocus,
obscureText: !_pwVisible,
textInputAction: TextInputAction.done,
onSubmitted: onSubmit == null ? null : (_) => onSubmit(),
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 _primaryBtn(PangolinScheme c, {required String label, required VoidCallback? onPressed}) {
final enabled = onPressed != null;
return Container(
decoration: enabled
? BoxDecoration(
borderRadius: BorderRadius.circular(PangolinRadius.full),
boxShadow: [BoxShadow(color: c.accent.withValues(alpha: 0.30), blurRadius: 20, offset: const Offset(0, 6))],
)
: null,
child: PangolinButton(label: label, expand: true, onPressed: onPressed),
);
}
Widget _login(PangolinScheme c, AppText t) {
return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
_field(c, icon: PangolinIcons.mail, label: t.emailLabel, focused: _emailFocus.hasFocus,
child: TextField(controller: _email, focusNode: _emailFocus,
textInputAction: TextInputAction.next,
onSubmitted: (_) => _pwFocus.requestFocus(),
decoration: _bare.copyWith(hintText: t.emailPh), onChanged: (_) => setState(() {}))),
const SizedBox(height: 16),
_field(c, icon: PangolinIcons.lock, label: t.pwLabel, focused: _pwFocus.hasFocus, child: _pwInput(c, t.pwPh, onSubmit: _submitLogin)),
Align(
alignment: Alignment.centerRight,
child: Padding(
padding: const EdgeInsets.only(top: 10),
child: Text(t.forgotPw, style: PangolinText.sm.copyWith(color: c.accent, fontWeight: FontWeight.w600)),
),
),
const SizedBox(height: 18),
_primaryBtn(c, label: _loading ? '...' : t.doLogin,
onPressed: (!_loading && _emailValid && _pw.text.isNotEmpty) ? _doLogin : null),
]);
}
Widget _register(PangolinScheme c, AppText t) {
if (_step == 0) {
return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
_field(c, icon: PangolinIcons.mail, label: t.emailLabel, focused: _emailFocus.hasFocus,
child: Row(children: [
Expanded(child: TextField(controller: _email, focusNode: _emailFocus,
textInputAction: TextInputAction.next,
onSubmitted: (_) => _onRegisterEmailSubmit(),
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, focused: _codeFocus.hasFocus,
child: TextField(controller: _code, focusNode: _codeFocus, keyboardType: TextInputType.number, maxLength: 6,
textInputAction: TextInputAction.next,
onSubmitted: (_) => _onRegisterCodeSubmit(),
style: PangolinText.mono.copyWith(letterSpacing: 6, color: c.fg1),
decoration: _bare.copyWith(counterText: '', hintText: ''), onChanged: (_) => setState(() {}))),
const SizedBox(height: 16),
_primaryBtn(c, label: t.doNext, onPressed: (_sent && _code.text.length == 6) ? () => setState(() => _step = 1) : null),
]);
}
// Step 1: 已验证邮箱(可返回改邮箱)+ 设密码
return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11),
decoration: BoxDecoration(color: c.bgSubtle, borderRadius: BorderRadius.circular(PangolinRadius.md)),
child: Row(children: [
Icon(PangolinIcons.shieldCheck, size: 16, color: c.success),
const SizedBox(width: 8),
Expanded(child: Text(_email.text, overflow: TextOverflow.ellipsis,
style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w600))),
GestureDetector(
onTap: () => setState(() => _step = 0),
child: Text(t.lang.changeEmail,
style: PangolinText.caption.copyWith(color: c.accent, fontWeight: FontWeight.w600, fontSize: 13)),
),
]),
),
const SizedBox(height: 16),
_field(c, icon: PangolinIcons.lock, label: t.pwLabel, focused: _pwFocus.hasFocus, child: _pwInput(c, t.setPwPh, onSubmit: _submitRegister)),
const SizedBox(height: 18),
_primaryBtn(c, label: _loading ? '...' : t.doCreate,
onPressed: (!_loading && _pw.text.length >= 6) ? _doRegister : null),
]);
}
}