/// ============================================================ /// 【ClassExercisesPage】课后练习装配页 /// (1:1 迁移 Vue pages/conquerPackage/classExercises/classExercises.vue) /// /// 结构(与 Vue 模板一致): /// HeaderBac 背景图 /// ├ NavBar(左对齐标题 / 返回) /// ├ head(edit 计时胶囊 / view 解析标题 + 进度条题号答题卡入口) /// ├ PageView(左右滑动切题;左滑受倒计时/必传笔记限制) /// ├ CompoundQuestionPanel 浮层(当前题为复合题时) /// └ footer(上传笔记 / 收藏 / 错题 / 反馈 / 上一题·下一题 / 倒计时气泡) /// 弹窗:ConquerPopup / AnswerCardPopup / PausePopup / /// ExerciseConfirmBox / FeedbackPopup /// 页面级加载遮罩 isPageLoading /// /// 状态全部收敛在普通 Dart 控制器(无 Riverpod): /// ExerciseSession + submit / navigation / timing / collect, /// 本 State implements ExerciseUiBridge 承接所有副作用。 /// ============================================================ library; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart' show ScrollDirection; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:go_router/go_router.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:new_learn/core/components/center_toast.dart'; import 'package:new_learn/core/utils/img_url.dart'; import 'package:new_learn/features/conquerPackage/classExercises/controllers/exercise_collect.dart'; import 'package:new_learn/features/conquerPackage/classExercises/controllers/exercise_load.dart'; import 'package:new_learn/features/conquerPackage/classExercises/controllers/exercise_navigation.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/controllers/exercise_timing.dart'; import 'package:new_learn/features/conquerPackage/classExercises/controllers/exercise_timing_handlers.dart'; import 'package:new_learn/features/conquerPackage/classExercises/exercise_question_view.dart'; import 'package:new_learn/features/conquerPackage/classExercises/models/question_data_adapter.dart'; import 'package:new_learn/features/conquerPackage/classExercises/models/question_model.dart'; import 'package:new_learn/features/conquerPackage/classExercises/widgets/answer_card_popup.dart'; import 'package:new_learn/features/conquerPackage/classExercises/widgets/compound_question_panel.dart'; import 'package:new_learn/features/conquerPackage/classExercises/widgets/conquer_popup.dart'; import 'package:new_learn/features/conquerPackage/classExercises/widgets/exercise_confirm_box.dart'; import 'package:new_learn/features/conquerPackage/classExercises/widgets/exercise_styles.dart'; import 'package:new_learn/features/conquerPackage/classExercises/widgets/feedback_popup.dart'; import 'package:new_learn/features/conquerPackage/classExercises/widgets/pause_popup.dart'; import 'package:new_learn/features/conquerPackage/classExercises/widgets/question_plan_countdown.dart'; class ClassExercisesPage extends StatefulWidget { const ClassExercisesPage({super.key, this.extra}); /// 路由入参(对标 onLoad options): /// courseId / packId / totalPackId / recordId / /// nowStudyObjectId / isWrong / targetSort / type(edit|view) final Map? extra; @override State createState() => _ClassExercisesPageState(); } class _ClassExercisesPageState extends State implements ExerciseUiBridge { // ===================== 会话与控制器 ===================== final ExerciseSession _session = ExerciseSession(); late final ExerciseSubmitHandlers _submit; late final ExerciseNavigationHandlers _navigation; late final ExerciseTimingHandlers _timing; late final ExerciseCollectHandlers _collect; final PageController _pageController = PageController(); final GlobalKey _feedbackKey = GlobalKey(); // ===================== 页面级 UI 状态 ===================== bool _isPageLoading = true; bool _showAnswerSheet = false; bool _showPausePopup = false; ConquerPopupConfig? _popup; bool _exitConfirmShow = false; /// 面板高度(拖拽时高频更新,用 ValueNotifier 避免整页 setState) final ValueNotifier _panelHeight = ValueNotifier(50); // 报告页题号定位(pendingTargetSort) int _pendingTargetSort = 0; bool _isJumpDataReady = false; bool _hasAutoJumped = false; String _nowStudyObjectId = ''; // 节流(对标 createThrottle / onBackPress 节流) DateTime? _lastStepAt; DateTime? _lastBackAt; // ===================== 全局 loading(Overlay,对标 uni.showLoading) ===================== final ValueNotifier _loadingText = ValueNotifier('加载中...'); OverlayEntry? _loadingEntry; bool get _isEdit => _session.mode == 'edit'; bool get _isInteractionLocked => _popup != null; // ===================== 生命周期 ===================== @override void initState() { super.initState(); _parseExtra(); _submit = ExerciseSubmitHandlers(session: _session, bridge: this); _navigation = ExerciseNavigationHandlers( session: _session, bridge: this, submit: _submit, // 错题模式不拦截(与 Vue 页面包装一致) openTimePopup: _openTimePopupWrapped, openUploadNotesPopup: _openNotesPopupWrapped, submitExamPaper: _submit.submitExamPaper, ); _timing = ExerciseTimingHandlers( session: _session, bridge: this, submit: _submit, isPausePopupVisible: () => _showPausePopup, onUiTick: () { if (mounted) setState(() {}); }, ); _collect = ExerciseCollectHandlers(session: _session, bridge: this); WidgetsBinding.instance.addPostFrameCallback((_) { _load(); }); } @override void dispose() { // 对标 onUnload:计时落盘/停止 + 失败队列落盘 _timing.persistTimingCache(); _submit.persistPendingToStorage(); _timing.dispose(); _navigation.dispose(); _hideLoadingImmediately(); _loadingText.dispose(); _pageController.dispose(); _panelHeight.dispose(); super.dispose(); } void _parseExtra() { final extra = widget.extra ?? const {}; _session.courseId = '${extra['courseId'] ?? ''}'; _session.packId = '${extra['packId'] ?? ''}'; _session.totalPackId = '${extra['totalPackId'] ?? ''}'; _session.recordId = '${extra['recordId'] ?? ''}'; _nowStudyObjectId = '${extra['nowStudyObjectId'] ?? ''}'; _session.isWrong = extra['isWrong'] == true || extra['isWrong'] == 'true'; _pendingTargetSort = int.tryParse('${extra['targetSort'] ?? 0}') ?? 0; _session.mode = extra['type'] == 'view' ? 'view' : 'edit'; } // ===================== 数据加载(对标 onLoad) ===================== Future _load() async { try { if (_isEdit) { // 先补提本地失败队列 await _submit.restorePendingFromStorage(); if (_submit.getPendingCount() > 0) { showLoading('提交中...'); var success = true; var failedCount = 0; try { final result = await _submit.retryFailedSubmitRequests( maxRetryRounds: 3, ); success = result.success; failedCount = result.failedCount; } finally { if (success) { try { final prefs = await SharedPreferences.getInstance(); await prefs .remove('classExercisesDraft:${_session.courseId}'); } catch (_) {} } else { await _submit.persistPendingToStorage(); } if (!success && mounted) { toast('网络异常,仍有 $failedCount 道题待提交,请稍后重试'); } // 与 Vue 一致:补提结束后把 loading 文案切为“加载中...” showLoading('加载中...'); } } final result = await ExerciseLoad.loadStartOvercome( inputRecordId: _session.recordId, ); _applyLoadResult(result); _resolveTargetSort(); _isJumpDataReady = true; _timing.restoreTimingCache(); _timing.timing.start(); _scheduleAutoJump(); } else { final result = await ExerciseLoad.loadSubmitReport( packId: _session.packId, isWrong: _session.isWrong, ); _applyLoadResult(result); _session.originalMaxSort = result.originalMaxSort; _resolveTargetSort(); _isJumpDataReady = true; _timing.restoreTimingCache(); // view 模式不启动计时 _scheduleAutoJump(); } } catch (e) { if (mounted) toast('加载失败,请重试'); } finally { hideLoading(); if (mounted) { setState(() => _isPageLoading = false); } } } void _applyLoadResult(ExerciseLoadResult result) { setState(() { _session.questions = result.questions; _session.recordId = result.recordId; _session.navBarTitle = result.navBarTitle; }); } /// nowStudyObjectId → 起始题号(对标 getTargetSortByNowStudyObjectId) void _resolveTargetSort() { if (_pendingTargetSort != 0 || _nowStudyObjectId.isEmpty) return; final targetId = _nowStudyObjectId; for (final item in _session.questions) { final itemId = item.id.isNotEmpty ? item.id : item.questionId; if (itemId.isNotEmpty && itemId == targetId) { _pendingTargetSort = sort.getQuestionSortRange(item).start; return; } } } void _scheduleAutoJump() { WidgetsBinding.instance.addPostFrameCallback((_) { _tryAutoJumpToTargetSort(); }); } /// 等 PageView 挂载完成后定位到 targetSort(对标 tryAutoJumpToTargetSort) Future _tryAutoJumpToTargetSort({int attempts = 0}) async { if (_hasAutoJumped) return; if (_pendingTargetSort == 0 || !_isJumpDataReady || _session.questions.isEmpty) { return; } if (!_pageController.hasClients) { if (attempts >= 10 || !mounted) return; await Future.delayed(const Duration(milliseconds: 100)); if (!mounted) return; return _tryAutoJumpToTargetSort(attempts: attempts + 1); } try { await _navigation .handleAnswerCardSelect({'sort': _pendingTargetSort}); _hasAutoJumped = true; } catch (_) { _hasAutoJumped = false; } } // ===================== ExerciseUiBridge ===================== @override void showLoading([String message = '加载中...']) { if (!mounted) return; _loadingText.value = message; if (_loadingEntry != null) return; final overlay = Overlay.maybeOf(context, rootOverlay: true); if (overlay == null) return; final entry = OverlayEntry( builder: (_) => _LoadingMask(text: _loadingText), ); _loadingEntry = entry; overlay.insert(entry); } @override void hideLoading() { _loadingEntry?.remove(); _loadingEntry = null; } void _hideLoadingImmediately() { if (_loadingEntry != null) { _loadingEntry?.remove(); _loadingEntry = null; } } @override void toast(String message) { if (mounted) CenterToast.show(context, message); } @override void notifyListeners() { if (mounted) setState(() {}); } @override void jumpToPage(int index) { if (_session.questions.isEmpty) return; final target = index.clamp(0, _session.questions.length - 1); void run() { _pageController.animateToPage( target, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ); } if (!_pageController.hasClients) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && _pageController.hasClients) run(); }); return; } run(); } @override void openExitPopup() { setState(() => _exitConfirmShow = true); } @override void showConquerPopup(ConquerPopupConfig config) { setState(() => _popup = config); } @override void hideConquerPopup() { setState(() => _popup = null); } @override void setAnswerSheetVisible(bool visible) { setState(() => _showAnswerSheet = visible); } @override void setPausePopupVisible(bool visible) { setState(() => _showPausePopup = visible); } @override void redirectToReport(String recordId, String packId) { if (!mounted) return; context.push( '/conquerPackage/report', extra: { 'recordId': recordId, 'packId': packId, 'from': 'submit', }, ); } @override void navigateBack({int delta = 1}) { if (!mounted) return; var n = delta; while (n-- > 0 && context.canPop()) { context.pop(); } } @override void relaunchCaptureList(String courseId) { if (!mounted) return; context.go('/conquerPackage/captureList', extra: courseId); } @override bool get isPad => isPadDevice(context); // ===================== 页面级事件包装(与 Vue 同名同义) ===================== bool _openTimePopupWrapped() { if (_session.isWrong) return false; return _submit.openTimePopupIfNeeded(); } bool _openNotesPopupWrapped() { if (_session.isWrong) return false; return _submit.openUploadNotesPopupIfNeeded(); } void _switchStep(String type, [String? timePopupIfNeeded]) { if (_isInteractionLocked) return; final now = DateTime.now(); if (_lastStepAt != null && now.difference(_lastStepAt!).inMilliseconds < 400) { return; } // 程序触发翻页后的 400ms 保护期内忽略重复触发 if (_navigation.isProgrammaticJump) return; _lastStepAt = now; _navigation.switchStep(type, timePopupIfNeeded); } void _handleNavBarBack() { if (_isInteractionLocked) return; if (!_isEdit) { navigateBack(delta: 1); return; } _navigation.clickBackNavBar(); } void _handleSystemBack() { if (!_isEdit) return; final now = DateTime.now(); if (_lastBackAt != null && now.difference(_lastBackAt!).inMilliseconds < 300) { return; } _lastBackAt = now; _navigation.clickBackNavBar(); } void _toggleTiming() { final willPause = _timing.timing.isRunning() && !_showPausePopup; _timing.toggleTiming(); if (willPause && _showPausePopup) { _navigation.submitCurrentQuestionBeforePageSwitch(); } } /// 暂停弹窗关闭:继续计时 + 当前题 startTime 重置 void _handlePausePopupClose() { _timing.handlePausePopupClose(); _resetCurrentQuestionStartTime(); } void _resetCurrentQuestionStartTime() { final index = _session.currentIndex; final item = _session.currentQuestion; if (item == null) return; final now = getCurrentISO(); item.startTime = now; final subIndex = _session.activeSubIndexMap[index] ?? -1; if (subIndex >= 0) { if (subIndex < item.subQuestions.length) { item.subQuestions[subIndex].startTime = now; } if (subIndex < item.childs.length) { item.childs[subIndex].startTime = now; } } } /// ConquerPopup 确认分发(页面在 submit 控制器之上加了 note / time 包装) void _handlePopupConfirm(String action) { final config = _popup; if (config == null) return; if (config.type == 'note') { setState(() => _popup = null); _navigation.uploadNotes(); return; } if (action == 'minimumLearningDuration') { setState(() => _popup = null); _navigation.switchStep('NextStep', 'minimumLearningDuration'); return; } _submit.handlePopupConfirm(config); } /// 答题卡选题(页面包装:先关弹层;向后跳题先查笔记必传) Future _handleAnswerCardSelect(Map question) async { setState(() => _showAnswerSheet = false); final targetSort = int.tryParse('${question['sort'] ?? 0}') ?? 0; if (targetSort > _navigation.currentSortLabel && _openNotesPopupWrapped()) { return; } await _navigation.handleAnswerCardSelect(question); } void _handleUploadNotes() { if (_isInteractionLocked) return; _navigation.uploadNotes(); } Future _handleExitConfirm() async { _session.itemRefs[_session.currentIndex]?.currentState?.pauseMedia(); _navigation.submitCurrentQuestionBeforePageSwitch(); await _submit.persistPendingToStorage(); if (!mounted) return; setState(() => _exitConfirmShow = false); navigateBack(delta: 1); } void _handleOpenFeedback() { if (_isInteractionLocked) return; final info = _session.currentQuestion; _feedbackKey.currentState?.open(info?.id, info?.questionId); } // ===================== Build ===================== @override Widget build(BuildContext context) { final current = _session.currentQuestion; return PopScope( canPop: !_isEdit, onPopInvokedWithResult: (didPop, result) { if (didPop) return; _handleSystemBack(); }, child: Scaffold( backgroundColor: ExerciseColors.bg, body: Stack( children: [ // ---- HeaderBac 顶部背景图 ---- Positioned( top: 0, left: 0, right: 0, child: CachedNetworkImage( imageUrl: ImgUrl.build( '/StaticResource/2026-05-26/my-header-bg-default1779775984375.png', ), fit: BoxFit.fitWidth, width: double.infinity, placeholder: (_, _) => const SizedBox.shrink(), errorWidget: (_, _, _) => const SizedBox.shrink(), ), ), SafeArea( bottom: false, child: Column( children: [ _buildNavBar(), _buildHead(), Expanded(child: _buildPageView()), ], ), ), // ---- 复合题浮层(swiper 外 Stack,与 Vue 一致) ---- if (current != null && current.hasOptionChildrenQuestion) _PanelBinder( key: ValueKey( 'panel-${current.id.isNotEmpty ? current.id : current.questionId}-${_session.currentIndex}', ), session: _session, detail: current, activeSubIndex: _session.activeSubIndexMap[_session.currentIndex] ?? 0, mode: _isEdit ? CompoundMode.edit : CompoundMode.view, onMyAnswer: (index, data) => _submit.getMyMinAnswer(index: index, data: data), onSubSwitch: (index, data) => _submit.selectChildData(index: index, data: data), onActiveSubIndexChanged: (index) => _navigation.handleSubQuestionChange( _session.currentIndex, index), onFillBlankImgChange: (subIndex, images) => _submit.fillBlankImgChange( images, subIndex, _session.currentIndex), onPanelHeightChanged: (h) { // 不 setState:只更新 notifier,占位盒局部跟随 if (h != _panelHeight.value) _panelHeight.value = h; }, ), // ---- 底部操作栏(有题时才显示) ---- if (_session.questions.isNotEmpty) _buildFooter(current), // ---- 弹窗(层级在 footer 之上) ---- ConquerPopup( show: _popup?.show ?? false, type: _popup?.type ?? 'note', title: _popup?.title ?? '', subtitle: _popup?.subtitle ?? '', confirmText: _popup?.confirmText ?? '确定', cancelText: _popup?.cancelText ?? '取消', isConfirm: _popup?.isConfirm ?? false, confirmAction: _popup?.confirmAction ?? '', onConfirm: _handlePopupConfirm, onCancel: () => setState(() => _popup = null), ), AnswerCardPopup( show: _showAnswerSheet, questions: _session.questions.map((q) => q.m).toList(growable: false), isExamAnswerCard: true, enableExtendedAnswerStatus: true, statusMode: _isEdit ? 'answer' : 'result', onCancel: () => setState(() => _showAnswerSheet = false), onSubmit: () => _submit.submitExamPaper(''), onSelect: _handleAnswerCardSelect, ), PausePopup( show: _showPausePopup, totalCount: _navigation.totalSortCount, remainingCount: _timing.remainingCount, onClose: _handlePausePopupClose, ), ExerciseConfirmBox( show: _exitConfirmShow, title: '确定要退出任务攻克?', content: '攻克记录会保存,后续可继续攻克', confirmText: '确定', cancelText: '取消', showCancel: true, onConfirm: _handleExitConfirm, onCancel: () => setState(() => _exitConfirmShow = false), ), FeedbackPopup(key: _feedbackKey), // ---- 页面加载遮罩 ---- if (_isPageLoading) const _PageLoadingMask(), ], ), ), ); } // ===================== NavBar ===================== Widget _buildNavBar() { return SizedBox( height: 44.h, child: Row( children: [ SizedBox(width: 8.w), GestureDetector( behavior: HitTestBehavior.opaque, onTap: _handleNavBarBack, child: Padding( padding: EdgeInsets.symmetric(horizontal: 4.w, vertical: 4.h), child: CachedNetworkImage( imageUrl: ImgUrl.build('/StaticResource/2026-04-01/return.png'), width: 24.w, height: 24.h, placeholder: (_, _) => Icon( Icons.arrow_back_ios, size: 18.w, color: ExerciseColors.subtitle, ), errorWidget: (_, _, _) => Icon( Icons.arrow_back_ios, size: 18.w, color: ExerciseColors.subtitle, ), ), ), ), Expanded( child: Padding( padding: EdgeInsets.only(left: 11.w), child: Align( alignment: Alignment.centerLeft, child: Text( _session.navBarTitle, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 12.sp, color: ExerciseColors.subtitle, ), ), ), ), ), SizedBox(width: 16.w), ], ), ); } // ===================== head:计时/标题 + 进度 ===================== Widget _buildHead() { return Padding( padding: EdgeInsets.all(10.w), child: Row( children: [ if (_isEdit) _buildTimingCapsule() else _buildViewTitle(), const Spacer(), _buildProgress(), ], ), ); } Widget _buildTimingCapsule() { return Container( padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 6.h), decoration: BoxDecoration( gradient: const LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Color(0xFFFFFFFF), Color(0xFFF0FCFF)], ), border: Border.all(color: Colors.white, width: 1.w), borderRadius: BorderRadius.circular(15.r), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ CachedNetworkImage( imageUrl: ImgUrl.build('/StaticResource/2026-04-02/timing.png'), width: 18.w, height: 18.h, placeholder: (_, _) => Icon(Icons.timer, size: 18.w, color: ExerciseColors.primary), errorWidget: (_, _, _) => Icon(Icons.timer, size: 18.w, color: ExerciseColors.primary), ), Padding( padding: EdgeInsets.symmetric(horizontal: 5.w), child: Text( _timing.timingText, style: TextStyle( fontSize: 13.sp, color: ExerciseColors.title, height: 18 / 13, ), ), ), GestureDetector( behavior: HitTestBehavior.opaque, onTap: _toggleTiming, child: CachedNetworkImage( imageUrl: ImgUrl.build( _timing.timingRunning ? '/StaticResource/2026-04-20/pause.png' : '/StaticResource/2026-04-20/play.png', ), width: 18.w, height: 18.h, placeholder: (_, _) => Icon( _timing.timingRunning ? Icons.pause : Icons.play_arrow, size: 18.w, color: ExerciseColors.primary, ), errorWidget: (_, _, _) => Icon( _timing.timingRunning ? Icons.pause : Icons.play_arrow, size: 18.w, color: ExerciseColors.primary, ), ), ), ], ), ); } Widget _buildViewTitle() { return Stack( children: [ // 标题装饰底图:位置可调(right 控制左右、bottom 控制上下) Positioned( right: 0, bottom: 3.h, child: Image( image: CachedNetworkImageProvider( ImgUrl.build( '/K12/materials/img/2026-03-30/769ab865255c46f989ce793b121786d0.png'), ), width: 59.5.w, height: 9.h, fit: BoxFit.fill, errorBuilder: (_, _, _) => const SizedBox.shrink(), ), ), Container( constraints: BoxConstraints(minHeight: 24.h), padding: EdgeInsets.only(right: 4.w, bottom: 3.h), child: Text( _session.isWrong ? '错题解析' : '全部解析', style: TextStyle( fontSize: 17.sp, fontWeight: FontWeight.w600, color: ExerciseColors.title, ), ), ), ], ); } Widget _buildProgress() { final currentLabel = _session.isWrong ? _navigation.currentWrongSortLabel : _navigation.currentSortLabel; final totalCount = _session.isWrong ? _navigation.totalWrongSortCount : _navigation.totalSortCount; return GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => setState(() => _showAnswerSheet = true), child: Row( mainAxisSize: MainAxisSize.min, children: [ SizedBox( width: 150.w, child: ClipRRect( borderRadius: BorderRadius.circular(4.r), child: LinearProgressIndicator( value: (_navigation.percentage / 100).clamp(0.0, 1.0), minHeight: 8, backgroundColor: Colors.white, valueColor: const AlwaysStoppedAnimation(ExerciseColors.primary), ), ), ), SizedBox(width: 10.w), Text.rich( TextSpan( children: [ TextSpan( text: '$currentLabel', style: TextStyle( fontSize: 15.sp, fontWeight: FontWeight.w600, color: ExerciseColors.title, ), ), TextSpan( text: '/$totalCount', style: TextStyle( fontSize: 12.sp, color: ExerciseColors.subtitle, ), ), ], ), ), SizedBox(width: 5.w), CachedNetworkImage( imageUrl: ImgUrl.build('/StaticResource/2026-04-02/answer.png'), width: 18.w, height: 18.h, placeholder: (_, _) => Icon(Icons.list_alt, size: 18.w, color: ExerciseColors.primary), errorWidget: (_, _, _) => Icon(Icons.list_alt, size: 18.w, color: ExerciseColors.primary), ), ], ), ); } // ===================== PageView ===================== Widget _buildPageView() { if (_session.questions.isEmpty) { return const SizedBox.shrink(); } // 左滑(下一题)在“倒计时未走完 / 必传笔记未传”时拦截;右滑始终放行。 // 关闭自带 pageSnapping:吸附由下方物理类统一处理,才能同时拦住“甩动翻页”。 return NotificationListener( onNotification: _onPageUserScroll, child: PageView( controller: _pageController, physics: _ForwardGuardScrollPhysics(canGoForward: _canSwipeToNext), pageSnapping: false, allowImplicitScrolling: true, onPageChanged: _navigation.handlePageChange, children: List.generate( _session.questions.length, _buildRealQuestionItem, ), ), ); } /// 被拦截的同一次左滑手势内只弹一次提示 bool _forwardBlockPopupShown = false; /// 是否允许左滑到下一题(与点“下一题”按钮的两道拦截口径一致) bool _canSwipeToNext() { if (!_isEdit) return true; // view 模式不拦截 final info = _session.currentQuestion; if (info == null) return false; // 1. 建议作答时长倒计时没走完 if (info.planTimeSecond > 0 && !info.isFinishPlanTime) return false; // 2. 必须上传笔记但还没传 if (info.isUploadNotes && info.notes.isEmpty) return false; return true; } /// 左滑被拦截时,给出与“下一题”按钮相同的提示(倒计时优先,其次笔记) bool _onPageUserScroll(UserScrollNotification n) { if (n.direction == ScrollDirection.idle) { _forwardBlockPopupShown = false; return false; } if (_forwardBlockPopupShown) return false; // 水平 PageView:reverse = 向左拖去下一题 if (n.direction == ScrollDirection.reverse && !_canSwipeToNext()) { _forwardBlockPopupShown = true; if (_openTimePopupWrapped()) return false; _openNotesPopupWrapped(); } return false; } /// 正式题目页 item Widget _buildRealQuestionItem(int index) { final key = _session.itemRefs[index] ??= GlobalKey(); final question = _session.questions[index]; return ExerciseQuestionView( key: key, data: question, isActive: index == _session.currentIndex, isAdjacent: (index - _session.currentIndex).abs() <= 1, mode: _isEdit ? ExerciseQuestionMode.edit : ExerciseQuestionMode.view, activeSubIndex: _session.activeSubIndexMap[index] ?? 0, panelHeight: _panelHeight, onSelectAnswer: (payload) => _submit.selectAnswer(payload['userAnswer'], index), onUpdateAnswer: (value) => _submit.updateAnswer(value, index), onNotesChange: (items) { // 对标 handleNotesChange:异步回调显式带索引,写回题目 notes question.notes = QuestionDataAdapter.uploadItemsToAnswerImgs(items); }, onSubQuestionChange: (payload) => _navigation.handleSubQuestionChange(index, payload.subIndex), onStemLinkTap: (subIndex) => _session.panelKey?.currentState?.onStemLinkTap(subIndex), ); } // ===================== footer ===================== Widget _buildFooter(QuestionModel? info) { final pad = isPad; return Positioned( left: 0, right: 0, bottom: 0, child: Material( color: Colors.white, child: SafeArea( top: false, child: Container( padding: EdgeInsets.fromLTRB( pad ? 32.w : 14.w, 12.h, pad ? 32.w : 14.w, 12.h, ), decoration: BoxDecoration( color: Colors.white, border: Border( top: BorderSide(color: const Color(0xFFEEEEEE), width: 1.w), ), ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ if (_isEdit) _buildFooterLi( icon: '/StaticResource/2026-04-02/note.png', text: '上传笔记', fallback: Icons.note_add_outlined, onTap: _handleUploadNotes, ), _buildFooterLi( icon: (info?.isCollect ?? false) ? '/StaticResource/2026-04-02/collected.png' : '/StaticResource/2026-04-02/collect.png', text: '收藏', fallback: Icons.star_border, onTap: () { if (_isInteractionLocked) return; _collect.onClickAddUserCollect(1); }, ), if (!_isEdit) _buildFooterLi( icon: '/StaticResource/2026-04-28/mistakes.png', text: (info?.isWrongCollect ?? false) ? '取消错题' : '加入错题', fallback: Icons.bookmark_border, onTap: () { if (_isInteractionLocked) return; _collect.onClickAddUserCollect(2); }, ), _buildFooterLi( icon: '/StaticResource/2026-04-01/feedback.png', text: '题目反馈', fallback: Icons.feedback_outlined, onTap: _handleOpenFeedback, ), _buildStepButtons(pad), ], ), ), ), ), ); } Widget _buildFooterLi({ required String icon, required String text, required IconData fallback, required VoidCallback onTap, }) { return GestureDetector( behavior: HitTestBehavior.opaque, onTap: onTap, child: Column( mainAxisSize: MainAxisSize.min, children: [ CachedNetworkImage( imageUrl: ImgUrl.build(icon), width: 24.w, height: 24.h, placeholder: (_, _) => Icon(fallback, size: 24.w, color: ExerciseColors.title), errorWidget: (_, _, _) => Icon(fallback, size: 24.w, color: ExerciseColors.title), ), SizedBox(height: 5.h), Text( text, style: TextStyle(fontSize: 10.sp, color: ExerciseColors.title), ), ], ), ); } Widget _buildStepButtons(bool pad) { final buttonWidth = pad ? 125.w : 82.w; final buttonHeight = 38.h; final radius = Radius.circular(19.r); final isLastSort = _navigation.currentSortLabel == _navigation.totalSortCount && _navigation.totalSortCount > 0; final showComplete = !_isEdit && isLastSort; return Row( mainAxisSize: MainAxisSize.min, children: [ // 上一题 Opacity( opacity: _navigation.isPrevDisabled ? 0.6 : 1, child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => _switchStep('PreviousStep'), child: Container( width: buttonWidth, height: buttonHeight, alignment: Alignment.center, decoration: BoxDecoration( color: Colors.white, border: Border.all(color: ExerciseColors.primary, width: 1.w), borderRadius: BorderRadius.only(topLeft: radius, bottomLeft: radius), ), child: Text( '上一题', style: TextStyle(fontSize: 13.sp, color: ExerciseColors.title), ), ), ), ), // 下一题 / 交卷 / 完成 + 倒计时气泡 Stack( clipBehavior: Clip.none, children: [ GestureDetector( behavior: HitTestBehavior.opaque, onTap: () { if (showComplete) { if (_isInteractionLocked) return; _navigation.completeEnd(); return; } _switchStep('NextStep'); }, child: Container( width: buttonWidth, height: buttonHeight, alignment: Alignment.center, decoration: BoxDecoration( color: ExerciseColors.primary, borderRadius: BorderRadius.only(topRight: radius, bottomRight: radius), ), child: Text( showComplete ? '完成' : (isLastSort ? '交卷' : '下一题'), style: const TextStyle( fontSize: 13, color: Colors.white, ), ), ), ), if (_shouldShowTimeBubble()) _buildTimeBubble(), ], ), ], ); } bool _shouldShowTimeBubble() { if (!_isEdit) return false; final info = _session.currentQuestion; if (info == null || info.planTimeSecond <= 0 || info.isFinishPlanTime) { return false; } return getDisplayPlanTimeSecond(info) > 0; } Widget _buildTimeBubble() { final info = _session.currentQuestion!; final questionKey = info.id.isNotEmpty ? info.id : _session.currentIndex; final remainSecond = getDisplayPlanTimeSecond(info); final timeKey = '$questionKey-${info.m['NewPlanTimeSecond'] ?? info.planTimeSecond}-${info.isFinishPlanTime ? 1 : 0}'; return Positioned( right: 0, top: 0, child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => _switchStep('NextStep'), child: Column( mainAxisSize: MainAxisSize.min, children: [ // 气泡背景图保持原始尺寸不变形 Container( width: 39.w, height: 21.h, alignment: Alignment.center, decoration: BoxDecoration( image: DecorationImage( image: CachedNetworkImageProvider( ImgUrl.build('/StaticResource/2026-04-17/bubble.png'), ), fit: BoxFit.fill, ), ), child: _CountdownBinder( key: ValueKey('countdown-$timeKey'), session: _session, questionKey: questionKey, timeMs: remainSecond * 1000, onFinish: _submit.finishPlanTime, ), ), // 时间文字距下方 10px,不拉伸背景图 const SizedBox(height: 10), ], ), ), ); } } /// ============================================================ /// 复合题面板绑定器: /// - 自身以 ValueKey(题目标识) 挂载,切大题整体重建(对标 Vue :key); /// - State 持有 GlobalKey 并回写 session.panelKey,供控制器命令式调用。 /// ============================================================ class _PanelBinder extends StatefulWidget { const _PanelBinder({ super.key, required this.session, required this.detail, required this.activeSubIndex, required this.mode, this.onMyAnswer, this.onSubSwitch, this.onActiveSubIndexChanged, this.onFillBlankImgChange, this.onPanelHeightChanged, }); final ExerciseSession session; final QuestionModel detail; final int activeSubIndex; final CompoundMode mode; final CompoundMyAnswerCallback? onMyAnswer; final CompoundSubSwitchCallback? onSubSwitch; final CompoundActiveSubCallback? onActiveSubIndexChanged; final CompoundFillBlankCallback? onFillBlankImgChange; final CompoundPanelHeightCallback? onPanelHeightChanged; @override State<_PanelBinder> createState() => _PanelBinderState(); } class _PanelBinderState extends State<_PanelBinder> { final GlobalKey _panelKey = GlobalKey(); @override void initState() { super.initState(); widget.session.panelKey = _panelKey; } @override void dispose() { // 仅当持有的仍是自己的 key 时才清理,避免新旧 binder 切换时序问题 if (identical(widget.session.panelKey, _panelKey)) { widget.session.panelKey = null; } super.dispose(); } @override Widget build(BuildContext context) { return CompoundQuestionPanel( key: _panelKey, detail: widget.detail, mode: widget.mode, activeSubIndex: widget.activeSubIndex, isActive: true, isBottomH: false, isScore: true, onMyAnswer: widget.onMyAnswer, onSubSwitch: widget.onSubSwitch, onActiveSubIndexChanged: widget.onActiveSubIndexChanged, onFillBlankImgChange: widget.onFillBlankImgChange, onPanelHeightChanged: widget.onPanelHeightChanged, ); } } /// ============================================================ /// 倒计时绑定器:key 三元组(题 id / 剩余秒 / 结束标志)变化即整体重建, /// 并把内部 GlobalKey 回写 session.countDownKey。 /// ============================================================ class _CountdownBinder extends StatefulWidget { const _CountdownBinder({ super.key, required this.session, required this.questionKey, required this.timeMs, required this.onFinish, }); final ExerciseSession session; final Object questionKey; final int timeMs; final ValueChanged onFinish; @override State<_CountdownBinder> createState() => _CountdownBinderState(); } class _CountdownBinderState extends State<_CountdownBinder> { final GlobalKey _countdownKey = GlobalKey(); @override void initState() { super.initState(); widget.session.countDownKey = _countdownKey; } @override void dispose() { if (identical(widget.session.countDownKey, _countdownKey)) { widget.session.countDownKey = null; } super.dispose(); } @override Widget build(BuildContext context) { return QuestionPlanCountDown( key: _countdownKey, questionKey: widget.questionKey, timeMs: widget.timeMs, onFinish: widget.onFinish, textStyle: TextStyle( color: Colors.white, fontSize: 12.sp, height: 19.h / (12.sp), ), ); } } /// ============================================================ /// 单向拦截 PageView 物理(PageView 已关闭自带 pageSnapping): /// 拖动阶段:左滑(去下一题)由 [canGoForward] 判定,false 时位移为 0; /// 松手阶段:自行计算最近页吸附,被拦时目标绝不超过当前页 /// ——同时挡住“甩动速度翻页”; /// 右滑(回上一题)始终放行。 /// ============================================================ class _ForwardGuardScrollPhysics extends ScrollPhysics { const _ForwardGuardScrollPhysics({ required this.canGoForward, super.parent, }); /// 返回 true 才允许向前(下一题)拖动 final bool Function() canGoForward; @override _ForwardGuardScrollPhysics applyTo(ScrollPhysics? ancestor) { return _ForwardGuardScrollPhysics( canGoForward: canGoForward, parent: buildParent(ancestor), ); } @override double applyPhysicsToUserOffset(ScrollMetrics position, double offset) { // 水平 PageView(非 reverse):offset < 0 = 手指左拖去下一题 if (offset < 0 && !canGoForward()) return 0; return super.applyPhysicsToUserOffset(position, offset); } @override Simulation? createBallisticSimulation( ScrollMetrics position, double velocity, ) { final tolerance = toleranceFor(position); final viewport = position.viewportDimension; if (viewport <= 0) return null; final maxPage = (position.maxScrollExtent / viewport).clamp(0, 1 << 31).round(); final currentPage = (position.pixels / viewport).round().clamp(0, maxPage); // 由甩动速度决定目标页(等价 PageScrollPhysics 的 ±1 页规则) var targetPage = currentPage; if (velocity < -tolerance.velocity) { targetPage = currentPage - 1; } else if (velocity > tolerance.velocity) { targetPage = currentPage + 1; } else { // 无明显甩动:吸附到距当前位置最近的页 targetPage = (position.pixels / viewport).round(); } // 关键:不允许向前时,目标页不得超过当前页(挡住甩动翻页) if (!canGoForward() && targetPage > currentPage) { targetPage = currentPage; } targetPage = targetPage.clamp(0, maxPage); final targetPixels = targetPage * viewport; if ((targetPixels - position.pixels).abs() < precisionErrorTolerance) { return null; } return ScrollSpringSimulation( spring, position.pixels, targetPixels.toDouble(), velocity, tolerance: tolerance, ); } } /// 全局 loading 遮罩(对标 uni.showLoading({ mask: true })) class _LoadingMask extends StatelessWidget { const _LoadingMask({required this.text}); final ValueListenable text; @override Widget build(BuildContext context) { return Positioned.fill( child: ColoredBox( color: const Color(0x66000000), child: Center( child: Container( padding: EdgeInsets.symmetric(horizontal: 32.w, vertical: 24.h), decoration: BoxDecoration( color: Colors.black87, borderRadius: BorderRadius.circular(12.r), ), child: ValueListenableBuilder( valueListenable: text, builder: (_, value, _) => Column( mainAxisSize: MainAxisSize.min, children: [ const CircularProgressIndicator(color: Colors.white), SizedBox(height: 16.h), Text( value, style: TextStyle(color: Colors.white, fontSize: 14.sp), ), ], ), ), ), ), ), ); } } /// 页面级加载遮罩(对标 .page-loading-mask:rgba(0,0,0,.2) + 白色转圈) class _PageLoadingMask extends StatelessWidget { const _PageLoadingMask(); @override Widget build(BuildContext context) { return Positioned.fill( child: ColoredBox( color: const Color(0x33000000), child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ SizedBox( width: 40.w, height: 40.w, child: CircularProgressIndicator( strokeWidth: 3.w, valueColor: const AlwaysStoppedAnimation(Colors.white), backgroundColor: Colors.white.withValues(alpha: 0.3), ), ), SizedBox(height: 12.h), Text( '加载中...', style: TextStyle(color: Colors.white, fontSize: 14.sp), ), ], ), ), ), ); } }