/// ============================================================ /// 【课堂习题 · 翻页 / 子题切换 / 答题卡定位】 /// /// 1:1 迁移 Vue exerciseNavigation.js(createNavigationHandlers)。 /// /// Flutter 端 PageView 使用 NeverScrollableScrollPhysics,所有翻页都由 /// 本控制器经 [ExerciseUiBridge.jumpToPage] 主动触发(对标程序 slideTo), /// PageView.onPageChanged 只接 [handlePageChange] 做状态同步,不再重复提交。 /// ============================================================ library; import 'dart:async'; import 'package:flutter/widgets.dart'; import 'package:new_learn/features/conquerPackage/classExercises/controllers/exercise_session.dart'; import 'package:new_learn/features/conquerPackage/classExercises/controllers/exercise_sort.dart' as sort; import 'package:new_learn/features/conquerPackage/classExercises/controllers/exercise_submit.dart'; import 'package:new_learn/features/conquerPackage/classExercises/models/question_model.dart'; class ExerciseNavigationHandlers { ExerciseNavigationHandlers({ required this.session, required this.bridge, required this.submit, required this.openTimePopup, required this.openUploadNotesPopup, required this.submitExamPaper, }); final ExerciseSession session; final ExerciseUiBridge bridge; /// 单题提交控制器(calculateRemainTime / QuestionRenewTimeFn) final ExerciseSubmitHandlers submit; /// 页面级“最低学习时长”拦截(含 isWrong 判断) final bool Function() openTimePopup; /// 页面级“上传笔记”拦截(含 isWrong 判断) final bool Function() openUploadNotesPopup; /// 交卷入口 final void Function(String type) submitExamPaper; bool _isProgrammaticJump = false; Timer? _programmaticJumpTimer; /// 标记程序跳转(400ms 内忽略手势重复触发) void markProgrammaticJump({Duration duration = const Duration(milliseconds: 400)}) { _isProgrammaticJump = true; _programmaticJumpTimer?.cancel(); _programmaticJumpTimer = Timer(duration, () { _isProgrammaticJump = false; _programmaticJumpTimer = null; }); } bool get isProgrammaticJump => _isProgrammaticJump; // ---------------- 题号计算 ---------------- sort.SortRange getDisplayQuestionSortRange(int targetIndex) => sort.getDisplayQuestionSortRange(session.questions, targetIndex); sort.ResolvedSortTarget resolveTargetByDisplaySort(int targetSort) => sort.resolveTargetByDisplaySort(session.questions, targetSort); ExerciseSubmitHandlers get _submit => submit; int get totalSortCount { if (session.questions.isEmpty) return 0; return getDisplayQuestionSortRange(session.questions.length - 1).end; } int get totalWrongSortCount { var maxSort = 0; for (final item in session.questions) { final end = sort.getWrongQuestionSortRange(item).end; if (end > maxSort) maxSort = end; } return maxSort; } int _safeActiveSubIndex(QuestionModel item) { final subs = item.subQuestions; if (subs.isEmpty) return 0; final raw = session.activeSubIndexMap[session.currentIndex] ?? 0; return raw < 0 ? 0 : (raw > subs.length - 1 ? subs.length - 1 : raw); } /// 当前展示题号(currentSortLabel) int get currentSortLabel { final item = session.currentQuestion; if (item == null) return 0; final start = getDisplayQuestionSortRange(session.currentIndex).start; if (sort.shouldCountSubQuestions(item)) { return start + _safeActiveSubIndex(item); } return start; } /// 错题模式当前题号(currentWrongSortLabel) int get currentWrongSortLabel { final item = session.currentQuestion; if (item == null) return 0; final start = sort.getWrongQuestionSortRange(item).start; if (item.hasChild && item.subQuestions.isNotEmpty) { final sub = item.subQuestions[_safeActiveSubIndex(item)]; return sub.wrongSort ?? start; } return start; } /// 进度百分比(percentage,ceil) int get percentage { if (session.isWrong) { final total = totalWrongSortCount; if (total == 0) return 0; final item = session.currentQuestion; if (item == null) return 0; final range = sort.getWrongQuestionSortRange(item); var current = range.start; if (sort.shouldCountSubQuestions(item)) { final sub = item.subQuestions[_safeActiveSubIndex(item)]; current = sub.wrongSort ?? range.start; } return (current / total * 100).ceil(); } final total = totalSortCount; if (total == 0) return 0; final item = session.currentQuestion; if (item == null) return 0; final start = getDisplayQuestionSortRange(session.currentIndex).start; var current = start; if (sort.shouldCountSubQuestions(item)) { current = start + _safeActiveSubIndex(item); } return (current / total * 100).ceil(); } bool get isPrevDisabled => session.currentIndex == 0 ? !canGoPrevSubQuestion() : false; // ---------------- 切页状态同步 ---------------- /// 大题切页后的状态同步(对标 handlePageChange,不含提交) void handlePageChange(int index) { final prevIndex = session.currentIndex; if (prevIndex != index) { // 注册表暂停,不遍历 element 树,动画收尾帧调用也安全; // 旧页 isActive 翻转后 didUpdateWidget 也会暂停一次(双保险) session.itemRefs[prevIndex]?.currentState?.pauseMedia(); } session.currentIndex = index; final q = session.currentQuestion; if (q != null) { q.startTime = getCurrentISO(); } final pendingSub = session.pendingSubIndexMap[index]; if (pendingSub != null) { session.activeSubIndexMap[index] = pendingSub; final pageKey = session.itemRefs[index]; WidgetsBinding.instance.addPostFrameCallback((_) { Future.delayed(const Duration(milliseconds: 500), () { pageKey?.currentState?.setActiveSubIndex(pendingSub); session.pendingSubIndexMap.remove(index); }); }); } session.countDownKey?.currentState?.reset(); bridge.notifyListeners(); } bool _hasSubQuestions(QuestionModel? item) => sort.shouldCountSubQuestions(item); int _currentActiveSubIndex(QuestionModel item) { if (!_hasSubQuestions(item)) return -1; final total = item.subQuestions.length; final current = session.activeSubIndexMap[session.currentIndex] ?? 0; return current < 0 ? 0 : (current > total - 1 ? total - 1 : current); } /// 跨大题切页前提交当前页(对标 submitCurrentQuestionBeforePageSwitch) void submitCurrentQuestionBeforePageSwitch() { _submit.calculateRemainTime(); final item = session.currentQuestion; if (item == null) return; // 填空题单个空:直接提交大题 if (item.childs.length == 1 && item.questionTypes == 'tiankongti') { _submit.questionRenewTimeFn(item, null); return; } // hasOptionChildrenQuestion=false 且图片作答:整题算 1 题 if (item.isFillblankItemUpload == 1 && !item.hasOptionChildrenQuestion) { _submit.questionRenewTimeFn(item, null); return; } // 有小题:提交当前激活子题 if (_hasSubQuestions(item) || item.childs.isNotEmpty) { final data = session.panelKey?.currentState?.getChildData(); _submit.questionRenewTimeFn( data?.data ?? QuestionModel({}), item.id, ); return; } _submit.questionRenewTimeFn(item, null); } /// 复合题面板子题切换(对标 handleSubQuestionChange) void handleSubQuestionChange(int questionIndex, int subIndex) { final item = questionIndex >= 0 && questionIndex < session.questions.length ? session.questions[questionIndex] : null; final subs = item == null ? const [] : (item.subQuestions.isNotEmpty ? item.mapList('subQuestions') : item.mapList('bkTagMetaList')); final safe = subs.isEmpty ? 0 : (subIndex < 0 ? 0 : (subIndex > subs.length - 1 ? subs.length - 1 : subIndex)); session.activeSubIndexMap[questionIndex] = safe; } bool canGoNextSubQuestion() { final item = session.currentQuestion; if (!_hasSubQuestions(item)) return false; final current = _currentActiveSubIndex(item!); return current >= 0 && current < item.subQuestions.length - 1; } bool canGoPrevSubQuestion() { final item = session.currentQuestion; if (!_hasSubQuestions(item)) return false; return _currentActiveSubIndex(item!) > 0; } bool goNextSubQuestion() { final item = session.currentQuestion; if (!_hasSubQuestions(item)) return false; final current = _currentActiveSubIndex(item!); if (current < 0 || current >= item.subQuestions.length - 1) return false; _submit.calculateRemainTime(); final data = session.panelKey?.currentState?.getChildData(); _submit.questionRenewTimeFn( data?.data ?? QuestionModel({}), session.currentQuestion?.id ?? '', ); session.activeSubIndexMap[session.currentIndex] = current + 1; bridge.notifyListeners(); return true; } bool goPrevSubQuestion() { final item = session.currentQuestion; if (!_hasSubQuestions(item)) return false; final current = _currentActiveSubIndex(item!); if (current <= 0) return false; _submit.calculateRemainTime(); final data = session.panelKey?.currentState?.getChildData(); _submit.questionRenewTimeFn( data?.data ?? QuestionModel({}), session.currentQuestion?.id ?? '', ); session.activeSubIndexMap[session.currentIndex] = current - 1; bridge.notifyListeners(); return true; } /// 答题卡定位(对标 handleAnswerCardSelect) Future handleAnswerCardSelect(Map question) async { final currentQuestionIndex = session.currentIndex; final currentInfo = session.currentQuestion; final currentActiveSubIndex = currentInfo == null ? -1 : _currentActiveSubIndex(currentInfo); var targetQuestionIndex = -1; var targetSubIndex = -1; final parentIndex = question['parentIndex']; if (parentIndex is int && parentIndex >= 0) { targetQuestionIndex = parentIndex; targetSubIndex = int.tryParse('${question['subIndex'] ?? -1}') ?? -1; } else { final displaySort = int.tryParse('${question['sort'] ?? 0}') ?? 0; if (displaySort == 0) return; final resolved = resolveTargetByDisplaySort(displaySort); targetQuestionIndex = resolved.questionIndex; targetSubIndex = resolved.subIndex; } if (targetQuestionIndex < 0) return; final shouldSubmitBeforeJump = targetQuestionIndex != currentQuestionIndex || (targetQuestionIndex == currentQuestionIndex && targetSubIndex >= 0 && targetSubIndex != currentActiveSubIndex); if (!session.isView && shouldSubmitBeforeJump) { // 只触发提交,不 await,避免跳题卡顿 submitCurrentQuestionBeforePageSwitch(); } if (targetSubIndex >= 0) { final targetItem = targetQuestionIndex >= 0 && targetQuestionIndex < session.questions.length ? session.questions[targetQuestionIndex] : null; final subTotal = targetItem?.subQuestions.length ?? 0; if (subTotal > 0) { targetSubIndex = targetSubIndex > subTotal - 1 ? subTotal - 1 : targetSubIndex; if (targetSubIndex < 0) targetSubIndex = 0; session.activeSubIndexMap[targetQuestionIndex] = targetSubIndex; session.pendingSubIndexMap[targetQuestionIndex] = targetSubIndex; } else { targetSubIndex = -1; } } if (targetQuestionIndex != currentQuestionIndex) { markProgrammaticJump(); _submit.calculateRemainTime(); await Future.delayed(const Duration(milliseconds: 500)); bridge.jumpToPage(targetQuestionIndex); return; } if (targetSubIndex >= 0) { WidgetsBinding.instance.addPostFrameCallback((_) { session.itemRefs[targetQuestionIndex] ?.currentState ?.setActiveSubIndex(targetSubIndex); }); bridge.notifyListeners(); } } /// 触发当前页“上传笔记” Future? uploadNotes() { return session.itemRefs[session.currentIndex] ?.currentState ?.uploadLectureNotes(); } /// 下一题(含复合题内部下一小题) void nextStep() { if (canGoNextSubQuestion()) { goNextSubQuestion(); return; } submitCurrentQuestionBeforePageSwitch(); if (session.currentIndex == session.questions.length - 1) return; markProgrammaticJump(); bridge.jumpToPage(session.currentIndex + 1); } /// 上一题(含复合题内部上一小题) void previousStep() { if (canGoPrevSubQuestion()) { goPrevSubQuestion(); return; } if (session.currentIndex == 0) return; submitCurrentQuestionBeforePageSwitch(); markProgrammaticJump(); bridge.jumpToPage(session.currentIndex - 1); } /// 顶部返回:通知页面打开退出确认弹窗 void clickBackNavBar() { bridge.openExitPopup(); } /// 底部上一题 / 下一题统一入口(对标 switchStep) void switchStep(String type, [String? timePopupIfNeeded]) { if (session.currentIndex == 0 && type == 'PreviousStep' && !canGoPrevSubQuestion()) { return; } if (type == 'NextStep') { // 最低学习时长只拦“跨大题”,不拦大题内子题切换 if (timePopupIfNeeded != 'minimumLearningDuration') { if (!canGoNextSubQuestion() && openTimePopup()) return; } if (openUploadNotesPopup()) return; if (currentSortLabel == totalSortCount) { submitExamPaper('submit'); return; } nextStep(); } else if (type == 'PreviousStep') { previousStep(); } } /// view 最后一题“完成”:回攻克列表 void completeEnd() { bridge.relaunchCaptureList(session.courseId); } void dispose() { _programmaticJumpTimer?.cancel(); } }