login_screen.dart 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338
  1. import 'package:flutter/material.dart';
  2. import 'package:flutter/services.dart';
  3. import 'package:flutter_riverpod/flutter_riverpod.dart';
  4. import 'package:go_router/go_router.dart';
  5. import '../../../core/l10n/app_localizations.dart';
  6. import '../../../core/theme/app_colors.dart';
  7. import '../../../core/utils/top_toast.dart';
  8. import '../../../providers/auth_provider.dart';
  9. import 'auth_widgets.dart';
  10. import 'verify_code_screen.dart';
  11. class LoginScreen extends ConsumerStatefulWidget {
  12. const LoginScreen({super.key});
  13. @override
  14. ConsumerState<LoginScreen> createState() => _LoginScreenState();
  15. }
  16. class _LoginScreenState extends ConsumerState<LoginScreen> {
  17. final _emailController = TextEditingController();
  18. final _passwordController = TextEditingController();
  19. final _emailFocusNode = FocusNode();
  20. final _passwordFocusNode = FocusNode();
  21. bool _obscurePassword = true;
  22. bool _emailTouched = false;
  23. bool _passwordTouched = false;
  24. // ── 字符过滤正则(仅允许数字、字母及常用特殊符号)──────────
  25. static final _emailCharRegex = RegExp(r'[a-zA-Z0-9@._%+\-]');
  26. static final _passwordCharRegex =
  27. RegExp(r'[a-zA-Z0-9!@#$%^&*()_\-+={};:",.<>/?~\[\]]');
  28. // ── 邮箱格式校验 ─────────────────────────────────────────
  29. static final _emailRegex = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$');
  30. bool get _emailValid => _emailRegex.hasMatch(_emailController.text.trim());
  31. // ── 密码规则校验 (与 Web 端 validateLoginPasswordRule 一致) ──
  32. bool get _passwordValid {
  33. final pwd = _passwordController.text;
  34. if (pwd.isEmpty) return false;
  35. if (pwd.length < 6 || pwd.length > 16) return false;
  36. if (pwd.contains(RegExp(r'\s'))) return false;
  37. if (!RegExp(r'(?=.*[a-zA-Z])(?=.*\d)').hasMatch(pwd)) return false;
  38. return true;
  39. }
  40. bool get _canSubmit => _emailValid && _passwordValid;
  41. @override
  42. void initState() {
  43. super.initState();
  44. _emailFocusNode.addListener(() {
  45. if (!_emailFocusNode.hasFocus && _emailController.text.isNotEmpty) {
  46. setState(() => _emailTouched = true);
  47. }
  48. });
  49. _passwordFocusNode.addListener(() {
  50. if (!_passwordFocusNode.hasFocus &&
  51. _passwordController.text.isNotEmpty) {
  52. setState(() => _passwordTouched = true);
  53. }
  54. });
  55. }
  56. @override
  57. void dispose() {
  58. _emailController.dispose();
  59. _passwordController.dispose();
  60. _emailFocusNode.dispose();
  61. _passwordFocusNode.dispose();
  62. super.dispose();
  63. }
  64. /// 邮箱脱敏显示
  65. String get _maskedEmail {
  66. final email = _emailController.text.trim();
  67. if (!email.contains('@')) return email;
  68. final parts = email.split('@');
  69. final name = parts[0];
  70. if (name.length <= 1) return '$name***@${parts[1]}';
  71. return '${name[0]}***@${parts[1]}';
  72. }
  73. /// 密码校验错误信息(与 Web 端 validateLoginPasswordRule 一致)
  74. String? _getPasswordError(AppLocalizations l10n) {
  75. final pwd = _passwordController.text;
  76. if (pwd.isEmpty) return null;
  77. if (pwd.length < 6 || pwd.length > 16) return l10n.passwordLengthError;
  78. if (pwd.contains(RegExp(r'\s'))) return l10n.passwordSpaceError;
  79. if (!RegExp(r'(?=.*[a-zA-Z])(?=.*\d)').hasMatch(pwd)) {
  80. return l10n.passwordLetterDigitError;
  81. }
  82. return null;
  83. }
  84. /// 点击登录 → 先发送验证码,成功后再跳转验证码页面
  85. Future<void> _handleLoginTap() async {
  86. final email = _emailController.text.trim();
  87. if (email.isEmpty || _passwordController.text.isEmpty) return;
  88. if (!_canSubmit) return;
  89. final ok = await ref.read(authProvider.notifier).sendLoginCode(
  90. email: email,
  91. password: _passwordController.text,
  92. );
  93. if (!ok || !mounted) return;
  94. context.push(
  95. '/verify-code',
  96. extra: VerifyCodeArgs(
  97. email: email,
  98. password: _passwordController.text,
  99. maskedEmail: _maskedEmail,
  100. codeSent: true,
  101. ),
  102. );
  103. }
  104. @override
  105. Widget build(BuildContext context) {
  106. final l10n = AppLocalizations.of(context)!;
  107. final state = ref.watch(authProvider);
  108. final cs = Theme.of(context).colorScheme;
  109. ref.listen<AuthState>(authProvider, (prev, next) {
  110. if (next.errorMessage != null && next.errorMessage != prev?.errorMessage) {
  111. final code = next.errorMessage!;
  112. WidgetsBinding.instance.addPostFrameCallback((_) {
  113. if (!context.mounted) return;
  114. final l10n = AppLocalizations.of(context)!;
  115. showTopToast(
  116. context,
  117. message: resolveProviderError(code, l10n) ?? code,
  118. );
  119. ref.read(authProvider.notifier).clearError();
  120. });
  121. }
  122. });
  123. final isDark = Theme.of(context).brightness == Brightness.dark;
  124. return Scaffold(
  125. appBar: AppBar(
  126. elevation: 0,
  127. leading: IconButton(
  128. icon: const Icon(Icons.chevron_left, size: 28),
  129. onPressed: () => context.go('/'),
  130. ),
  131. ),
  132. body: SingleChildScrollView(
  133. padding: const EdgeInsets.fromLTRB(24, 16, 24, 40),
  134. child: Column(
  135. crossAxisAlignment: CrossAxisAlignment.start,
  136. children: [
  137. // 标题区
  138. Text(
  139. l10n.welcomeBack,
  140. style: TextStyle(
  141. color: cs.onSurface,
  142. fontSize: 28,
  143. fontWeight: FontWeight.w700,
  144. letterSpacing: -0.5,
  145. ),
  146. ),
  147. const SizedBox(height: 6),
  148. Text(
  149. l10n.loginSubtitle,
  150. style: TextStyle(
  151. color: cs.onSurface.withAlpha(120),
  152. fontSize: 14,
  153. ),
  154. ),
  155. const SizedBox(height: 40),
  156. // 邮箱字段
  157. Text(
  158. l10n.email,
  159. style: TextStyle(
  160. color: cs.onSurface.withAlpha(153),
  161. fontSize: 13,
  162. fontWeight: FontWeight.w500,
  163. ),
  164. ),
  165. const SizedBox(height: 8),
  166. AuthField(
  167. controller: _emailController,
  168. hint: l10n.emailHint,
  169. keyboardType: TextInputType.emailAddress,
  170. maxLength: 100,
  171. focusNode: _emailFocusNode,
  172. inputFormatters: [
  173. FilteringTextInputFormatter.allow(_emailCharRegex),
  174. ],
  175. onChanged: (_) => setState(() { _emailTouched = false; }),
  176. errorText: _emailTouched && !_emailValid ? l10n.emailError : null,
  177. semanticsLabel: 'login_input_email',
  178. ),
  179. const SizedBox(height: 20),
  180. // 密码字段
  181. Row(
  182. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  183. children: [
  184. Text(
  185. l10n.password,
  186. style: TextStyle(
  187. color: cs.onSurface.withAlpha(153),
  188. fontSize: 13,
  189. fontWeight: FontWeight.w500,
  190. ),
  191. ),
  192. Semantics(
  193. label: 'login_link_forgot_password',
  194. child: GestureDetector(
  195. onTap: () => context.push('/forgot-password'),
  196. child: Text(
  197. l10n.forgotPassword,
  198. style: TextStyle(
  199. color: AppColors.brand,
  200. fontSize: 13,
  201. fontWeight: FontWeight.w500,
  202. ),
  203. ),
  204. ),
  205. ),
  206. ],
  207. ),
  208. const SizedBox(height: 8),
  209. AuthField(
  210. controller: _passwordController,
  211. hint: l10n.loginPasswordHint,
  212. obscure: _obscurePassword,
  213. maxLength: 16,
  214. focusNode: _passwordFocusNode,
  215. inputFormatters: [
  216. FilteringTextInputFormatter.allow(_passwordCharRegex),
  217. ],
  218. onChanged: (_) => setState(() {}),
  219. errorText: _passwordTouched
  220. ? _getPasswordError(l10n)
  221. : null,
  222. semanticsLabel: 'login_input_password',
  223. suffixIcon: IconButton(
  224. onPressed: () =>
  225. setState(() => _obscurePassword = !_obscurePassword),
  226. icon: Icon(
  227. _obscurePassword
  228. ? Icons.visibility_off_outlined
  229. : Icons.visibility_outlined,
  230. color: cs.onSurface.withAlpha(120),
  231. size: 20,
  232. ),
  233. ),
  234. ),
  235. const SizedBox(height: 40),
  236. // 登录按钮
  237. Semantics(
  238. label: 'login_btn_submit',
  239. button: true,
  240. enabled: _canSubmit && !state.isLoading,
  241. onTap: (_canSubmit && !state.isLoading) ? _handleLoginTap : null,
  242. child: SizedBox(
  243. width: double.infinity,
  244. height: 50,
  245. child: ElevatedButton(
  246. onPressed: (_canSubmit && !state.isLoading) ? _handleLoginTap : null,
  247. style: ElevatedButton.styleFrom(
  248. backgroundColor: AppColors.brand,
  249. foregroundColor: Colors.black,
  250. disabledBackgroundColor: isDark
  251. ? AppColors.darkBgTertiary
  252. : AppColors.lightBgTertiary,
  253. disabledForegroundColor: isDark
  254. ? AppColors.darkTextDisabled
  255. : AppColors.lightTextDisabled,
  256. shape: RoundedRectangleBorder(
  257. borderRadius: BorderRadius.circular(10),
  258. ),
  259. ),
  260. child: state.isLoading
  261. ? const SizedBox(
  262. width: 20,
  263. height: 20,
  264. child: CircularProgressIndicator(
  265. strokeWidth: 2,
  266. color: Colors.black,
  267. ),
  268. )
  269. : Text(
  270. l10n.login,
  271. style: const TextStyle(
  272. fontSize: 16,
  273. fontWeight: FontWeight.w600,
  274. ),
  275. ),
  276. ),
  277. ),
  278. ),
  279. const SizedBox(height: 32),
  280. // 注册引导
  281. Center(
  282. child: Row(
  283. mainAxisSize: MainAxisSize.min,
  284. children: [
  285. Text(
  286. l10n.noAccount,
  287. style: TextStyle(
  288. color: cs.onSurface.withAlpha(120),
  289. fontSize: 14,
  290. ),
  291. ),
  292. Semantics(
  293. label: 'auth_link_register',
  294. child: GestureDetector(
  295. onTap: () => context.push('/register'),
  296. child: Text(
  297. l10n.registerNow,
  298. style: const TextStyle(
  299. color: AppColors.brand,
  300. fontSize: 14,
  301. fontWeight: FontWeight.w600,
  302. ),
  303. ),
  304. ),
  305. ),
  306. ],
  307. ),
  308. ),
  309. ],
  310. ),
  311. ),
  312. );
  313. }
  314. }