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 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 options; final bool multiSelect; final String? answer; final ValueChanged 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 ? {} : 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 ? {} : 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 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 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 onAnswer; @override State createState() => _EssayQuestionViewState(); } class _EssayQuestionViewState extends State { 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), ), ); } }