// 班级码加入页面 — 学生/家长通过 6 位码加入班级 // // 设计要点: // - 6 位独立输入框,自动聚焦下一位 // - 输入完成后自动提交验证 // - 安全限制:5 次错误后锁定 30 分钟 // - 友好的状态反馈(验证中/成功/失败) import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import '../../../core/constants/design_tokens.dart'; import '../bloc/auth_bloc.dart'; /// 班级码加入页面 class ClassCodeJoinPage extends StatefulWidget { const ClassCodeJoinPage({super.key}); @override State createState() => _ClassCodeJoinPageState(); } class _ClassCodeJoinPageState extends State { final List _controllers = List.generate( DesignTokens.classCodeLength, (_) => TextEditingController(), ); final List _focusNodes = List.generate( DesignTokens.classCodeLength, (_) => FocusNode(), ); int _failedAttempts = 0; DateTime? _lockoutEndTime; /// 当前是否被锁定 bool get _isCurrentlyLocked { if (_lockoutEndTime == null) return false; if (DateTime.now().isAfter(_lockoutEndTime!)) { _lockoutEndTime = null; return false; } return true; } @override void initState() { super.initState(); // 自动聚焦第一个输入框 WidgetsBinding.instance.addPostFrameCallback((_) { _focusNodes[0].requestFocus(); }); } @override void dispose() { for (final c in _controllers) { c.dispose(); } for (final f in _focusNodes) { f.dispose(); } super.dispose(); } /// 获取当前输入的班级码 String get _classCode => _controllers.map((c) => c.text).join(); /// 是否所有位都已输入 bool get _isComplete => _controllers.every((c) => c.text.isNotEmpty); /// 清空所有输入框 void _clearInputs() { for (final c in _controllers) { c.clear(); } _focusNodes[0].requestFocus(); } void _onChanged(int index, String value) { if (value.isEmpty && index > 0) { // 退格清空 → 跳到前一位 _focusNodes[index - 1].requestFocus(); } else if (value.isNotEmpty && index < DesignTokens.classCodeLength - 1) { // 输入字符 → 跳到下一位 _focusNodes[index + 1].requestFocus(); } // 全部输入完成 → 自动提交 if (_isComplete) { _submit(); } } void _submit() { if (!_isComplete || _isCurrentlyLocked) return; context.read().add(ClassCodeSubmitted(_classCode)); } /// 处理 AuthBloc 状态变化 void _handleAuthState(BuildContext context, AuthState state) { if (state is! Authenticated) return; // 成功加入班级 if (!state.needsClassCode) { context.go('/home'); return; } // 班级码验证错误 final error = state.classCodeError; if (error != null && error.isNotEmpty) { _failedAttempts++; // 检查是否为锁定错误 if (error.contains('30 分钟') || error.contains('尝试次数过多')) { setState(() { _lockoutEndTime = DateTime.now().add( Duration(minutes: DesignTokens.classCodeLockoutMinutes), ); }); } // 清空输入 _clearInputs(); // 显示错误提示 if (mounted) { ScaffoldMessenger.of(context).clearSnackBars(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(error), backgroundColor: Theme.of(context).colorScheme.error, duration: const Duration(seconds: 3), ), ); } } } @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; return BlocListener( listener: _handleAuthState, child: Scaffold( body: SafeArea( child: Padding( padding: const EdgeInsets.symmetric( horizontal: DesignTokens.spacing24, ), child: Column( children: [ const Spacer(flex: 2), // 标题 Icon( Icons.groups_rounded, size: 64, color: colorScheme.primary, ), const SizedBox(height: DesignTokens.spacing24), Text( '加入班级', style: Theme.of(context).textTheme.headlineMedium?.copyWith( fontWeight: FontWeight.bold, ), ), const SizedBox(height: DesignTokens.spacing8), Text( '输入老师提供的 6 位班级码', style: Theme.of(context).textTheme.bodyLarge?.copyWith( color: colorScheme.onSurface.withValues(alpha: 0.6), ), ), const SizedBox(height: DesignTokens.spacing48), // 锁定状态 or 输入框 if (_isCurrentlyLocked) _buildLockoutView(context, colorScheme) else _buildCodeInputs(context, colorScheme), const SizedBox(height: DesignTokens.spacing24), // 跳过按钮 TextButton( onPressed: () => context.go('/home'), child: Text( '稍后再加入', style: TextStyle( color: colorScheme.onSurface.withValues(alpha: 0.5), ), ), ), // 剩余尝试次数提示 if (!_isCurrentlyLocked && _failedAttempts > 0) Padding( padding: const EdgeInsets.only(top: 8), child: Text( '剩余 ${DesignTokens.classCodeMaxAttempts - _failedAttempts} 次尝试机会', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: _failedAttempts >= 4 ? colorScheme.error : colorScheme.onSurface.withValues(alpha: 0.5), ), ), ), const Spacer(flex: 3), ], ), ), ), ), ); } /// 锁定视图 — 超过最大尝试次数后显示 Widget _buildLockoutView(BuildContext context, ColorScheme colorScheme) { return Column( children: [ Icon(Icons.lock_outline, size: 48, color: colorScheme.error), const SizedBox(height: 16), Text( '尝试次数过多', style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, ), ), const SizedBox(height: 8), Text( '请等待 ${DesignTokens.classCodeLockoutMinutes} 分钟后再试', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: colorScheme.onSurface.withValues(alpha: 0.6), ), ), ], ); } /// 6 位班级码输入框 Widget _buildCodeInputs(BuildContext context, ColorScheme colorScheme) { return BlocBuilder( builder: (context, state) { final isLoading = state is Authenticated && state.isLoading; return Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: List.generate( DesignTokens.classCodeLength, (index) => _buildCodeInput(context, index, colorScheme, isLoading), ), ); }, ); } /// 单个班级码输入框 Widget _buildCodeInput( BuildContext context, int index, ColorScheme colorScheme, bool isLoading, ) { return SizedBox( width: 48, height: 56, child: TextField( controller: _controllers[index], focusNode: _focusNodes[index], enabled: !isLoading, textAlign: TextAlign.center, textAlignVertical: TextAlignVertical.center, style: Theme.of(context).textTheme.headlineSmall?.copyWith( fontWeight: FontWeight.bold, letterSpacing: 0, ), maxLength: 1, keyboardType: TextInputType.text, textCapitalization: TextCapitalization.characters, decoration: InputDecoration( counterText: '', filled: true, fillColor: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none, ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: colorScheme.primary, width: 2), ), ), onChanged: (value) => _onChanged(index, value), onSubmitted: (_) { if (_isComplete) _submit(); }, ), ); } }