Files
nj/app/lib/features/auth/views/class_code_join_page.dart

307 lines
9.0 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 班级码加入页面 — 学生/家长通过 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<ClassCodeJoinPage> createState() => _ClassCodeJoinPageState();
}
class _ClassCodeJoinPageState extends State<ClassCodeJoinPage> {
final List<TextEditingController> _controllers = List.generate(
DesignTokens.classCodeLength,
(_) => TextEditingController(),
);
final List<FocusNode> _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<AuthBloc>().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<AuthBloc, AuthState>(
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<AuthBloc, AuthState>(
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();
},
),
);
}
}