// 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 createState() => _AuthScreenState(); } class _AuthScreenState extends ConsumerState { _AuthMode _mode = _AuthMode.login; int _step = 0; bool _sent = false; bool _loading = 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 dispose() { _email.dispose(); _code.dispose(); _pw.dispose(); _api.dispose(); super.dispose(); } // ── 认证操作 ────────────────────────────────────────────────── Future _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 _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(authProvider.notifier).saveTokens(tokens); if (mounted) widget.onDone(); } on AuthApiException catch (e) { if (mounted) setState(() { _errorZh = e.messageZh; _loading = false; }); } } Future _doLogin() async { setState(() { _loading = true; _errorZh = null; }); try { final tokens = await _api.login( email: _email.text.trim(), password: _pw.text, ); 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 _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: TextField(controller: _pw, obscureText: true, decoration: _bare.copyWith(hintText: t.pwPh), onChanged: (_) => setState(() {}))), 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: TextField(controller: _pw, obscureText: true, decoration: _bare.copyWith(hintText: t.setPwPh), onChanged: (_) => setState(() {}))), const SizedBox(height: 18), PangolinButton( label: _loading ? '...' : t.doCreate, expand: true, onPressed: (!_loading && _pw.text.length >= 6) ? _doRegister : null, ), ]); } }