419 lines
14 KiB
Dart
419 lines
14 KiB
Dart
/// ============================================================
|
||
/// 【课堂习题 · 翻页 / 子题切换 / 答题卡定位】
|
||
///
|
||
/// 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<void>.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(<String, dynamic>{}),
|
||
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(<String, dynamic>{}),
|
||
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(<String, dynamic>{}),
|
||
session.currentQuestion?.id ?? '',
|
||
);
|
||
session.activeSubIndexMap[session.currentIndex] = current - 1;
|
||
bridge.notifyListeners();
|
||
return true;
|
||
}
|
||
|
||
/// 答题卡定位(对标 handleAnswerCardSelect)
|
||
Future<void> handleAnswerCardSelect(Map<String, dynamic> 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<void>.delayed(const Duration(milliseconds: 500));
|
||
bridge.jumpToPage(targetQuestionIndex);
|
||
return;
|
||
}
|
||
|
||
if (targetSubIndex >= 0) {
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
session.itemRefs[targetQuestionIndex]
|
||
?.currentState
|
||
?.setActiveSubIndex(targetSubIndex);
|
||
});
|
||
bridge.notifyListeners();
|
||
}
|
||
}
|
||
|
||
/// 触发当前页“上传笔记”
|
||
Future<void>? 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();
|
||
}
|
||
}
|