/// ============================================================ /// 【课堂习题 · 顶部计时与暂停联动】 /// /// 1:1 迁移 Vue exerciseTiming.js(createTimingHandlers): /// - 顶部计时(可暂停 / 继续),与倒计时组件联动; /// - 按 recordId 缓存 / 恢复计时进度(实际逻辑按题目 answerTimeSecond /// 汇总,SP 缓存代码在 Vue 端已注释,这里仍保留 persist 写缓存); /// - 暂停时统计剩余未答题数,驱动暂停弹窗。 /// ============================================================ library; import 'package:shared_preferences/shared_preferences.dart'; import 'package:new_learn/features/conquerPackage/classExercises/controllers/exercise_session.dart'; import 'package:new_learn/features/conquerPackage/classExercises/controllers/exercise_submit.dart'; import 'package:new_learn/features/conquerPackage/classExercises/controllers/exercise_timing.dart'; class ExerciseTimingHandlers { ExerciseTimingHandlers({ required this.session, required this.bridge, required this.submit, required this.isPausePopupVisible, this.onUiTick, }) { timing = ExerciseTiming(onTick: (tick) { final total = timingOffsetMs + tick.elapsedMs; timingText = formatElapsed(total); timingRunning = tick.running; // 页面层据此刷新顶部计时胶囊(对标 Vue ref 赋值触发渲染) onUiTick?.call(); }); } final ExerciseSession session; final ExerciseUiBridge bridge; final ExerciseSubmitHandlers submit; /// 每次秒表 tick 后回调页面刷新 UI(计时文案) final void Function()? onUiTick; /// 暂停弹窗当前是否展示(对标 showPausePopup.value 读取) final bool Function() isPausePopupVisible; late final ExerciseTiming timing; /// 顶部计时文案 String timingText = '00:00'; /// 计时是否运行中 bool timingRunning = true; /// 累计偏移(恢复的已学时长) int timingOffsetMs = 0; /// 暂停时剩余未答题数 int remainingCount = 0; /// 恢复计时缓存(Vue 端实际使用题目 answerTimeSecond 汇总) void restoreTimingCache() { try { var totalStudyTime = 0; for (final item in session.questions) { totalStudyTime += item.answerTimeSecond; } if (totalStudyTime != 0) { timingOffsetMs = totalStudyTime * 1000; timingText = formatElapsed(totalStudyTime * 1000); } else { timingOffsetMs = 0; timingText = '00:00'; } } catch (_) { timingOffsetMs = 0; timingText = '00:00'; } } /// 持久化计时进度(view 模式 / 无 recordId 不写) Future persistTimingCache() async { final keyId = session.recordId; if (session.isView || keyId.isEmpty) return; final elapsedMs = timingOffsetMs + timing.getElapsedMs(); try { final prefs = await SharedPreferences.getInstance(); await prefs.setString( 'classExercises_timing_$keyId', // 与 Vue 一致:历史上存的是 JSON 字符串 '{"elapsedMs":$elapsedMs}', ); } catch (_) { // 忽略落盘失败 } } /// 切换计时(对标 toggleTiming) void toggleTiming() { if (isPausePopupVisible()) return; if (timing.isRunning()) { timing.pause(); session.countDownKey?.currentState?.pause(); bridge.setPausePopupVisible(true); final list = submit.batchProcessQuestions(); remainingCount = list.where((res) => res['answered'] != true).length; bridge.notifyListeners(); return; } timing.resume(); session.countDownKey?.currentState?.start(); } /// 关闭暂停弹窗并继续(对标 handlePausePopupClose) void handlePausePopupClose() { bridge.setPausePopupVisible(false); if (!timing.isRunning()) timing.resume(); session.countDownKey?.currentState?.start(); } void dispose() { timing.stop(); } }