Files
2026-09-04 15:22:20 +08:00

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),
),
);
}
}