更新
This commit is contained in:
@@ -0,0 +1,418 @@
|
||||
/// ============================================================
|
||||
/// 【课堂习题 · 翻页 / 子题切换 / 答题卡定位】
|
||||
///
|
||||
/// 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user