339 lines
12 KiB
Dart
339 lines
12 KiB
Dart
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('下一题'),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|