639 lines
20 KiB
Dart
639 lines
20 KiB
Dart
/// ============================================================
|
||
/// 闲言APP — 注册区域组件
|
||
/// 创建时间: 2026-05-10
|
||
/// 更新时间: 2026-06-08
|
||
/// 作用: 登录页面的注册区域,分步式注册流程(主控组件)
|
||
/// 上次更新: 邮箱变更检测重置验证码、Timer.periodic倒计时、步骤切换动画
|
||
/// ============================================================
|
||
|
||
import 'dart:async';
|
||
|
||
import 'package:flutter/cupertino.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
|
||
import '../../../../core/router/app_nav_extension.dart';
|
||
import '../../../../core/theme/app_theme.dart';
|
||
import '../../../../core/theme/app_spacing.dart';
|
||
import '../../../../core/theme/app_typography.dart';
|
||
import '../../../../core/theme/app_radius.dart';
|
||
import '../../../../core/utils/logger.dart';
|
||
import '../../../../l10n/translation_resolver.dart';
|
||
import '../../../../l10n/types/t.dart';
|
||
import '../../../../shared/widgets/feedback/app_toast.dart';
|
||
import '../../../../shared/widgets/containers/glass_container.dart';
|
||
import '../providers/auth_provider.dart';
|
||
import '../services/email_service.dart';
|
||
import '../services/user_security_service.dart';
|
||
import 'register_step_account.dart';
|
||
import 'register_step_verify.dart';
|
||
import 'register_step_password.dart';
|
||
import 'register_dialogs.dart';
|
||
|
||
class RegisterSection extends ConsumerStatefulWidget {
|
||
const RegisterSection({
|
||
super.key,
|
||
required this.ext,
|
||
required this.onSwitchToLogin,
|
||
required this.onRegisterSuccess,
|
||
});
|
||
|
||
final AppThemeExtension ext;
|
||
final VoidCallback onSwitchToLogin;
|
||
final VoidCallback onRegisterSuccess;
|
||
|
||
@override
|
||
ConsumerState<RegisterSection> createState() => _RegisterSectionState();
|
||
}
|
||
|
||
class _RegisterSectionState extends ConsumerState<RegisterSection>
|
||
with TickerProviderStateMixin {
|
||
// ============================================================
|
||
// 状态变量
|
||
// ============================================================
|
||
|
||
int _regStep = 1;
|
||
bool _obscureRegPassword = true;
|
||
bool _subscribeEmail = false;
|
||
bool _agreedToTerms = false;
|
||
bool _emsSending = false;
|
||
int _emsCountdown = 0;
|
||
bool _showSecQuestion = false;
|
||
bool _showSecQuestionTip = false;
|
||
int? _selectedSecQuestion;
|
||
String _selectedSecQuestionText = '';
|
||
List<SecQuestionItem> _secQuestions = [];
|
||
|
||
/// 记录发送验证码时的邮箱,用于检测邮箱变更
|
||
String _emailWhenCodeSent = '';
|
||
|
||
/// 倒计时定时器
|
||
Timer? _countdownTimer;
|
||
|
||
/// 步骤切换动画控制器
|
||
late final AnimationController _stepAnimController;
|
||
late final Animation<double> _stepFadeAnimation;
|
||
late final Animation<Offset> _stepSlideAnimation;
|
||
|
||
// ============================================================
|
||
// 控制器
|
||
// ============================================================
|
||
|
||
final _usernameController = TextEditingController();
|
||
final _emailController = TextEditingController();
|
||
final _regPasswordController = TextEditingController();
|
||
final _regConfirmPasswordController = TextEditingController();
|
||
final _regCodeController = TextEditingController();
|
||
final _secAnswerController = TextEditingController();
|
||
|
||
// ============================================================
|
||
// 生命周期
|
||
// ============================================================
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_usernameController.addListener(() => setState(() {}));
|
||
_emailController.addListener(() => setState(() {}));
|
||
_regPasswordController.addListener(() => setState(() {}));
|
||
_regConfirmPasswordController.addListener(() => setState(() {}));
|
||
_regCodeController.addListener(() => setState(() {}));
|
||
_secAnswerController.addListener(() => setState(() {}));
|
||
_loadSecQuestions();
|
||
|
||
// 步骤切换动画初始化
|
||
_stepAnimController = AnimationController(
|
||
vsync: this,
|
||
duration: const Duration(milliseconds: 350),
|
||
);
|
||
_stepFadeAnimation = CurvedAnimation(
|
||
parent: _stepAnimController,
|
||
curve: Curves.easeOut,
|
||
);
|
||
_stepSlideAnimation =
|
||
Tween<Offset>(begin: const Offset(0.15, 0), end: Offset.zero).animate(
|
||
CurvedAnimation(
|
||
parent: _stepAnimController,
|
||
curve: Curves.easeOutCubic,
|
||
),
|
||
);
|
||
_stepAnimController.value = 1.0;
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_countdownTimer?.cancel();
|
||
_stepAnimController.dispose();
|
||
_usernameController.dispose();
|
||
_emailController.dispose();
|
||
_regPasswordController.dispose();
|
||
_regConfirmPasswordController.dispose();
|
||
_regCodeController.dispose();
|
||
_secAnswerController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
AppThemeExtension get ext => widget.ext;
|
||
|
||
// ============================================================
|
||
// 步骤切换(含动画)
|
||
// ============================================================
|
||
|
||
void _goToStep(int step) {
|
||
if (step == _regStep) return;
|
||
_stepAnimController.reverse(from: 1.0).then((_) {
|
||
if (mounted) {
|
||
setState(() => _regStep = step);
|
||
_stepAnimController.forward(from: 0.0);
|
||
}
|
||
});
|
||
}
|
||
|
||
// ============================================================
|
||
// 构建方法
|
||
// ============================================================
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final authState = ref.watch(authProvider);
|
||
final t = ref.watch(translationsProvider);
|
||
final auth = t.auth;
|
||
final common = t.common;
|
||
|
||
return Column(
|
||
children: [
|
||
_buildHeader(auth),
|
||
_buildStepIndicator(),
|
||
const SizedBox(height: AppSpacing.md),
|
||
GlassContainer(
|
||
depth: GlassDepth.elevated,
|
||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||
child: FadeTransition(
|
||
opacity: _stepFadeAnimation,
|
||
child: SlideTransition(
|
||
position: _stepSlideAnimation,
|
||
child: _buildRegStepContent(authState, auth, common),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: AppSpacing.md),
|
||
CupertinoButton(
|
||
onPressed: () {
|
||
widget.onSwitchToLogin();
|
||
setState(() => _regStep = 1);
|
||
},
|
||
child: Text(
|
||
auth.hasAccountLogin,
|
||
style: AppTypography.subhead.copyWith(color: ext.accent),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
// ============================================================
|
||
// 头部区域
|
||
// ============================================================
|
||
|
||
Widget _buildHeader(TAuth auth) {
|
||
return Padding(
|
||
padding: const EdgeInsets.only(bottom: AppSpacing.xl),
|
||
child: Row(
|
||
children: [
|
||
Container(
|
||
width: 56,
|
||
height: 56,
|
||
decoration: BoxDecoration(
|
||
gradient: LinearGradient(
|
||
colors: [ext.accent, ext.accentLight],
|
||
begin: Alignment.topLeft,
|
||
end: Alignment.bottomRight,
|
||
),
|
||
borderRadius: AppRadius.lgBorder,
|
||
),
|
||
child: Icon(
|
||
CupertinoIcons.person_add,
|
||
size: 28,
|
||
color: ext.textInverse,
|
||
),
|
||
),
|
||
const SizedBox(width: AppSpacing.md),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
auth.createAccount,
|
||
style: AppTypography.title2.copyWith(
|
||
color: ext.textPrimary,
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
),
|
||
const SizedBox(height: 2),
|
||
Text(
|
||
auth.registerNewAccount,
|
||
style: AppTypography.subhead.copyWith(
|
||
color: ext.textSecondary,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
GestureDetector(
|
||
onTap: () =>
|
||
RegisterDialogs.showRegisterTips(context, ext: ext, auth: auth),
|
||
child: Icon(
|
||
CupertinoIcons.info_circle,
|
||
size: 22,
|
||
color: ext.textHint,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
// ============================================================
|
||
// 步骤指示器
|
||
// ============================================================
|
||
|
||
Widget _buildStepIndicator() {
|
||
return Row(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: List.generate(3, (i) {
|
||
final step = i + 1;
|
||
final isActive = step == _regStep;
|
||
final isDone = step < _regStep;
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||
child: AnimatedContainer(
|
||
duration: const Duration(milliseconds: 300),
|
||
width: isActive ? 24 : 8,
|
||
height: 8,
|
||
decoration: BoxDecoration(
|
||
color: isDone
|
||
? ext.accent.withValues(alpha: 0.5)
|
||
: isActive
|
||
? ext.accent
|
||
: ext.textHint.withValues(alpha: 0.3),
|
||
borderRadius: AppRadius.smBorder,
|
||
),
|
||
),
|
||
);
|
||
}),
|
||
);
|
||
}
|
||
|
||
// ============================================================
|
||
// 步骤内容分发
|
||
// ============================================================
|
||
|
||
Widget _buildRegStepContent(AuthState authState, TAuth auth, TCommon common) {
|
||
switch (_regStep) {
|
||
case 1:
|
||
return RegisterStepAccount(
|
||
usernameController: _usernameController,
|
||
emailController: _emailController,
|
||
onNext: () {
|
||
_emailWhenCodeSent = _emailController.text.trim();
|
||
_goToStep(2);
|
||
_sendCode(_emailController.text.trim());
|
||
},
|
||
agreedToTerms: _agreedToTerms,
|
||
onToggleAgreement: () =>
|
||
setState(() => _agreedToTerms = !_agreedToTerms),
|
||
onShowAgreement: _showAgreement,
|
||
ext: ext,
|
||
auth: auth,
|
||
);
|
||
|
||
case 2:
|
||
return RegisterStepVerify(
|
||
email: _emailController.text.trim(),
|
||
codeController: _regCodeController,
|
||
emSending: _emsSending,
|
||
emsCountdown: _emsCountdown,
|
||
showSecQuestionTip: _showSecQuestionTip,
|
||
onSendCode: () => _sendCode(_emailController.text.trim()),
|
||
onPrev: () {
|
||
// 返回步骤1时,检测邮箱是否变更,若变更则重置验证码状态
|
||
_resetCodeStateIfEmailChanged();
|
||
_goToStep(1);
|
||
},
|
||
onNext: () {
|
||
setState(() {
|
||
_regStep = 3;
|
||
_showSecQuestionTip = false;
|
||
});
|
||
_stepAnimController.forward(from: 0.0);
|
||
},
|
||
onSkipToSecQuestion: () {
|
||
setState(() {
|
||
_regStep = 3;
|
||
_showSecQuestion = true;
|
||
_showSecQuestionTip = false;
|
||
});
|
||
_stepAnimController.forward(from: 0.0);
|
||
},
|
||
ext: ext,
|
||
auth: auth,
|
||
);
|
||
|
||
case 3:
|
||
return RegisterStepPassword(
|
||
username: _usernameController.text.trim(),
|
||
email: _emailController.text.trim(),
|
||
passwordController: _regPasswordController,
|
||
confirmPasswordController: _regConfirmPasswordController,
|
||
secAnswerController: _secAnswerController,
|
||
obscurePassword: _obscureRegPassword,
|
||
onToggleObscure: () =>
|
||
setState(() => _obscureRegPassword = !_obscureRegPassword),
|
||
showSecQuestion: _showSecQuestion,
|
||
onToggleSecQuestion: () =>
|
||
setState(() => _showSecQuestion = !_showSecQuestion),
|
||
secQuestions: _secQuestions,
|
||
selectedSecQuestion: _selectedSecQuestion,
|
||
selectedSecQuestionText: _selectedSecQuestionText,
|
||
onSelectSecQuestion: () => RegisterDialogs.showSecQuestionPicker(
|
||
context,
|
||
ext: ext,
|
||
auth: auth,
|
||
common: common,
|
||
secQuestions: _secQuestions,
|
||
selectedSecQuestion: _selectedSecQuestion,
|
||
onSelected: (index) {
|
||
if (index < _secQuestions.length) {
|
||
setState(() {
|
||
_selectedSecQuestion = _secQuestions[index].id;
|
||
_selectedSecQuestionText = _secQuestions[index].question;
|
||
});
|
||
}
|
||
},
|
||
),
|
||
subscribeEmail: _subscribeEmail,
|
||
onToggleSubscribe: () =>
|
||
setState(() => _subscribeEmail = !_subscribeEmail),
|
||
onPrev: () => _goToStep(2),
|
||
onBack: () => _goToStep(1),
|
||
onRegister: _handleRegister,
|
||
isLoading: authState.isLoading,
|
||
canRegister:
|
||
_agreedToTerms &&
|
||
_regPasswordController.text.isNotEmpty &&
|
||
_regConfirmPasswordController.text.isNotEmpty &&
|
||
!authState.isLoading,
|
||
ext: ext,
|
||
auth: auth,
|
||
);
|
||
|
||
default:
|
||
return const SizedBox.shrink();
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// 业务逻辑:邮箱变更检测,重置验证码状态
|
||
// ============================================================
|
||
|
||
void _resetCodeStateIfEmailChanged() {
|
||
final currentEmail = _emailController.text.trim();
|
||
if (currentEmail != _emailWhenCodeSent) {
|
||
// 邮箱已变更,重置验证码相关状态
|
||
_countdownTimer?.cancel();
|
||
_regCodeController.clear();
|
||
setState(() {
|
||
_emsSending = false;
|
||
_emsCountdown = 0;
|
||
_showSecQuestionTip = false;
|
||
});
|
||
Log.d('邮箱已变更,验证码状态已重置');
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// 业务逻辑:加载密保问题
|
||
// ============================================================
|
||
|
||
Future<void> _loadSecQuestions() async {
|
||
try {
|
||
final questions = await UserSecurityService.secQuestions();
|
||
if (mounted) {
|
||
setState(() => _secQuestions = questions);
|
||
}
|
||
} catch (e) {
|
||
Log.w('加载密保问题列表失败: $e');
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// 业务逻辑:协议跳转
|
||
// ============================================================
|
||
|
||
void _showAgreement(bool isUserAgreement) {
|
||
if (isUserAgreement) {
|
||
context.appPush('/agreement/user-service-agreement');
|
||
} else {
|
||
context.appPush('/agreement/privacy-policy');
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// 业务逻辑:注册处理
|
||
// ============================================================
|
||
|
||
Future<void> _handleRegister() async {
|
||
final t = ref.read(translationsProvider);
|
||
final username = _usernameController.text.trim();
|
||
final password = _regPasswordController.text;
|
||
final confirmPassword = _regConfirmPasswordController.text;
|
||
final email = _emailController.text.trim();
|
||
final code = _regCodeController.text.trim();
|
||
|
||
// 表单校验
|
||
if (username.isEmpty || password.isEmpty || email.isEmpty) {
|
||
AppToast.showWarning(t.auth.pleaseFillRequired);
|
||
return;
|
||
}
|
||
|
||
if (password != confirmPassword) {
|
||
AppToast.showError(t.auth.passwordMismatch);
|
||
return;
|
||
}
|
||
|
||
if (password.length < 6) {
|
||
AppToast.showWarning(t.auth.passwordTooShort);
|
||
return;
|
||
}
|
||
|
||
// 邮箱验证码校验:如果用户填写了验证码则验证,未填写则跳过(使用密保替代)
|
||
if (code.isNotEmpty && !EmailService.verifyCode(email: email, code: code)) {
|
||
AppToast.showError(t.auth.codeError);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
final success = await ref
|
||
.read(authProvider.notifier)
|
||
.register(
|
||
username: username,
|
||
password: password,
|
||
email: email,
|
||
secQuestion: _selectedSecQuestion,
|
||
secAnswer: _secAnswerController.text.trim().isNotEmpty
|
||
? _secAnswerController.text.trim()
|
||
: null,
|
||
);
|
||
if (success && mounted) {
|
||
AppToast.showSuccess(t.auth.registerSuccess);
|
||
widget.onRegisterSuccess();
|
||
}
|
||
} catch (e) {
|
||
_handleRegisterError(e, email, username, password);
|
||
}
|
||
}
|
||
|
||
/// 处理注册错误
|
||
void _handleRegisterError(
|
||
Object e,
|
||
String email,
|
||
String username,
|
||
String password,
|
||
) {
|
||
final errorMsg = e.toString();
|
||
if (errorMsg.contains('已注册') ||
|
||
errorMsg.contains('already') ||
|
||
errorMsg.contains('已被') ||
|
||
errorMsg.contains('exists')) {
|
||
_showEmailRegisteredDialog(email);
|
||
} else if (errorMsg.contains('内部服务器错误') ||
|
||
errorMsg.contains('Internal') ||
|
||
errorMsg.contains('服务器错误')) {
|
||
_handleServerErrorAfterRegister(username, password, email);
|
||
} else {
|
||
AppToast.showError(errorMsg);
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// 业务逻辑:发送验证码
|
||
// ============================================================
|
||
|
||
Future<void> _sendCode(String email) async {
|
||
final t = ref.read(translationsProvider);
|
||
if (_emsSending || _emsCountdown > 0 || email.isEmpty) return;
|
||
|
||
if (!email.contains('@')) {
|
||
AppToast.showWarning(t.auth.pleaseEnterValidEmail);
|
||
return;
|
||
}
|
||
|
||
setState(() {
|
||
_emsSending = true;
|
||
_emsCountdown = 60;
|
||
});
|
||
_startCountdown();
|
||
|
||
try {
|
||
final code = EmailService.generateCode();
|
||
final ok = await EmailService.sendVerificationCode(
|
||
toEmail: email,
|
||
code: code,
|
||
);
|
||
if (!ok && mounted) {
|
||
AppToast.showError(t.auth.codeSendFailed);
|
||
_cancelCountdown();
|
||
} else if (mounted) {
|
||
AppToast.showSuccess(t.auth.codeSent);
|
||
setState(() => _emsSending = false);
|
||
}
|
||
} catch (e) {
|
||
Log.e('发送验证码失败', e);
|
||
if (mounted) {
|
||
final errorMsg = e.toString();
|
||
if (errorMsg.contains('已注册') ||
|
||
errorMsg.contains('already') ||
|
||
errorMsg.contains('exists')) {
|
||
_showEmailRegisteredDialog(email);
|
||
} else {
|
||
AppToast.showError(t.auth.codeSendFailedShort);
|
||
}
|
||
_cancelCountdown();
|
||
}
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// 业务逻辑:倒计时(Timer.periodic)
|
||
// ============================================================
|
||
|
||
void _startCountdown() {
|
||
_countdownTimer?.cancel();
|
||
_countdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||
if (!mounted) {
|
||
timer.cancel();
|
||
return;
|
||
}
|
||
setState(() {
|
||
_emsCountdown--;
|
||
if (_emsCountdown <= 0) {
|
||
_emsCountdown = 0;
|
||
_showSecQuestionTip = true;
|
||
_emsSending = false;
|
||
timer.cancel();
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
/// 取消倒计时并重置状态
|
||
void _cancelCountdown() {
|
||
_countdownTimer?.cancel();
|
||
if (mounted) {
|
||
setState(() {
|
||
_emsSending = false;
|
||
_emsCountdown = 0;
|
||
});
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// 业务逻辑:邮箱已注册弹窗
|
||
// ============================================================
|
||
|
||
void _showEmailRegisteredDialog(String email) {
|
||
final t = ref.read(translationsProvider);
|
||
RegisterDialogs.showEmailRegistered(
|
||
context,
|
||
email: email,
|
||
auth: t.auth,
|
||
common: t.common,
|
||
onGoLogin: widget.onSwitchToLogin,
|
||
);
|
||
}
|
||
|
||
// ============================================================
|
||
// 业务逻辑:服务器错误后尝试登录确认
|
||
// ============================================================
|
||
|
||
Future<void> _handleServerErrorAfterRegister(
|
||
String username,
|
||
String password,
|
||
String email,
|
||
) async {
|
||
final t = ref.read(translationsProvider);
|
||
try {
|
||
final success = await ref
|
||
.read(authProvider.notifier)
|
||
.login(account: username, password: password);
|
||
if (success && mounted) {
|
||
AppToast.showSuccess(t.auth.registerSuccess);
|
||
widget.onRegisterSuccess();
|
||
}
|
||
} catch (_) {
|
||
if (mounted) {
|
||
_showEmailRegisteredDialog(email);
|
||
}
|
||
}
|
||
}
|
||
}
|