// 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 createState() => _AuthScreenState(); } class _AuthScreenState extends ConsumerState 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 _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 _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 _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), ]); } }