399 lines
13 KiB
Dart
399 lines
13 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
|
import 'package:new_learn/core/components/rich_text_content.dart';
|
|
import 'package:new_learn/features/exam/exam_question.dart';
|
|
import 'package:new_learn/features/exam/question_type.dart';
|
|
|
|
/// ============================================================
|
|
/// 【QuestionCard】题目卡片 —— 纯展示组件(业务解耦)
|
|
///
|
|
/// 一、设计原则:
|
|
/// 组件不碰 Riverpod,只接收 question + 当前答案 + 回调,
|
|
/// 由 ExamPage 负责桥接 provider。这样组件可复用、可单测,
|
|
/// 换状态管理方案不用改组件。
|
|
///
|
|
/// 二、题型分发(本项目核心模式):
|
|
/// 按 question.type switch 到三个子视图:
|
|
/// ChoiceQuestionView(单选/多选,共用选项布局,toggle 逻辑区分)
|
|
/// JudgeQuestionView(判断,对/错两按钮)
|
|
/// EssayQuestionView(解答,文本输入 + 防抖提交)
|
|
/// 新增题型只需加一个枚举值 + 一个视图,QuestionCard 加一个 case。
|
|
///
|
|
/// 三、题干渲染:
|
|
/// RichTextContent 纯 widget 渲染 HTML(非 WebView),
|
|
/// 富文本解析发生在每次 build —— 真实项目可在此接入预解析缓存
|
|
/// (进卷时批量解析一次 HTML,按 questionId 缓存解析结果)。
|
|
/// ============================================================
|
|
class QuestionCard extends StatelessWidget {
|
|
const QuestionCard({
|
|
super.key,
|
|
required this.question,
|
|
required this.answer,
|
|
required this.onAnswer,
|
|
});
|
|
|
|
final ExamQuestion question;
|
|
final String? answer;
|
|
final ValueChanged<String> onAnswer;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final type = question.type;
|
|
return Container(
|
|
margin: EdgeInsets.all(16.w),
|
|
padding: EdgeInsets.all(16.w),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(12.r),
|
|
border: Border.all(color: const Color(0xFFEEF1F5)),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// ---------- 题型标签 + 题号 ----------
|
|
Row(
|
|
children: [
|
|
Container(
|
|
padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 3.h),
|
|
decoration: BoxDecoration(
|
|
color: _typeColor(type).withValues(alpha: 0.1),
|
|
borderRadius: BorderRadius.circular(4.r),
|
|
),
|
|
child: Text(
|
|
type.label,
|
|
style: TextStyle(
|
|
fontSize: 11.sp,
|
|
color: _typeColor(type),
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
SizedBox(width: 8.w),
|
|
Text(
|
|
'第 ${question.questionId} 题',
|
|
style: TextStyle(fontSize: 12.sp, color: const Color(0xFF999999)),
|
|
),
|
|
],
|
|
),
|
|
SizedBox(height: 12.h),
|
|
// ---------- 题干(富文本) ----------
|
|
RichTextContent(
|
|
html: question.title,
|
|
textStyle: TextStyle(
|
|
fontSize: 15.sp,
|
|
height: 1.6,
|
|
color: const Color(0xFF333333),
|
|
),
|
|
),
|
|
SizedBox(height: 16.h),
|
|
// ---------- 按题型分发 ----------
|
|
switch (type) {
|
|
QuestionType.single => ChoiceQuestionView(
|
|
options: question.options,
|
|
multiSelect: false,
|
|
answer: answer,
|
|
onAnswer: onAnswer,
|
|
),
|
|
QuestionType.multi => ChoiceQuestionView(
|
|
options: question.options,
|
|
multiSelect: true,
|
|
answer: answer,
|
|
onAnswer: onAnswer,
|
|
),
|
|
QuestionType.judge => JudgeQuestionView(
|
|
answer: answer,
|
|
onAnswer: onAnswer,
|
|
),
|
|
QuestionType.essay => EssayQuestionView(
|
|
answer: answer,
|
|
onAnswer: onAnswer,
|
|
),
|
|
},
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
static Color _typeColor(QuestionType type) {
|
|
return switch (type) {
|
|
QuestionType.single => const Color(0xFF3B82F6),
|
|
QuestionType.multi => const Color(0xFF8B5CF6),
|
|
QuestionType.judge => const Color(0xFFF59E0B),
|
|
QuestionType.essay => const Color(0xFF10B981),
|
|
};
|
|
}
|
|
}
|
|
|
|
/// ============================================================
|
|
/// 【ChoiceQuestionView】选择题(单选/多选共用)
|
|
///
|
|
/// 受控组件:选中状态完全来自外部 answer,自己不留状态。
|
|
/// 答案格式:"A"(单选)/ "A,C"(多选,升序逗号拼接)。
|
|
/// ============================================================
|
|
class ChoiceQuestionView extends StatelessWidget {
|
|
const ChoiceQuestionView({
|
|
super.key,
|
|
required this.options,
|
|
required this.multiSelect,
|
|
required this.answer,
|
|
required this.onAnswer,
|
|
});
|
|
|
|
final List<String> options;
|
|
final bool multiSelect;
|
|
final String? answer;
|
|
final ValueChanged<String> onAnswer;
|
|
|
|
void _onTap(int index) {
|
|
final letter = String.fromCharCode(65 + index); // 0→A, 1→B...
|
|
if (!multiSelect) {
|
|
onAnswer(letter);
|
|
return;
|
|
}
|
|
// 多选:在选中集合中 toggle,再升序拼接
|
|
final selected = answer == null
|
|
? <String>{}
|
|
: answer!.split(',').where((s) => s.isNotEmpty).toSet();
|
|
if (!selected.add(letter)) selected.remove(letter);
|
|
final sorted = selected.toList()..sort();
|
|
onAnswer(sorted.join(','));
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final selectedSet = answer == null
|
|
? <String>{}
|
|
: answer!.split(',').where((s) => s.isNotEmpty).toSet();
|
|
return Column(
|
|
children: [
|
|
for (int i = 0; i < options.length; i++) ...[
|
|
_buildOption(i, selectedSet),
|
|
if (i < options.length - 1) SizedBox(height: 10.h),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildOption(int index, Set<String> selectedSet) {
|
|
final letter = String.fromCharCode(65 + index);
|
|
final isSelected = selectedSet.contains(letter);
|
|
return InkWell(
|
|
onTap: () => _onTap(index),
|
|
borderRadius: BorderRadius.circular(8.r),
|
|
child: Container(
|
|
width: double.infinity,
|
|
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 12.h),
|
|
decoration: BoxDecoration(
|
|
color: isSelected ? const Color(0xFFEFF6FF) : Colors.white,
|
|
borderRadius: BorderRadius.circular(8.r),
|
|
border: Border.all(
|
|
color: isSelected
|
|
? const Color(0xFF3B82F6)
|
|
: const Color(0xFFE5E7EB),
|
|
width: isSelected ? 1.5 : 1,
|
|
),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 26.w,
|
|
height: 26.w,
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: isSelected ? const Color(0xFF3B82F6) : Colors.white,
|
|
border: Border.all(
|
|
color: isSelected
|
|
? const Color(0xFF3B82F6)
|
|
: const Color(0xFF9CA3AF),
|
|
),
|
|
),
|
|
child: Text(
|
|
letter,
|
|
style: TextStyle(
|
|
fontSize: 13.sp,
|
|
fontWeight: FontWeight.w600,
|
|
color: isSelected ? Colors.white : const Color(0xFF6B7280),
|
|
),
|
|
),
|
|
),
|
|
SizedBox(width: 12.w),
|
|
Expanded(
|
|
child: Text(
|
|
options[index],
|
|
style: TextStyle(
|
|
fontSize: 14.sp,
|
|
color: const Color(0xFF333333),
|
|
),
|
|
),
|
|
),
|
|
if (multiSelect)
|
|
Icon(
|
|
isSelected ? Icons.check_circle : Icons.circle_outlined,
|
|
size: 18,
|
|
color: isSelected
|
|
? const Color(0xFF3B82F6)
|
|
: const Color(0xFFD1D5DB),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// ============================================================
|
|
/// 【JudgeQuestionView】判断题(对/错两按钮)
|
|
/// 答案格式:"对" / "错"(与 Question.answer 存储约定一致)。
|
|
/// ============================================================
|
|
class JudgeQuestionView extends StatelessWidget {
|
|
const JudgeQuestionView({
|
|
super.key,
|
|
required this.answer,
|
|
required this.onAnswer,
|
|
});
|
|
|
|
final String? answer;
|
|
final ValueChanged<String> onAnswer;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Row(
|
|
children: [
|
|
for (final value in const ['对', '错']) ...[
|
|
Expanded(
|
|
child: _buildJudgeButton(value),
|
|
),
|
|
if (value == '对') SizedBox(width: 12.w),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildJudgeButton(String value) {
|
|
final isSelected = answer == value;
|
|
return InkWell(
|
|
onTap: () => onAnswer(value),
|
|
borderRadius: BorderRadius.circular(8.r),
|
|
child: Container(
|
|
padding: EdgeInsets.symmetric(vertical: 14.h),
|
|
decoration: BoxDecoration(
|
|
color: isSelected
|
|
? (value == '对' ? const Color(0xFFE8F7EE) : const Color(0xFFFDECEA))
|
|
: Colors.white,
|
|
borderRadius: BorderRadius.circular(8.r),
|
|
border: Border.all(
|
|
color: isSelected
|
|
? (value == '对' ? const Color(0xFF22C55E) : const Color(0xFFEF4444))
|
|
: const Color(0xFFE5E7EB),
|
|
width: isSelected ? 1.5 : 1,
|
|
),
|
|
),
|
|
child: Column(
|
|
children: [
|
|
Icon(
|
|
value == '对' ? Icons.check_circle : Icons.cancel,
|
|
size: 22,
|
|
color: isSelected
|
|
? (value == '对' ? const Color(0xFF22C55E) : const Color(0xFFEF4444))
|
|
: const Color(0xFF9CA3AF),
|
|
),
|
|
SizedBox(height: 6.h),
|
|
Text(
|
|
value,
|
|
style: TextStyle(
|
|
fontSize: 15.sp,
|
|
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
|
|
color: const Color(0xFF333333),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// ============================================================
|
|
/// 【EssayQuestionView】解答题(文本输入 + 300ms 防抖提交)
|
|
///
|
|
/// 防抖原因:onChanged 每敲一个字都会触发 provider 更新 + Isar 写库,
|
|
/// 300ms 空闲后合并为一次提交,减少高频写盘。
|
|
/// 注意:组件是受控回填的 —— 外部 answer 变化(续答恢复)时
|
|
/// didUpdateWidget 同步 controller 文本,但不打断用户正在输入。
|
|
/// ============================================================
|
|
class EssayQuestionView extends StatefulWidget {
|
|
const EssayQuestionView({
|
|
super.key,
|
|
required this.answer,
|
|
required this.onAnswer,
|
|
});
|
|
|
|
final String? answer;
|
|
final ValueChanged<String> onAnswer;
|
|
|
|
@override
|
|
State<EssayQuestionView> createState() => _EssayQuestionViewState();
|
|
}
|
|
|
|
class _EssayQuestionViewState extends State<EssayQuestionView> {
|
|
late final TextEditingController _controller;
|
|
Timer? _debounce;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_controller = TextEditingController(text: widget.answer ?? '');
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(covariant EssayQuestionView oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
// 外部答案变化(如续答恢复)且与当前输入不一致时才回填,
|
|
// 避免覆盖用户正在输入的内容
|
|
if (widget.answer != null && widget.answer != _controller.text) {
|
|
_controller.text = widget.answer!;
|
|
}
|
|
}
|
|
|
|
void _onChanged(String text) {
|
|
_debounce?.cancel();
|
|
_debounce = Timer(const Duration(milliseconds: 300), () {
|
|
widget.onAnswer(text.trim());
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_debounce?.cancel();
|
|
_controller.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFFF9FAFB),
|
|
borderRadius: BorderRadius.circular(8.r),
|
|
border: Border.all(color: const Color(0xFFE5E7EB)),
|
|
),
|
|
child: TextField(
|
|
controller: _controller,
|
|
onChanged: _onChanged,
|
|
maxLines: 5,
|
|
minLines: 3,
|
|
decoration: InputDecoration(
|
|
hintText: '请输入你的解答...',
|
|
hintStyle: const TextStyle(color: Color(0xFFB0B7C3)),
|
|
contentPadding: EdgeInsets.all(12.w),
|
|
border: InputBorder.none,
|
|
),
|
|
style: TextStyle(fontSize: 14.sp, height: 1.5),
|
|
),
|
|
);
|
|
}
|
|
}
|