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>`:loading/error/data 三态 /// answerProvider → `Map`:当前全部答案,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 createState() => _ExamPageState(); } class _ExamPageState extends ConsumerState { 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 _restoreProgress() async { final records = await AnswerRecordStore.instance.findByPaper(demoPaperId); if (!mounted || records.isEmpty) return; final saved = {}; 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 _submit(List 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( 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( 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 questions) { showDialog( 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 questions, Map 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 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('下一题'), ), ), ], ), ); } }