Files
2026-09-28 20:12:34 +08:00

419 lines
14 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// ============================================================
/// 【课堂习题 · 翻页 / 子题切换 / 答题卡定位】
///
/// 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();
}
}