import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../providers/sm_account_provider.dart'; import '../../shared/theme/app_theme.dart'; import '../../shared/widgets/app_inline_alert.dart'; import '../../shared/widgets/app_loading_ring.dart'; import '../../shared/widgets/frosted_panel.dart'; import 'widgets/otp_code_input.dart'; class AccountAuthPage extends ConsumerStatefulWidget { const AccountAuthPage({super.key}); @override ConsumerState createState() => _AccountAuthPageState(); } class _AccountAuthPageState extends ConsumerState { bool _busy = false; @override Widget build(BuildContext context) { final tokens = context.appTokens; final isDark = Theme.of(context).brightness == Brightness.dark; return Scaffold( body: Stack( children: [ const _AuthBackground(), SafeArea( child: Stack( children: [ Center( child: SingleChildScrollView( padding: const EdgeInsets.symmetric( horizontal: 24, vertical: 32, ), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 400), child: FrostedPanel( radius: tokens.radiusCardCompact, color: isDark ? Colors.white.withValues(alpha: 0.05) : Colors.black.withValues(alpha: 0.03), blurSigma: 15, border: Border.all( color: Colors.white.withValues(alpha: 0.08), ), padding: const EdgeInsets.fromLTRB(28, 32, 28, 28), child: AccountAuthForm( onBusyChanged: (v) => setState(() => _busy = v), onSuccess: (message) => context.pop(message), ), ), ), ), ), Positioned( top: 8, left: 8, child: IconButton( onPressed: _busy ? null : () => context.pop(), icon: Icon( Icons.close_rounded, color: Theme.of(context).colorScheme.onSurface, ), tooltip: '关闭', ), ), ], ), ), ], ), ); } } class AccountAuthForm extends ConsumerStatefulWidget { final ValueChanged onSuccess; final ValueChanged? onBusyChanged; final bool autofocus; const AccountAuthForm({ super.key, required this.onSuccess, this.onBusyChanged, this.autofocus = true, }); @override ConsumerState createState() => _AccountAuthFormState(); } enum _AuthMode { login, register } class _AccountAuthFormState extends ConsumerState { final _emailCtrl = TextEditingController(); final _passwordCtrl = TextEditingController(); final _emailFocus = FocusNode(); final _passwordFocus = FocusNode(); _AuthMode _mode = _AuthMode.login; bool _awaitingCode = false; bool _busy = false; bool _obscure = true; String _code = ''; String? _formError; String? _emailError; String? _passwordError; int _resendIn = 0; Timer? _resendTimer; bool get _isRegister => _mode == _AuthMode.register; @override void dispose() { _resendTimer?.cancel(); _emailCtrl.dispose(); _passwordCtrl.dispose(); _emailFocus.dispose(); _passwordFocus.dispose(); super.dispose(); } void _startResendCountdown() { _resendTimer?.cancel(); setState(() => _resendIn = 60); _resendTimer = Timer.periodic(const Duration(seconds: 1), (t) { if (!mounted) { t.cancel(); return; } setState(() { _resendIn -= 1; if (_resendIn <= 0) t.cancel(); }); }); } void _switchMode(_AuthMode mode) { if (_busy) return; setState(() { _mode = mode; _awaitingCode = false; _formError = null; _emailError = null; _passwordError = null; }); } static final _emailRe = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$'); bool _validateForm() { final email = _emailCtrl.text.trim(); final password = _passwordCtrl.text; String? emailErr; String? passErr; if (email.isEmpty) { emailErr = '请输入邮箱'; } else if (!_emailRe.hasMatch(email)) { emailErr = '邮箱格式不正确'; } if (password.isEmpty) { passErr = '请输入密码'; } else if (_isRegister && password.length < 6) { passErr = '密码至少 6 位'; } setState(() { _emailError = emailErr; _passwordError = passErr; _formError = null; }); return emailErr == null && passErr == null; } bool _validateEmailOnly() { final email = _emailCtrl.text.trim(); String? emailErr; if (email.isEmpty) { emailErr = '请输入邮箱'; } else if (!_emailRe.hasMatch(email)) { emailErr = '邮箱格式不正确'; } setState(() { _emailError = emailErr; _formError = null; }); return emailErr == null; } bool _validateRegisterPassword() { final password = _passwordCtrl.text; String? passErr; if (password.isEmpty) { passErr = '请输入密码'; } else if (password.length < 6) { passErr = '密码至少 6 位'; } setState(() { _passwordError = passErr; _formError = null; }); return passErr == null; } void _setBusy(bool value) { setState(() => _busy = value); widget.onBusyChanged?.call(value); } Future _login() async { if (!_validateForm()) return; _setBusy(true); final result = await ref .read(smAccountProvider.notifier) .login(_emailCtrl.text.trim(), _passwordCtrl.text); if (!mounted) return; _setBusy(false); if (result.ok) { widget.onSuccess('登录成功'); } else if (result.needsVerification) { setState(() { _mode = _AuthMode.register; _awaitingCode = true; _code = ''; _formError = result.message ?? '邮箱尚未验证,请输入验证码完成激活'; }); _startResendCountdown(); } else { setState(() => _formError = result.message ?? '登录失败'); } } Future _registerStart() async { if (!_validateEmailOnly()) return; _setBusy(true); final result = await ref .read(smAccountProvider.notifier) .registerStart(_emailCtrl.text.trim()); if (!mounted) return; _setBusy(false); if (result.ok) { setState(() { _awaitingCode = true; _code = ''; _formError = null; }); _startResendCountdown(); } else { setState(() => _formError = result.message ?? '发送验证码失败'); } } Future _registerVerify() async { if (!_validateRegisterPassword()) return; _setBusy(true); final result = await ref .read(smAccountProvider.notifier) .registerVerify(_emailCtrl.text.trim(), _passwordCtrl.text, _code); if (!mounted) return; _setBusy(false); if (result.ok) { widget.onSuccess('注册成功,已登录'); } else { setState(() => _formError = result.message ?? '验证失败'); } } Future _resend() async { if (_resendIn > 0 || _busy) return; _setBusy(true); final result = await ref .read(smAccountProvider.notifier) .resendRegisterCode(_emailCtrl.text.trim()); if (!mounted) return; _setBusy(false); if (result.ok) { _startResendCountdown(); setState(() => _formError = null); } else { setState(() => _formError = result.message ?? '重发失败'); } } @override Widget build(BuildContext context) { final theme = Theme.of(context); final tokens = context.appTokens; return AnimatedSize( duration: const Duration(milliseconds: 250), curve: Curves.easeOut, alignment: Alignment.topCenter, child: Column( key: const ValueKey('form'), mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const _BrandHeader(), const SizedBox(height: 6), Text( _isRegister ? '创建 player 账号' : '登录以使用 player 账号服务', textAlign: TextAlign.center, style: theme.textTheme.bodySmall?.copyWith( color: tokens.mutedForeground, ), ), const SizedBox(height: 24), if (_formError != null) ...[ AppInlineAlert(message: _formError!), const SizedBox(height: 16), ], TextField( controller: _emailCtrl, focusNode: _emailFocus, enabled: !_busy, autofocus: widget.autofocus, keyboardType: TextInputType.emailAddress, textInputAction: TextInputAction.next, decoration: InputDecoration( labelText: '邮箱', hintText: 'your@email.com', errorText: _emailError, ), onChanged: (_) { if (_emailError != null) setState(() => _emailError = null); }, onSubmitted: (_) => _passwordFocus.requestFocus(), ), if (_isRegister) ...[ const SizedBox(height: 6), Align( alignment: Alignment.centerRight, child: TextButton( onPressed: (_busy || _resendIn > 0) ? null : (_awaitingCode ? _resend : _registerStart), style: TextButton.styleFrom( foregroundColor: theme.colorScheme.primary, padding: const EdgeInsets.symmetric( horizontal: 4, vertical: 4, ), minimumSize: Size.zero, tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), child: Text( _awaitingCode ? (_resendIn > 0 ? '重新发送(${_resendIn}s)' : '重新发送验证码') : '发送验证码', style: const TextStyle( fontSize: 13, fontWeight: FontWeight.w600, ), ), ), ), ], const SizedBox(height: 16), TextField( controller: _passwordCtrl, focusNode: _passwordFocus, enabled: !_busy, obscureText: _obscure, textInputAction: TextInputAction.done, decoration: InputDecoration( labelText: '密码', hintText: _isRegister ? '设置密码(至少 6 位)' : '输入密码', errorText: _passwordError, suffixIcon: IconButton( onPressed: () => setState(() => _obscure = !_obscure), icon: Icon( _obscure ? Icons.visibility_off_outlined : Icons.visibility_outlined, ), tooltip: _obscure ? '显示密码' : '隐藏密码', ), ), onChanged: (_) { if (_passwordError != null) { setState(() => _passwordError = null); } }, onSubmitted: (_) { if (!_isRegister) _login(); }, ), if (_isRegister) AnimatedSize( duration: const Duration(milliseconds: 250), curve: Curves.easeOut, alignment: Alignment.topCenter, child: _awaitingCode ? Padding( padding: const EdgeInsets.only(top: 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.only(left: 2, bottom: 6), child: Text( '验证码', style: theme.textTheme.bodySmall?.copyWith( color: tokens.mutedForeground, ), ), ), OtpCodeInput( enabled: !_busy, foreground: theme.colorScheme.onSurface, accent: theme.colorScheme.primary, onChanged: (v) { setState(() { _code = v; if (_formError != null) _formError = null; }); }, ), ], ), ) : const SizedBox.shrink(), ), const SizedBox(height: 24), SizedBox( height: 46, child: FilledButton( onPressed: _busy ? null : (_isRegister ? ((!_awaitingCode || _code.length < 6 || _passwordCtrl.text.length < 6) ? null : _registerVerify) : _login), child: _busy ? const AppLoadingRing(size: 18, color: Colors.white) : Text( _isRegister ? '完成注册' : '登录', style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w600, ), ), ), ), const SizedBox(height: 16), _ModeSwitchLink( hint: _isRegister ? '已有账号?' : '还没有账号?', action: _isRegister ? '去登录' : '去注册', onTap: _busy ? null : () => _switchMode( _isRegister ? _AuthMode.login : _AuthMode.register, ), ), ], ), ); } } class _AuthBackground extends StatelessWidget { const _AuthBackground(); @override Widget build(BuildContext context) { final theme = Theme.of(context); final tokens = context.appTokens; return DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [tokens.pageGradientTop, tokens.pageGradientBottom], ), ), child: Align( alignment: const Alignment(0, -1.3), child: Container( width: 460, height: 460, decoration: BoxDecoration( shape: BoxShape.circle, gradient: RadialGradient( colors: [ theme.colorScheme.primary.withValues(alpha: 0.22), theme.colorScheme.primary.withValues(alpha: 0.0), ], ), ), ), ), ); } } class _BrandHeader extends StatelessWidget { const _BrandHeader(); @override Widget build(BuildContext context) { final theme = Theme.of(context); return Column( mainAxisSize: MainAxisSize.min, children: [ ClipRRect( borderRadius: BorderRadius.circular(16), child: Image.asset( 'assets/icon/app_icon.png', width: 60, height: 60, fit: BoxFit.cover, errorBuilder: (_, _, _) => Container( width: 60, height: 60, decoration: BoxDecoration( color: theme.colorScheme.primary, borderRadius: BorderRadius.circular(16), ), child: const Icon( Icons.play_circle_fill_rounded, color: Colors.white, size: 34, ), ), ), ), const SizedBox(height: 14), Text('smPlayer', style: theme.textTheme.titleMedium), ], ); } } class _ModeSwitchLink extends StatelessWidget { final String hint; final String action; final VoidCallback? onTap; const _ModeSwitchLink({ required this.hint, required this.action, required this.onTap, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); final tokens = context.appTokens; return Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Text( hint, style: TextStyle(color: tokens.mutedForeground, fontSize: 13), ), GestureDetector( onTap: onTap, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), child: Text( action, style: TextStyle( color: theme.colorScheme.primary, fontSize: 13, fontWeight: FontWeight.w600, ), ), ), ), ], ); } }