flutter架 构项目基础
This commit is contained in:
@@ -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('下一题'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user