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

339 lines
12 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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('下一题'),
),
),
],
),
);
}
}