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
+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('下一题'),
),
),
],
),
);
}
}