flutter架 构项目基础

This commit is contained in:
崔宝新
2026-09-04 15:22:20 +08:00
parent 70967229d0
commit 0c5aee59c0
265 changed files with 35484 additions and 0 deletions
+54
View File
@@ -0,0 +1,54 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:new_learn/core/storage/stores/answer_record_store.dart';
/// ============================================================
/// 【answerProvider】答题状态 —— Riverpod 内存主状态
///
/// 一、架构定位(对应方案分析结论):
/// 答案主状态必须住内存(每题作答高频读写、交卷即弃),
/// Isar 只是"备份恢复层":每次作答同步写一条 AnswerRecord,
/// 进程杀掉后下次进卷按 paperId 读回 restore() 续答。
///
/// 二、数据结构:`Map<questionId, String>`
/// key 用服务器题号(Question.questionId),不依赖本地自增 id,
/// 这样续答恢复时即使题目重新拉取也能对上。
///
/// 三、持久化为什么 fire-and-forget:
/// put 走 Isar 毫秒级完成,没必要阻塞 UI 作答回调;
/// web 端 store 是空实现(打印跳过),行为自动降级为纯内存。
/// ============================================================
/// Demo 试卷 id(真实项目由试卷对象传入,如 paper.paperId)
const int demoPaperId = 1001;
final answerProvider =
NotifierProvider<AnswerNotifier, Map<int, String>>(AnswerNotifier.new);
class AnswerNotifier extends Notifier<Map<int, String>> {
@override
Map<int, String> build() => {};
/// 【作答】更新内存 + 同步落盘(续答持久化)
void answer(int questionId, String value) {
state = {...state, questionId: value};
// fire-and-forget:写库毫秒级,不阻塞作答回调
unawaited(
AnswerRecordStore.instance.put(
paperId: demoPaperId,
questionId: questionId,
answer: value,
),
);
}
/// 【续答恢复】进卷时从 Isar 读回,整体注入(只恢复本卷存在的题)
void restore(Map<int, String> saved) {
if (saved.isEmpty) return;
state = {...state, ...saved};
}
/// 【交卷清理】清空内存状态(Isar 记录由页面调 deleteByPaper)
void clear() => state = {};
}
+170
View File
@@ -0,0 +1,170 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
/// ============================================================
/// 【AnswerSheetDialog】答题卡 —— 题号网格 + 跳题
///
/// 一、职责:
/// 1. 展示所有题号(GridView 4 列),颜色区分答题状态:
/// 已答=蓝 / 未答=灰 / 当前题=绿;
/// 2. 点击题号 → onJump(index) 跳转(由 ExamPage 调 jumpToPage)。
///
/// 二、为什么独立成组件:
/// 答题卡是"总览 → 跳转"的通用交互,参数化设计后可复用于
/// 错题重做、试卷回顾等场景;不直接碰 PageController,
/// 跳转动作通过 onJump 回调上抛,保持组件解耦。
///
/// 三、状态判定:answers 是 `Map<questionId, answer>`,
/// 已答 = questionId 存在于 map(解答题空字符串算未答?——
/// 本 Demo 约定:answer 非空字符串才算已答)。
/// ============================================================
class AnswerSheetDialog extends StatelessWidget {
const AnswerSheetDialog({
super.key,
required this.questionIds,
required this.answers,
required this.currentIndex,
required this.onJump,
});
/// 题目列表(顺序即试卷顺序,用于定位题号)
final List<int> questionIds;
/// 已答答案表(questionId → answer)
final Map<int, String> answers;
/// 当前题索引(高亮绿色)
final int currentIndex;
/// 点击题号回调(传入索引,由页面执行跳转)
final ValueChanged<int> onJump;
int get _answeredCount => questionIds
.where((id) => (answers[id] ?? '').isNotEmpty)
.length;
@override
Widget build(BuildContext context) {
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16.r)),
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: 420.h),
child: Padding(
padding: EdgeInsets.all(16.w),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// ---------- 标题 + 已答统计 ----------
Row(
children: [
const Text(
'答题卡',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Color(0xFF333333),
),
),
const Spacer(),
Container(
padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 4.h),
decoration: BoxDecoration(
color: const Color(0xFFEFF6FF),
borderRadius: BorderRadius.circular(20.r),
),
child: Text(
'已答 $_answeredCount / ${questionIds.length}',
style: const TextStyle(
fontSize: 12,
color: Color(0xFF3B82F6),
fontWeight: FontWeight.w600,
),
),
),
],
),
SizedBox(height: 12.h),
// ---------- 图例 ----------
Row(
children: [
_buildLegend(const Color(0xFF3B82F6), '已答'),
SizedBox(width: 16.w),
_buildLegend(const Color(0xFF22C55E), '当前'),
SizedBox(width: 16.w),
_buildLegend(const Color(0xFFE5E7EB), '未答'),
],
),
SizedBox(height: 12.h),
// ---------- 题号网格 ----------
Flexible(
child: GridView.builder(
shrinkWrap: true,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 5,
mainAxisSpacing: 10,
crossAxisSpacing: 10,
childAspectRatio: 1.3,
),
itemCount: questionIds.length,
itemBuilder: (context, index) {
final id = questionIds[index];
final answered = (answers[id] ?? '').isNotEmpty;
final isCurrent = index == currentIndex;
return _buildCell(index, answered, isCurrent);
},
),
),
],
),
),
),
);
}
Widget _buildLegend(Color color, String label) {
return Row(
children: [
Container(
width: 12.w,
height: 12.w,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(3.r),
),
),
SizedBox(width: 4.w),
Text(
label,
style: const TextStyle(fontSize: 11, color: Color(0xFF666666)),
),
],
);
}
Widget _buildCell(int index, bool answered, bool isCurrent) {
final color = isCurrent
? const Color(0xFF22C55E)
: answered
? const Color(0xFF3B82F6)
: const Color(0xFFE5E7EB);
return InkWell(
onTap: () => onJump(index),
borderRadius: BorderRadius.circular(6.r),
child: Container(
alignment: Alignment.center,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(6.r),
),
child: Text(
'${index + 1}',
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w600,
color: isCurrent || answered ? Colors.white : const Color(0xFF666666),
),
),
),
);
}
}
+338
View File
@@ -0,0 +1,338 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:new_learn/core/storage/stores/answer_record_store.dart';
import 'package:new_learn/features/exam/answer_provider.dart';
import 'package:new_learn/features/exam/answer_sheet_dialog.dart';
import 'package:new_learn/features/exam/exam_question.dart';
import 'package:new_learn/features/exam/paper_provider.dart';
import 'package:new_learn/features/exam/question_card.dart';
import 'package:new_learn/features/exam/question_type.dart';
/// ============================================================
/// 【ExamPage】试卷练习页 —— 大数据滑动核心 Demo
///
/// 一、组件协作(本页是唯一桥接 Riverpod 的地方):
/// paperProvider → `AsyncValue<List<Question>>`:loading/error/data 三态
/// answerProvider → `Map<int, String>`:当前全部答案,ref.watch 响应式
/// QuestionCard → 纯展示组件,props 传题 + 答案 + 作答回调
///
/// 二、PageView.builder 大数据性能(对应 Vue3 swiper 方案分析):
/// - builder 惰性构建:只构建视口内 ±1 页(cacheExtent),
/// 100 道题传入也只是个 List,滑出的页面 Element 自动回收;
/// - allowImplicitScrolling: true → 预构建相邻页,滑动不掉帧;
/// - ValueKey(questionId) → 页面复用时按题号隔离,杜绝串题。
///
/// 三、续答链路(进卷 → 作答 → 交卷):
/// 进卷 initState → AnswerRecordStore.findByPaper 读回 →
/// 注入 answerProvider(有记录则 SnackBar 提示已恢复);
/// 作答 QuestionCard 回调 → answer() 更新内存 + 异步写 Isar;
/// 交卷 确认 → 判分统计 → deleteByPaper 清理 → clear 内存。
///
/// 四、坑:
/// 1. restore 是异步的,首次 build 时答案可能还没回来 →
/// 注入后 provider 变化自动重建页面(响应式),无需手动刷新;
/// 2. 交卷接口失败不能删本地记录(防"没交成但答案没了"),
/// 本 Demo 模拟提交成功后统一清理。
/// ============================================================
class ExamPage extends ConsumerStatefulWidget {
const ExamPage({super.key});
@override
ConsumerState<ExamPage> createState() => _ExamPageState();
}
class _ExamPageState extends ConsumerState<ExamPage> {
final PageController _pageController = PageController();
int _currentIndex = 0;
/// 续答恢复后要定位到的题号(null = 不跳转,只定位一次)
int? _jumpToQuestion;
@override
void initState() {
super.initState();
_restoreProgress();
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
/// 【进卷恢复】从 Isar 读回本卷已答记录 → 注入 answerProvider
/// 并定位到最后作答的题(按 updatedAt 最新一条)。
///
/// 为什么还要定位:恢复的只是"答案数据",PageView 初始还在第 1 题,
/// 不定位的话用户每次进卷都要手动滑回上次位置(本 Bug 修复点)。
Future<void> _restoreProgress() async {
final records = await AnswerRecordStore.instance.findByPaper(demoPaperId);
if (!mounted || records.isEmpty) return;
final saved = <int, String>{};
int? lastQuestionId;
DateTime? lastAt;
for (final r in records) {
final qid = r.questionId as int;
saved[qid] = (r.answer as String?) ?? '';
// 记录最后作答的题:updatedAt 最新的一条(重复作答会覆盖更新时间)
final at = r.updatedAt as DateTime?;
if (at != null && (lastAt == null || at.isAfter(lastAt))) {
lastAt = at;
lastQuestionId = qid;
}
}
ref.read(answerProvider.notifier).restore(saved);
_jumpToQuestion = lastQuestionId;
_tryJumpAfterRestore(); // 若题目已加载完成(异步竞争),这里直接跳
if (!mounted) return;
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text('已恢复上次答题进度(${saved.length} 题),定位到第 $lastQuestionId 题'),
duration: const Duration(seconds: 2),
),
);
}
/// 定位到最后作答的题:restore 与题目加载谁先完成都能跳转
void _tryJumpAfterRestore() {
final target = _jumpToQuestion;
if (target == null) return;
final questions = ref.read(paperProvider).value;
if (questions == null) return; // 题目还没加载完,等 build data 分支再试
final index = questions.indexWhere((q) => q.questionId == target);
if (index < 0) return;
_jumpToQuestion = null; // 只定位一次
WidgetsBinding.instance.addPostFrameCallback((_) {
// 首帧后跳转:此时 PageView 已 attach,jumpToPage 才安全
if (!mounted || !_pageController.hasClients) return;
_pageController.jumpToPage(index);
});
}
/// 【交卷】确认 → 清理本地进度 → 判分统计
Future<void> _submit(List<ExamQuestion> questions) async {
final answers = ref.read(answerProvider);
final total = questions.length;
final answered =
questions.where((q) => (answers[q.questionId] ?? '').isNotEmpty).length;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('确认交卷?'),
content: Text('已答 $answered / $total 题,未答 ${total - answered} 题。'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('再检查一下'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('交卷'),
),
],
),
);
if (confirmed != true || !mounted) return;
// 模拟提交接口成功 → 清理本地进度(防止下次进卷恢复旧答案)
await AnswerRecordStore.instance.deleteByPaper(demoPaperId);
ref.read(answerProvider.notifier).clear();
if (!mounted) return;
// 判分:选择/判断自动比对;解答题不算分(需人工批改)
final rightCount = questions.where((q) {
if (q.type == QuestionType.essay) return false; // 解答题跳过
final user = answers[q.questionId];
return user != null && user == q.answer;
}).length;
if (!mounted) return;
await showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('交卷成功'),
content: Text(
'自动判分:答对 $rightCount / ${total - answered} 题(客观题)\n'
'主观题需人工批改,本地进度已清理,下次进卷重新作答。',
),
actions: [
FilledButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('知道了'),
),
],
),
);
}
/// 打开答题卡
void _openAnswerSheet(List<ExamQuestion> questions) {
showDialog<void>(
context: context,
builder: (ctx) => AnswerSheetDialog(
questionIds: [for (final q in questions) q.questionId],
answers: ref.read(answerProvider),
currentIndex: _currentIndex,
onJump: (index) {
Navigator.pop(ctx);
_pageController.jumpToPage(index);
},
),
);
}
@override
Widget build(BuildContext context) {
final paperAsync = ref.watch(paperProvider);
final answers = ref.watch(answerProvider);
return Scaffold(
appBar: AppBar(
title: const Text('试卷练习'),
actions: [
TextButton(
onPressed: paperAsync.hasValue
? () => _submit(paperAsync.value!)
: null,
child: const Text(
'交卷',
style: TextStyle(fontWeight: FontWeight.w600),
),
),
],
),
body: paperAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(
child: Text('试卷加载失败:$e', style: const TextStyle(fontSize: 14)),
),
data: (questions) {
// 续答恢复可能晚于题目加载完成:data 分支兜底再触发一次定位
_tryJumpAfterRestore();
return Column(
children: [
_buildProgressBar(questions, answers),
Expanded(
child: PageView.builder(
controller: _pageController,
itemCount: questions.length,
// 预构建相邻页:滑动时目标页已就绪,不掉帧
allowImplicitScrolling: true,
onPageChanged: (i) => setState(() => _currentIndex = i),
itemBuilder: (context, i) {
final q = questions[i];
return QuestionCard(
// 按题号隔离:PageView 页面复用时杜绝串题
key: ValueKey(q.questionId),
question: q,
answer: answers[q.questionId],
onAnswer: (v) =>
ref.read(answerProvider.notifier).answer(q.questionId, v),
);
},
),
),
_buildBottomBar(questions),
],
);
},
),
);
}
/// 顶部进度条:已答 X / N + 当前题号
Widget _buildProgressBar(List<ExamQuestion> questions, Map<int, String> answers) {
final total = questions.length;
final answered =
questions.where((q) => (answers[q.questionId] ?? '').isNotEmpty).length;
return Container(
padding: EdgeInsets.fromLTRB(16.w, 12.h, 16.w, 10.h),
color: Colors.white,
child: Column(
children: [
Row(
children: [
Text(
'第 ${_currentIndex + 1} / $total 题',
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w600,
color: const Color(0xFF333333),
),
),
const Spacer(),
Text(
'已答 $answered 题',
style: TextStyle(fontSize: 12.sp, color: const Color(0xFF999999)),
),
],
),
SizedBox(height: 8.h),
ClipRRect(
borderRadius: BorderRadius.circular(4.r),
child: LinearProgressIndicator(
value: total == 0 ? 0 : answered / total,
minHeight: 6,
backgroundColor: const Color(0xFFF0F2F5),
valueColor: const AlwaysStoppedAnimation(Color(0xFF3B82F6)),
),
),
],
),
);
}
/// 底部导航:上一题 / 答题卡 / 下一题
Widget _buildBottomBar(List<ExamQuestion> questions) {
final isFirst = _currentIndex == 0;
final isLast = _currentIndex == questions.length - 1;
return Container(
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 10.h),
decoration: const BoxDecoration(
color: Colors.white,
border: Border(top: BorderSide(color: Color(0xFFF0F2F5))),
),
child: Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: isFirst
? null
: () => _pageController.previousPage(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOut,
),
icon: const Icon(Icons.chevron_left, size: 18),
label: const Text('上一题'),
),
),
SizedBox(width: 12.w),
Expanded(
child: FilledButton.tonalIcon(
onPressed: () => _openAnswerSheet(questions),
icon: const Icon(Icons.grid_view, size: 18),
label: const Text('答题卡'),
),
),
SizedBox(width: 12.w),
Expanded(
child: FilledButton.icon(
onPressed: isLast
? null
: () => _pageController.nextPage(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOut,
),
icon: const Icon(Icons.chevron_right, size: 18),
label: const Text('下一题'),
),
),
],
),
);
}
}
+57
View File
@@ -0,0 +1,57 @@
import 'package:new_learn/features/exam/question_type.dart';
/// ============================================================
/// 【ExamQuestion】试卷题目接口模型(纯 Dart,无 Isar 依赖)
///
/// 一、为什么不能直接用 Isar 的 Question 模型(web 编译约束):
/// Isar 模型生成的 *.g.dart 含 64 位 schema 哈希常量,
/// dart2js 无法精确表示 → web 编译直接失败(本项目已踩坑)。
/// 业务代码 import Isar 模型 = 把它拉进 web 编译图。
/// 所以试卷功能用本纯 Dart 模型(接口 JSON 解析),
/// WrongQuestionStore 注释的约定:"页面永远不要 import 模型"。
///
/// 二、与 Isar Question 字段对应:
/// questionId/title/answer/analysis 一一对应;
/// options 直接用 `List<String>`(接口 JSON 是数组),
/// Isar 侧存 JSON 字符串只是本地存储的简化写法,语义相同。
/// ============================================================
class ExamQuestion {
const ExamQuestion({
required this.questionId,
required this.title,
required this.options,
required this.answer,
required this.type,
required this.analysis,
});
/// 服务器题号(答案 Map 的 key,续答恢复的关联字段)
final int questionId;
/// 题干(HTML 富文本,RichTextContent 渲染)
final String title;
/// 选项(选择/判断题有值,解答题空列表)
final List<String> options;
/// 正确答案(多选 "A,C" 逗号拼接,判断 "对"/"错",解答为参考答案)
final String answer;
/// 题型(渲染分发的依据)
final QuestionType type;
/// 解析(交卷后查看答案用)
final String analysis;
/// 接口 JSON → 模型(字段缺失兜底空值,防御脏数据)
factory ExamQuestion.fromJson(Map<String, dynamic> json) {
return ExamQuestion(
questionId: json['questionId'] as int,
title: json['title'] as String? ?? '',
options: (json['options'] as List?)?.cast<String>() ?? const [],
answer: json['answer'] as String? ?? '',
type: QuestionType.fromCode(json['type'] as int?),
analysis: json['analysis'] as String? ?? '',
);
}
}
+124
View File
@@ -0,0 +1,124 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:new_learn/features/exam/exam_question.dart';
import 'package:new_learn/features/exam/question_type.dart';
/// ============================================================
/// 【paperProvider】试卷题目数据源 —— Riverpod 内存主状态
///
/// 一、架构定位(对应方案分析结论):
/// 题目是"会话数据"不是"资产":进卷拉一次、退出即弃,
/// 不落 Isar(Isar 只负责跨会话的答案进度,见 answer_provider)。
/// 100 题内存占用 ≈ 1MB,PageView.builder 懒加载按需构建,
/// 大数组传入无性能压力。
///
/// 二、mock 模式(对标 Vue3 内联 mock 体验):
/// const bool mock = true 时直接本地生成题目(Future.delayed
/// 模拟接口延迟),改 false 后接真实接口(DioClient),
/// 一行开关切换,热重启即生效。
///
/// 三、paperSizeProvider:demo 专用,控制生成题数(50/100/200),
/// paperProvider watch 它 → 改变自动重新加载(Riverpod 响应式)。
/// ============================================================
/// Demo 专用:试卷题目数量(50/100/200,入口页选择)
class PaperSizeNotifier extends Notifier<int> {
@override
int build() => 100;
void setSize(int size) => state = size;
}
final paperSizeProvider =
NotifierProvider<PaperSizeNotifier, int>(PaperSizeNotifier.new);
/// 试卷题目加载器
final paperProvider = AsyncNotifierProvider<PaperNotifier, List<ExamQuestion>>(
PaperNotifier.new,
);
class PaperNotifier extends AsyncNotifier<List<ExamQuestion>> {
/// 接口 mock 开关:true = 本地生成;false = 接真实接口(DioClient)
static const bool mock = true;
@override
Future<List<ExamQuestion>> build() async {
final size = ref.watch(paperSizeProvider);
if (mock) {
// 模拟接口延迟:首屏展示 loading,验证加载态
await Future.delayed(const Duration(milliseconds: 600));
return MockExamData.generate(size);
}
// 真实接口:GET /api/v1/Paper/{paperId}/Questions
// final resp = await DioClient.instance.get('/api/v1/Paper/1/Questions');
// return (resp.data['list'] as List).map(Question.fromJson).toList();
return const [];
}
}
/// ============================================================
/// 【MockExamData】大数据模拟生成器(100 题 × 4 题型)
///
/// 模拟真实试卷的题目构成:单选 / 多选 / 判断 / 解答 循环分布,
/// 题干带少量 HTML 标签(RichTextContent 富文本渲染路径),
/// 用于验证 PageView.builder 大数据滑动性能与题型分发。
/// ============================================================
class MockExamData {
/// 学科题材池(按题号轮换,避免题目千篇一律)
static const List<(String, String)> _stems = [
('数学', '计算 12 × 8 = ?'),
('语文', '下列词语书写<u>完全正确</u>的一项是?'),
('英语', '— What does he do?<br>— He is a(n) <b>____</b>.'),
('科学', '植物进行光合作用的主要器官是?'),
('常识', '地球是太阳系中最大的行星。'),
('语文', '“落霞与孤鹜齐飞”出自哪篇名作?'),
('数学', '一个三角形最多有几个直角?'),
('英语', '下列单词中发音与其他三项不同的一项是?'),
];
static List<ExamQuestion> generate(int count) {
return List.generate(count, (i) {
final (subject, stem) = _stems[i % _stems.length];
return switch (i % 4) {
// 0 → 单选(4 个选项)
0 => ExamQuestion(
questionId: i + 1,
type: QuestionType.single,
title: '<p><b>【$subject】</b> 第 ${i + 1} 题</p><p>$stem</p>',
options: const ['选项 A 的内容', '选项 B 的内容', '选项 C 的内容', '选项 D 的内容'],
answer: 'A',
analysis: '本题考察基础知识点,正确答案为 A。',
),
// 1 → 多选(答案两个字母)
1 => ExamQuestion(
questionId: i + 1,
type: QuestionType.multi,
title:
'<p><b>【$subject】</b> 第 ${i + 1} 题(多选)</p><p>$stem</p>',
options: const ['选项 A 的内容', '选项 B 的内容', '选项 C 的内容', '选项 D 的内容'],
answer: 'A,C',
analysis: '本题有两个正确答案:A 与 C。',
),
// 2 → 判断(对 / 错)
2 => ExamQuestion(
questionId: i + 1,
type: QuestionType.judge,
title:
'<p><b>【$subject】</b> 第 ${i + 1} 题(判断)</p><p>$stem</p>',
options: const ['对', '错'],
answer: i % 6 == 0 ? '错' : '对',
analysis: '依据教材定义判断,答案为 ${i % 6 == 0 ? "错" : "对"}。',
),
// 3 → 解答题(文本输入)
_ => ExamQuestion(
questionId: i + 1,
type: QuestionType.essay,
title:
'<p><b>【$subject】</b> 第 ${i + 1} 题(解答)</p><p>$stem</p>',
options: const [],
answer: '水有三种状态:液态、固态、气态。',
analysis: '解答题需人工批改,参考答案:水有三种状态。',
),
};
});
}
}
+398
View File
@@ -0,0 +1,398 @@
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),
),
);
}
}
+41
View File
@@ -0,0 +1,41 @@
/// ============================================================
/// 【QuestionType】题型枚举 —— 试卷渲染分发的唯一依据
///
/// 与 Question 模型的 type 字段(int)一一对应:
/// 1 单选 / 2 多选 / 3 判断 / 4 解答
/// QuestionCard 拿到 question 后 switch type 分发到对应视图组件。
///
/// 为什么用枚举而不是裸 int:
/// 枚举自带 label(UI 标签)、答案格式约定(选择 "A,B" / 判断 "对" /
/// 解答文本),页面不用到处散落魔法数字,加新题型只改这一个文件。
/// ============================================================
enum QuestionType {
single(1, '单选题'),
multi(2, '多选题'),
judge(3, '判断题'),
essay(4, '解答题');
const QuestionType(this.code, this.label);
/// Isar 存储值(Question.type)
final int code;
final String label;
/// 从存储 int 转枚举(非法值兜底为单选,防御脏数据)
static QuestionType fromCode(int? code) {
return QuestionType.values.firstWhere(
(t) => t.code == code,
orElse: () => QuestionType.single,
);
}
}
/// ============================================================
/// 【答案格式约定】统一 String 存储
///
/// 单选 → "A"(一个字母)
/// 多选 → "A,B"(字母按升序逗号拼接,如 "A,C")
/// 判断 → "对" / "错"
/// 解答 → 用户输入的纯文本
/// QuestionCard 各题型视图按约定编码/解码,交卷时按约定序列化上报。
/// ============================================================