register_screen.dart 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447
  1. import 'package:flutter/gestures.dart';
  2. import 'package:flutter/material.dart';
  3. import 'package:flutter/services.dart';
  4. import 'package:flutter_riverpod/flutter_riverpod.dart';
  5. import 'package:go_router/go_router.dart';
  6. import '../../../core/l10n/app_localizations.dart';
  7. import '../../../core/theme/app_colors.dart';
  8. import '../../../core/utils/top_toast.dart';
  9. import '../../../providers/auth_provider.dart';
  10. import '../user/protocol_screen.dart';
  11. import 'auth_widgets.dart';
  12. import 'verify_code_screen.dart';
  13. class RegisterScreen extends ConsumerStatefulWidget {
  14. const RegisterScreen({super.key});
  15. @override
  16. ConsumerState<RegisterScreen> createState() => _RegisterScreenState();
  17. }
  18. class _RegisterScreenState extends ConsumerState<RegisterScreen> {
  19. final _emailController = TextEditingController();
  20. final _passwordController = TextEditingController();
  21. final _inviteController = TextEditingController();
  22. final _emailFocusNode = FocusNode();
  23. bool _obscurePassword = true;
  24. bool _agreed = false;
  25. bool _emailTouched = false;
  26. static final _emailRegex = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$');
  27. bool get _emailValid => _emailRegex.hasMatch(_emailController.text.trim());
  28. bool get _has6to16 =>
  29. _passwordController.text.length >= 6 &&
  30. _passwordController.text.length <= 16;
  31. bool get _hasDigit => RegExp(r'[0-9]').hasMatch(_passwordController.text);
  32. bool get _hasLetter =>
  33. RegExp(r'[a-zA-Z]').hasMatch(_passwordController.text);
  34. bool get _passwordValid => _has6to16 && _hasDigit && _hasLetter;
  35. @override
  36. void initState() {
  37. super.initState();
  38. _emailFocusNode.addListener(() {
  39. if (!_emailFocusNode.hasFocus && _emailController.text.isNotEmpty) {
  40. setState(() => _emailTouched = true);
  41. }
  42. });
  43. }
  44. @override
  45. void dispose() {
  46. _emailController.dispose();
  47. _passwordController.dispose();
  48. _inviteController.dispose();
  49. _emailFocusNode.dispose();
  50. super.dispose();
  51. }
  52. /// 邮箱脱敏
  53. String get _maskedEmail {
  54. final email = _emailController.text.trim();
  55. if (!email.contains('@')) return email;
  56. final parts = email.split('@');
  57. final name = parts[0];
  58. if (name.length <= 1) return '$name***@${parts[1]}';
  59. return '${name[0]}***@${parts[1]}';
  60. }
  61. /// 点击注册 → 发送验证码成功后 → 跳转验证码页面
  62. Future<void> _handleRegisterTap() async {
  63. final notifier = ref.read(authProvider.notifier);
  64. final email = _emailController.text.trim();
  65. if (email.isEmpty || !_passwordValid) return;
  66. final ok = await notifier.sendRegisterCode(email: email);
  67. if (!ok || !mounted) return;
  68. notifier.setPendingRegister(
  69. email: email,
  70. password: _passwordController.text,
  71. inviteCode: _inviteController.text.trim(),
  72. );
  73. context.push(
  74. '/verify-code',
  75. extra: VerifyCodeArgs(
  76. email: email,
  77. password: _passwordController.text,
  78. maskedEmail: _maskedEmail,
  79. mode: VerifyCodeMode.register,
  80. showAuthenticator: false,
  81. codeSent: true,
  82. ),
  83. );
  84. }
  85. @override
  86. Widget build(BuildContext context) {
  87. final l10n = AppLocalizations.of(context)!;
  88. final state = ref.watch(authProvider);
  89. final cs = Theme.of(context).colorScheme;
  90. ref.listen<AuthState>(authProvider, (prev, next) {
  91. if (next.errorMessage != null &&
  92. next.errorMessage != prev?.errorMessage) {
  93. final code = next.errorMessage!;
  94. WidgetsBinding.instance.addPostFrameCallback((_) {
  95. if (!context.mounted) return;
  96. final l10n = AppLocalizations.of(context)!;
  97. showTopToast(
  98. context,
  99. message: resolveProviderError(code, l10n) ?? code,
  100. );
  101. ref.read(authProvider.notifier).clearError();
  102. });
  103. }
  104. });
  105. final canSubmit = _agreed && _emailValid && _passwordValid && !state.isLoading;
  106. final metCount = [_has6to16, _hasDigit, _hasLetter].where((m) => m).length;
  107. final pwdTyped = _passwordController.text.isNotEmpty;
  108. return Scaffold(
  109. appBar: AppBar(
  110. elevation: 0,
  111. leading: IconButton(
  112. icon: const Icon(Icons.chevron_left, size: 28),
  113. onPressed: () => context.pop(),
  114. ),
  115. actions: [
  116. TextButton(
  117. onPressed: () => context.pop(),
  118. child: Text(
  119. l10n.login,
  120. style: TextStyle(color: cs.onSurface, fontSize: 14),
  121. ),
  122. ),
  123. ],
  124. ),
  125. body: SingleChildScrollView(
  126. padding: const EdgeInsets.fromLTRB(24, 8, 24, 0),
  127. child: Column(
  128. crossAxisAlignment: CrossAxisAlignment.start,
  129. children: [
  130. // Logo + 标题(居中)
  131. Center(
  132. child: Column(
  133. children: [
  134. ClipRRect(
  135. borderRadius: BorderRadius.circular(32),
  136. child: Image.asset(
  137. 'assets/images/cv_logo.png',
  138. width: 64,
  139. height: 64,
  140. ),
  141. ),
  142. const SizedBox(height: 16),
  143. Text(
  144. l10n.welcomeJoin,
  145. style: TextStyle(
  146. color: cs.onSurface,
  147. fontSize: 22,
  148. fontWeight: FontWeight.w700,
  149. ),
  150. ),
  151. ],
  152. ),
  153. ),
  154. const SizedBox(height: 28),
  155. // 邮箱
  156. Text(l10n.emailRegister,
  157. style: TextStyle(
  158. color: cs.onSurface.withAlpha(153), fontSize: 13)),
  159. const SizedBox(height: 8),
  160. AuthField(
  161. controller: _emailController,
  162. hint: l10n.emailHint,
  163. keyboardType: TextInputType.emailAddress,
  164. maxLength: 100,
  165. focusNode: _emailFocusNode,
  166. onChanged: (_) => setState(() { _emailTouched = false; }),
  167. errorText: _emailTouched && !_emailValid ? l10n.emailError : null,
  168. semanticsLabel: 'register_input_email',
  169. ),
  170. const SizedBox(height: 20),
  171. // 密码
  172. Text(l10n.loginPassword,
  173. style: TextStyle(
  174. color: cs.onSurface.withAlpha(153), fontSize: 13)),
  175. const SizedBox(height: 8),
  176. AuthField(
  177. controller: _passwordController,
  178. hint: l10n.loginPasswordHint,
  179. obscure: _obscurePassword,
  180. maxLength: 16,
  181. onChanged: (_) => setState(() {}),
  182. semanticsLabel: 'register_input_password',
  183. // 只允许 ASCII 可打印字符(字母、数字、英文标点),禁止中文及特殊字符
  184. inputFormatters: [
  185. FilteringTextInputFormatter.allow(RegExp(r'[\x21-\x7E]')),
  186. ],
  187. suffixIcon: IconButton(
  188. onPressed: () =>
  189. setState(() => _obscurePassword = !_obscurePassword),
  190. icon: Icon(
  191. _obscurePassword
  192. ? Icons.visibility_off_outlined
  193. : Icons.visibility_outlined,
  194. color: cs.onSurface.withAlpha(153),
  195. size: 20,
  196. ),
  197. ),
  198. ),
  199. const SizedBox(height: 10),
  200. // 密码强度提示区域
  201. Container(
  202. padding: const EdgeInsets.all(12),
  203. decoration: BoxDecoration(
  204. border: Border(
  205. bottom: BorderSide(color: cs.onSurface.withAlpha(100)),
  206. ),
  207. ),
  208. child: Column(
  209. children: [
  210. Row(
  211. children: [
  212. _StrengthChip(label: l10n.pwdCharsRule, met: _has6to16, typed: pwdTyped),
  213. const SizedBox(width: 8),
  214. _StrengthChip(label: l10n.pwdDigitRule, met: _hasDigit, typed: pwdTyped),
  215. const SizedBox(width: 8),
  216. _StrengthChip(label: l10n.pwdLetterRule, met: _hasLetter, typed: pwdTyped),
  217. ],
  218. ),
  219. const SizedBox(height: 8),
  220. Row(
  221. children: List.generate(3, (i) {
  222. Color barColor;
  223. if (!pwdTyped) {
  224. barColor = cs.onSurface.withAlpha(40);
  225. } else if (i < metCount) {
  226. barColor = metCount == 3
  227. ? AppColors.rise
  228. : AppColors.fall;
  229. } else {
  230. barColor = cs.onSurface.withAlpha(40);
  231. }
  232. return Expanded(
  233. child: Container(
  234. height: 3,
  235. margin: EdgeInsets.only(right: i < 2 ? 6 : 0),
  236. decoration: BoxDecoration(
  237. color: barColor,
  238. borderRadius: BorderRadius.circular(2),
  239. ),
  240. ),
  241. );
  242. }),
  243. ),
  244. ],
  245. ),
  246. ),
  247. const SizedBox(height: 20),
  248. // 邀请码
  249. Text(l10n.inviteCode,
  250. style: TextStyle(
  251. color: cs.onSurface.withAlpha(153), fontSize: 13)),
  252. const SizedBox(height: 8),
  253. AuthField(
  254. controller: _inviteController,
  255. hint: l10n.inviteCodeHint,
  256. maxLength: 10,
  257. semanticsLabel: 'register_input_invite_code',
  258. ),
  259. const SizedBox(height: 4),
  260. Text(
  261. l10n.inviteCodeTip,
  262. style: TextStyle(
  263. color: cs.onSurface.withAlpha(153), fontSize: 11),
  264. ),
  265. const SizedBox(height: 20),
  266. // 协议
  267. Row(
  268. crossAxisAlignment: CrossAxisAlignment.start,
  269. children: [
  270. Semantics(
  271. label: 'register_checkbox_agree',
  272. button: true,
  273. enabled: true,
  274. onTap: () => setState(() => _agreed = !_agreed),
  275. child: GestureDetector(
  276. onTap: () => setState(() => _agreed = !_agreed),
  277. child: Container(
  278. width: 20,
  279. height: 20,
  280. margin: const EdgeInsets.only(top: 1),
  281. decoration: BoxDecoration(
  282. color: _agreed ? AppColors.brand : Colors.transparent,
  283. border: Border.all(
  284. color: _agreed
  285. ? AppColors.brand
  286. : cs.onSurface.withAlpha(100),
  287. width: 1.5,
  288. ),
  289. borderRadius: BorderRadius.circular(4),
  290. ),
  291. child: _agreed
  292. ? const Icon(Icons.check, size: 14, color: Colors.black)
  293. : null,
  294. ),
  295. ),
  296. ),
  297. const SizedBox(width: 8),
  298. Expanded(
  299. child: Text.rich(
  300. TextSpan(
  301. style: TextStyle(
  302. color: cs.onSurface.withAlpha(153),
  303. fontSize: 12,
  304. ),
  305. children: [
  306. TextSpan(text: l10n.agreePrefix),
  307. TextSpan(
  308. text: l10n.termsOfService,
  309. style: TextStyle(color: cs.onSurface),
  310. recognizer: TapGestureRecognizer()
  311. ..onTap = () => context.push('/protocol',
  312. extra: ProtocolArgs(title: l10n.termsOfService)),
  313. ),
  314. const TextSpan(text: '、'),
  315. TextSpan(
  316. text: l10n.privacyPolicy,
  317. style: TextStyle(color: cs.onSurface),
  318. recognizer: TapGestureRecognizer()
  319. ..onTap = () => context.push('/protocol',
  320. extra: ProtocolArgs(
  321. title: l10n.privacyPolicy,
  322. categoryCode: 'PRIVACY',
  323. )),
  324. ),
  325. ],
  326. ),
  327. ),
  328. ),
  329. ],
  330. ),
  331. const SizedBox(height: 32),
  332. // 注册按钮
  333. SafeArea(
  334. top: false,
  335. child: Semantics(
  336. label: 'register_btn_submit',
  337. button: true,
  338. enabled: canSubmit,
  339. onTap: canSubmit ? _handleRegisterTap : null,
  340. child: SizedBox(
  341. width: double.infinity,
  342. height: 50,
  343. child: ElevatedButton(
  344. onPressed: canSubmit ? _handleRegisterTap : null,
  345. style: ElevatedButton.styleFrom(
  346. backgroundColor: AppColors.brand,
  347. disabledBackgroundColor: AppColors.brand.withAlpha(80),
  348. shape: RoundedRectangleBorder(
  349. borderRadius: BorderRadius.circular(25),
  350. ),
  351. ),
  352. child: state.isLoading
  353. ? SizedBox(
  354. width: 20,
  355. height: 20,
  356. child: CircularProgressIndicator(
  357. strokeWidth: 2, color: Colors.black),
  358. )
  359. : Text(
  360. l10n.registerAccount,
  361. style: TextStyle(
  362. color: canSubmit
  363. ? Colors.black
  364. : Colors.black.withAlpha(153),
  365. fontSize: 16,
  366. fontWeight: FontWeight.w600,
  367. ),
  368. ),
  369. ),
  370. ),
  371. ),
  372. ),
  373. const SizedBox(height: 24),
  374. ],
  375. ),
  376. ),
  377. );
  378. }
  379. }
  380. // ── 密码强度提示芯片 ──────────────────────────────────────────
  381. class _StrengthChip extends StatelessWidget {
  382. const _StrengthChip({required this.label, required this.met, this.typed = false});
  383. final String label;
  384. final bool met;
  385. final bool typed;
  386. @override
  387. Widget build(BuildContext context) {
  388. final Color color;
  389. final IconData icon;
  390. if (!typed) {
  391. color = Theme.of(context).colorScheme.onSurface.withAlpha(120);
  392. icon = Icons.circle;
  393. } else if (met) {
  394. color = AppColors.rise;
  395. icon = Icons.check;
  396. } else {
  397. color = AppColors.fall;
  398. icon = Icons.close;
  399. }
  400. return Container(
  401. padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
  402. decoration: BoxDecoration(
  403. borderRadius: BorderRadius.circular(4),
  404. border: Border.all(color: color.withAlpha(80), width: 1),
  405. ),
  406. child: Row(
  407. mainAxisSize: MainAxisSize.min,
  408. children: [
  409. Icon(icon, size: icon == Icons.circle ? 6 : 12, color: color),
  410. const SizedBox(width: 4),
  411. Text(
  412. label,
  413. style: TextStyle(color: color, fontSize: 11),
  414. ),
  415. ],
  416. ),
  417. );
  418. }
  419. }