| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338 |
- import 'package:flutter/material.dart';
- import 'package:flutter/services.dart';
- import 'package:flutter_riverpod/flutter_riverpod.dart';
- import 'package:go_router/go_router.dart';
- import '../../../core/l10n/app_localizations.dart';
- import '../../../core/theme/app_colors.dart';
- import '../../../core/utils/top_toast.dart';
- import '../../../providers/auth_provider.dart';
- import 'auth_widgets.dart';
- import 'verify_code_screen.dart';
- class LoginScreen extends ConsumerStatefulWidget {
- const LoginScreen({super.key});
- @override
- ConsumerState<LoginScreen> createState() => _LoginScreenState();
- }
- class _LoginScreenState extends ConsumerState<LoginScreen> {
- final _emailController = TextEditingController();
- final _passwordController = TextEditingController();
- final _emailFocusNode = FocusNode();
- final _passwordFocusNode = FocusNode();
- bool _obscurePassword = true;
- bool _emailTouched = false;
- bool _passwordTouched = false;
- // ── 字符过滤正则(仅允许数字、字母及常用特殊符号)──────────
- static final _emailCharRegex = RegExp(r'[a-zA-Z0-9@._%+\-]');
- static final _passwordCharRegex =
- RegExp(r'[a-zA-Z0-9!@#$%^&*()_\-+={};:",.<>/?~\[\]]');
- // ── 邮箱格式校验 ─────────────────────────────────────────
- static final _emailRegex = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$');
- bool get _emailValid => _emailRegex.hasMatch(_emailController.text.trim());
- // ── 密码规则校验 (与 Web 端 validateLoginPasswordRule 一致) ──
- bool get _passwordValid {
- final pwd = _passwordController.text;
- if (pwd.isEmpty) return false;
- if (pwd.length < 6 || pwd.length > 16) return false;
- if (pwd.contains(RegExp(r'\s'))) return false;
- if (!RegExp(r'(?=.*[a-zA-Z])(?=.*\d)').hasMatch(pwd)) return false;
- return true;
- }
- bool get _canSubmit => _emailValid && _passwordValid;
- @override
- void initState() {
- super.initState();
- _emailFocusNode.addListener(() {
- if (!_emailFocusNode.hasFocus && _emailController.text.isNotEmpty) {
- setState(() => _emailTouched = true);
- }
- });
- _passwordFocusNode.addListener(() {
- if (!_passwordFocusNode.hasFocus &&
- _passwordController.text.isNotEmpty) {
- setState(() => _passwordTouched = true);
- }
- });
- }
- @override
- void dispose() {
- _emailController.dispose();
- _passwordController.dispose();
- _emailFocusNode.dispose();
- _passwordFocusNode.dispose();
- super.dispose();
- }
- /// 邮箱脱敏显示
- String get _maskedEmail {
- final email = _emailController.text.trim();
- if (!email.contains('@')) return email;
- final parts = email.split('@');
- final name = parts[0];
- if (name.length <= 1) return '$name***@${parts[1]}';
- return '${name[0]}***@${parts[1]}';
- }
- /// 密码校验错误信息(与 Web 端 validateLoginPasswordRule 一致)
- String? _getPasswordError(AppLocalizations l10n) {
- final pwd = _passwordController.text;
- if (pwd.isEmpty) return null;
- if (pwd.length < 6 || pwd.length > 16) return l10n.passwordLengthError;
- if (pwd.contains(RegExp(r'\s'))) return l10n.passwordSpaceError;
- if (!RegExp(r'(?=.*[a-zA-Z])(?=.*\d)').hasMatch(pwd)) {
- return l10n.passwordLetterDigitError;
- }
- return null;
- }
- /// 点击登录 → 先发送验证码,成功后再跳转验证码页面
- Future<void> _handleLoginTap() async {
- final email = _emailController.text.trim();
- if (email.isEmpty || _passwordController.text.isEmpty) return;
- if (!_canSubmit) return;
- final ok = await ref.read(authProvider.notifier).sendLoginCode(
- email: email,
- password: _passwordController.text,
- );
- if (!ok || !mounted) return;
- context.push(
- '/verify-code',
- extra: VerifyCodeArgs(
- email: email,
- password: _passwordController.text,
- maskedEmail: _maskedEmail,
- codeSent: true,
- ),
- );
- }
- @override
- Widget build(BuildContext context) {
- final l10n = AppLocalizations.of(context)!;
- final state = ref.watch(authProvider);
- final cs = Theme.of(context).colorScheme;
- ref.listen<AuthState>(authProvider, (prev, next) {
- if (next.errorMessage != null && next.errorMessage != prev?.errorMessage) {
- final code = next.errorMessage!;
- WidgetsBinding.instance.addPostFrameCallback((_) {
- if (!context.mounted) return;
- final l10n = AppLocalizations.of(context)!;
- showTopToast(
- context,
- message: resolveProviderError(code, l10n) ?? code,
- );
- ref.read(authProvider.notifier).clearError();
- });
- }
- });
- final isDark = Theme.of(context).brightness == Brightness.dark;
- return Scaffold(
- appBar: AppBar(
- elevation: 0,
- leading: IconButton(
- icon: const Icon(Icons.chevron_left, size: 28),
- onPressed: () => context.go('/'),
- ),
- ),
- body: SingleChildScrollView(
- padding: const EdgeInsets.fromLTRB(24, 16, 24, 40),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- // 标题区
- Text(
- l10n.welcomeBack,
- style: TextStyle(
- color: cs.onSurface,
- fontSize: 28,
- fontWeight: FontWeight.w700,
- letterSpacing: -0.5,
- ),
- ),
- const SizedBox(height: 6),
- Text(
- l10n.loginSubtitle,
- style: TextStyle(
- color: cs.onSurface.withAlpha(120),
- fontSize: 14,
- ),
- ),
- const SizedBox(height: 40),
- // 邮箱字段
- Text(
- l10n.email,
- style: TextStyle(
- color: cs.onSurface.withAlpha(153),
- fontSize: 13,
- fontWeight: FontWeight.w500,
- ),
- ),
- const SizedBox(height: 8),
- AuthField(
- controller: _emailController,
- hint: l10n.emailHint,
- keyboardType: TextInputType.emailAddress,
- maxLength: 100,
- focusNode: _emailFocusNode,
- inputFormatters: [
- FilteringTextInputFormatter.allow(_emailCharRegex),
- ],
- onChanged: (_) => setState(() { _emailTouched = false; }),
- errorText: _emailTouched && !_emailValid ? l10n.emailError : null,
- semanticsLabel: 'login_input_email',
- ),
- const SizedBox(height: 20),
- // 密码字段
- Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [
- Text(
- l10n.password,
- style: TextStyle(
- color: cs.onSurface.withAlpha(153),
- fontSize: 13,
- fontWeight: FontWeight.w500,
- ),
- ),
- Semantics(
- label: 'login_link_forgot_password',
- child: GestureDetector(
- onTap: () => context.push('/forgot-password'),
- child: Text(
- l10n.forgotPassword,
- style: TextStyle(
- color: AppColors.brand,
- fontSize: 13,
- fontWeight: FontWeight.w500,
- ),
- ),
- ),
- ),
- ],
- ),
- const SizedBox(height: 8),
- AuthField(
- controller: _passwordController,
- hint: l10n.loginPasswordHint,
- obscure: _obscurePassword,
- maxLength: 16,
- focusNode: _passwordFocusNode,
- inputFormatters: [
- FilteringTextInputFormatter.allow(_passwordCharRegex),
- ],
- onChanged: (_) => setState(() {}),
- errorText: _passwordTouched
- ? _getPasswordError(l10n)
- : null,
- semanticsLabel: 'login_input_password',
- suffixIcon: IconButton(
- onPressed: () =>
- setState(() => _obscurePassword = !_obscurePassword),
- icon: Icon(
- _obscurePassword
- ? Icons.visibility_off_outlined
- : Icons.visibility_outlined,
- color: cs.onSurface.withAlpha(120),
- size: 20,
- ),
- ),
- ),
- const SizedBox(height: 40),
- // 登录按钮
- Semantics(
- label: 'login_btn_submit',
- button: true,
- enabled: _canSubmit && !state.isLoading,
- onTap: (_canSubmit && !state.isLoading) ? _handleLoginTap : null,
- child: SizedBox(
- width: double.infinity,
- height: 50,
- child: ElevatedButton(
- onPressed: (_canSubmit && !state.isLoading) ? _handleLoginTap : null,
- style: ElevatedButton.styleFrom(
- backgroundColor: AppColors.brand,
- foregroundColor: Colors.black,
- disabledBackgroundColor: isDark
- ? AppColors.darkBgTertiary
- : AppColors.lightBgTertiary,
- disabledForegroundColor: isDark
- ? AppColors.darkTextDisabled
- : AppColors.lightTextDisabled,
- shape: RoundedRectangleBorder(
- borderRadius: BorderRadius.circular(10),
- ),
- ),
- child: state.isLoading
- ? const SizedBox(
- width: 20,
- height: 20,
- child: CircularProgressIndicator(
- strokeWidth: 2,
- color: Colors.black,
- ),
- )
- : Text(
- l10n.login,
- style: const TextStyle(
- fontSize: 16,
- fontWeight: FontWeight.w600,
- ),
- ),
- ),
- ),
- ),
- const SizedBox(height: 32),
- // 注册引导
- Center(
- child: Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- Text(
- l10n.noAccount,
- style: TextStyle(
- color: cs.onSurface.withAlpha(120),
- fontSize: 14,
- ),
- ),
- Semantics(
- label: 'auth_link_register',
- child: GestureDetector(
- onTap: () => context.push('/register'),
- child: Text(
- l10n.registerNow,
- style: const TextStyle(
- color: AppColors.brand,
- fontSize: 14,
- fontWeight: FontWeight.w600,
- ),
- ),
- ),
- ),
- ],
- ),
- ),
- ],
- ),
- ),
- );
- }
- }
|