Files
2026-07-14 11:11:36 +08:00

599 lines
17 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<AccountAuthPage> createState() => _AccountAuthPageState();
}
class _AccountAuthPageState extends ConsumerState<AccountAuthPage> {
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<String> onSuccess;
final ValueChanged<bool>? onBusyChanged;
final bool autofocus;
const AccountAuthForm({
super.key,
required this.onSuccess,
this.onBusyChanged,
this.autofocus = true,
});
@override
ConsumerState<AccountAuthForm> createState() => _AccountAuthFormState();
}
enum _AuthMode { login, register }
class _AccountAuthFormState extends ConsumerState<AccountAuthForm> {
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<void> _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<void> _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<void> _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<void> _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,
),
),
),
),
],
);
}
}