| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447 |
- import 'package:flutter/gestures.dart';
- 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 '../user/protocol_screen.dart';
- import 'auth_widgets.dart';
- import 'verify_code_screen.dart';
- class RegisterScreen extends ConsumerStatefulWidget {
- const RegisterScreen({super.key});
- @override
- ConsumerState<RegisterScreen> createState() => _RegisterScreenState();
- }
- class _RegisterScreenState extends ConsumerState<RegisterScreen> {
- final _emailController = TextEditingController();
- final _passwordController = TextEditingController();
- final _inviteController = TextEditingController();
- final _emailFocusNode = FocusNode();
- bool _obscurePassword = true;
- bool _agreed = false;
- bool _emailTouched = false;
- static final _emailRegex = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$');
- bool get _emailValid => _emailRegex.hasMatch(_emailController.text.trim());
- bool get _has6to16 =>
- _passwordController.text.length >= 6 &&
- _passwordController.text.length <= 16;
- bool get _hasDigit => RegExp(r'[0-9]').hasMatch(_passwordController.text);
- bool get _hasLetter =>
- RegExp(r'[a-zA-Z]').hasMatch(_passwordController.text);
- bool get _passwordValid => _has6to16 && _hasDigit && _hasLetter;
- @override
- void initState() {
- super.initState();
- _emailFocusNode.addListener(() {
- if (!_emailFocusNode.hasFocus && _emailController.text.isNotEmpty) {
- setState(() => _emailTouched = true);
- }
- });
- }
- @override
- void dispose() {
- _emailController.dispose();
- _passwordController.dispose();
- _inviteController.dispose();
- _emailFocusNode.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]}';
- }
- /// 点击注册 → 发送验证码成功后 → 跳转验证码页面
- Future<void> _handleRegisterTap() async {
- final notifier = ref.read(authProvider.notifier);
- final email = _emailController.text.trim();
- if (email.isEmpty || !_passwordValid) return;
- final ok = await notifier.sendRegisterCode(email: email);
- if (!ok || !mounted) return;
- notifier.setPendingRegister(
- email: email,
- password: _passwordController.text,
- inviteCode: _inviteController.text.trim(),
- );
- context.push(
- '/verify-code',
- extra: VerifyCodeArgs(
- email: email,
- password: _passwordController.text,
- maskedEmail: _maskedEmail,
- mode: VerifyCodeMode.register,
- showAuthenticator: false,
- 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 canSubmit = _agreed && _emailValid && _passwordValid && !state.isLoading;
- final metCount = [_has6to16, _hasDigit, _hasLetter].where((m) => m).length;
- final pwdTyped = _passwordController.text.isNotEmpty;
- return Scaffold(
- appBar: AppBar(
- elevation: 0,
- leading: IconButton(
- icon: const Icon(Icons.chevron_left, size: 28),
- onPressed: () => context.pop(),
- ),
- actions: [
- TextButton(
- onPressed: () => context.pop(),
- child: Text(
- l10n.login,
- style: TextStyle(color: cs.onSurface, fontSize: 14),
- ),
- ),
- ],
- ),
- body: SingleChildScrollView(
- padding: const EdgeInsets.fromLTRB(24, 8, 24, 0),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- // Logo + 标题(居中)
- Center(
- child: Column(
- children: [
- ClipRRect(
- borderRadius: BorderRadius.circular(32),
- child: Image.asset(
- 'assets/images/cv_logo.png',
- width: 64,
- height: 64,
- ),
- ),
- const SizedBox(height: 16),
- Text(
- l10n.welcomeJoin,
- style: TextStyle(
- color: cs.onSurface,
- fontSize: 22,
- fontWeight: FontWeight.w700,
- ),
- ),
- ],
- ),
- ),
- const SizedBox(height: 28),
- // 邮箱
- Text(l10n.emailRegister,
- style: TextStyle(
- color: cs.onSurface.withAlpha(153), fontSize: 13)),
- const SizedBox(height: 8),
- AuthField(
- controller: _emailController,
- hint: l10n.emailHint,
- keyboardType: TextInputType.emailAddress,
- maxLength: 100,
- focusNode: _emailFocusNode,
- onChanged: (_) => setState(() { _emailTouched = false; }),
- errorText: _emailTouched && !_emailValid ? l10n.emailError : null,
- semanticsLabel: 'register_input_email',
- ),
- const SizedBox(height: 20),
- // 密码
- Text(l10n.loginPassword,
- style: TextStyle(
- color: cs.onSurface.withAlpha(153), fontSize: 13)),
- const SizedBox(height: 8),
- AuthField(
- controller: _passwordController,
- hint: l10n.loginPasswordHint,
- obscure: _obscurePassword,
- maxLength: 16,
- onChanged: (_) => setState(() {}),
- semanticsLabel: 'register_input_password',
- // 只允许 ASCII 可打印字符(字母、数字、英文标点),禁止中文及特殊字符
- inputFormatters: [
- FilteringTextInputFormatter.allow(RegExp(r'[\x21-\x7E]')),
- ],
- suffixIcon: IconButton(
- onPressed: () =>
- setState(() => _obscurePassword = !_obscurePassword),
- icon: Icon(
- _obscurePassword
- ? Icons.visibility_off_outlined
- : Icons.visibility_outlined,
- color: cs.onSurface.withAlpha(153),
- size: 20,
- ),
- ),
- ),
- const SizedBox(height: 10),
- // 密码强度提示区域
- Container(
- padding: const EdgeInsets.all(12),
- decoration: BoxDecoration(
- border: Border(
- bottom: BorderSide(color: cs.onSurface.withAlpha(100)),
- ),
- ),
- child: Column(
- children: [
- Row(
- children: [
- _StrengthChip(label: l10n.pwdCharsRule, met: _has6to16, typed: pwdTyped),
- const SizedBox(width: 8),
- _StrengthChip(label: l10n.pwdDigitRule, met: _hasDigit, typed: pwdTyped),
- const SizedBox(width: 8),
- _StrengthChip(label: l10n.pwdLetterRule, met: _hasLetter, typed: pwdTyped),
- ],
- ),
- const SizedBox(height: 8),
- Row(
- children: List.generate(3, (i) {
- Color barColor;
- if (!pwdTyped) {
- barColor = cs.onSurface.withAlpha(40);
- } else if (i < metCount) {
- barColor = metCount == 3
- ? AppColors.rise
- : AppColors.fall;
- } else {
- barColor = cs.onSurface.withAlpha(40);
- }
- return Expanded(
- child: Container(
- height: 3,
- margin: EdgeInsets.only(right: i < 2 ? 6 : 0),
- decoration: BoxDecoration(
- color: barColor,
- borderRadius: BorderRadius.circular(2),
- ),
- ),
- );
- }),
- ),
- ],
- ),
- ),
- const SizedBox(height: 20),
- // 邀请码
- Text(l10n.inviteCode,
- style: TextStyle(
- color: cs.onSurface.withAlpha(153), fontSize: 13)),
- const SizedBox(height: 8),
- AuthField(
- controller: _inviteController,
- hint: l10n.inviteCodeHint,
- maxLength: 10,
- semanticsLabel: 'register_input_invite_code',
- ),
- const SizedBox(height: 4),
- Text(
- l10n.inviteCodeTip,
- style: TextStyle(
- color: cs.onSurface.withAlpha(153), fontSize: 11),
- ),
- const SizedBox(height: 20),
- // 协议
- Row(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Semantics(
- label: 'register_checkbox_agree',
- button: true,
- enabled: true,
- onTap: () => setState(() => _agreed = !_agreed),
- child: GestureDetector(
- onTap: () => setState(() => _agreed = !_agreed),
- child: Container(
- width: 20,
- height: 20,
- margin: const EdgeInsets.only(top: 1),
- decoration: BoxDecoration(
- color: _agreed ? AppColors.brand : Colors.transparent,
- border: Border.all(
- color: _agreed
- ? AppColors.brand
- : cs.onSurface.withAlpha(100),
- width: 1.5,
- ),
- borderRadius: BorderRadius.circular(4),
- ),
- child: _agreed
- ? const Icon(Icons.check, size: 14, color: Colors.black)
- : null,
- ),
- ),
- ),
- const SizedBox(width: 8),
- Expanded(
- child: Text.rich(
- TextSpan(
- style: TextStyle(
- color: cs.onSurface.withAlpha(153),
- fontSize: 12,
- ),
- children: [
- TextSpan(text: l10n.agreePrefix),
- TextSpan(
- text: l10n.termsOfService,
- style: TextStyle(color: cs.onSurface),
- recognizer: TapGestureRecognizer()
- ..onTap = () => context.push('/protocol',
- extra: ProtocolArgs(title: l10n.termsOfService)),
- ),
- const TextSpan(text: '、'),
- TextSpan(
- text: l10n.privacyPolicy,
- style: TextStyle(color: cs.onSurface),
- recognizer: TapGestureRecognizer()
- ..onTap = () => context.push('/protocol',
- extra: ProtocolArgs(
- title: l10n.privacyPolicy,
- categoryCode: 'PRIVACY',
- )),
- ),
- ],
- ),
- ),
- ),
- ],
- ),
- const SizedBox(height: 32),
- // 注册按钮
- SafeArea(
- top: false,
- child: Semantics(
- label: 'register_btn_submit',
- button: true,
- enabled: canSubmit,
- onTap: canSubmit ? _handleRegisterTap : null,
- child: SizedBox(
- width: double.infinity,
- height: 50,
- child: ElevatedButton(
- onPressed: canSubmit ? _handleRegisterTap : null,
- style: ElevatedButton.styleFrom(
- backgroundColor: AppColors.brand,
- disabledBackgroundColor: AppColors.brand.withAlpha(80),
- shape: RoundedRectangleBorder(
- borderRadius: BorderRadius.circular(25),
- ),
- ),
- child: state.isLoading
- ? SizedBox(
- width: 20,
- height: 20,
- child: CircularProgressIndicator(
- strokeWidth: 2, color: Colors.black),
- )
- : Text(
- l10n.registerAccount,
- style: TextStyle(
- color: canSubmit
- ? Colors.black
- : Colors.black.withAlpha(153),
- fontSize: 16,
- fontWeight: FontWeight.w600,
- ),
- ),
- ),
- ),
- ),
- ),
- const SizedBox(height: 24),
- ],
- ),
- ),
- );
- }
- }
- // ── 密码强度提示芯片 ──────────────────────────────────────────
- class _StrengthChip extends StatelessWidget {
- const _StrengthChip({required this.label, required this.met, this.typed = false});
- final String label;
- final bool met;
- final bool typed;
- @override
- Widget build(BuildContext context) {
- final Color color;
- final IconData icon;
- if (!typed) {
- color = Theme.of(context).colorScheme.onSurface.withAlpha(120);
- icon = Icons.circle;
- } else if (met) {
- color = AppColors.rise;
- icon = Icons.check;
- } else {
- color = AppColors.fall;
- icon = Icons.close;
- }
- return Container(
- padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
- decoration: BoxDecoration(
- borderRadius: BorderRadius.circular(4),
- border: Border.all(color: color.withAlpha(80), width: 1),
- ),
- child: Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- Icon(icon, size: icon == Icons.circle ? 6 : 12, color: color),
- const SizedBox(width: 4),
- Text(
- label,
- style: TextStyle(color: color, fontSize: 11),
- ),
- ],
- ),
- );
- }
- }
|