1464 lines
48 KiB
Dart
1464 lines
48 KiB
Dart
/// ============================================================
|
||
/// 【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<String, dynamic>? extra;
|
||
|
||
@override
|
||
State<ClassExercisesPage> createState() => _ClassExercisesPageState();
|
||
}
|
||
|
||
class _ClassExercisesPageState extends State<ClassExercisesPage>
|
||
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<FeedbackPopupState> _feedbackKey = GlobalKey();
|
||
|
||
// ===================== 页面级 UI 状态 =====================
|
||
|
||
bool _isPageLoading = true;
|
||
bool _showAnswerSheet = false;
|
||
bool _showPausePopup = false;
|
||
ConquerPopupConfig? _popup;
|
||
bool _exitConfirmShow = false;
|
||
|
||
/// 面板高度(拖拽时高频更新,用 ValueNotifier 避免整页 setState)
|
||
final ValueNotifier<double> _panelHeight = ValueNotifier<double>(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<String> _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 <String, dynamic>{};
|
||
_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<void> _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<void> _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<void>.delayed(const Duration(milliseconds: 100));
|
||
if (!mounted) return;
|
||
return _tryAutoJumpToTargetSort(attempts: attempts + 1);
|
||
}
|
||
try {
|
||
await _navigation
|
||
.handleAnswerCardSelect(<String, dynamic>{'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: <String, dynamic>{
|
||
'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<void> _handleAnswerCardSelect(Map<String, dynamic> 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<void> _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<UserScrollNotification>(
|
||
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<ExerciseQuestionViewState>();
|
||
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<CompoundQuestionPanelState> _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<Object?> onFinish;
|
||
|
||
@override
|
||
State<_CountdownBinder> createState() => _CountdownBinderState();
|
||
}
|
||
|
||
class _CountdownBinderState extends State<_CountdownBinder> {
|
||
final GlobalKey<QuestionPlanCountDownState> _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<String> 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<String>(
|
||
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<Color>(Colors.white),
|
||
backgroundColor: Colors.white.withValues(alpha: 0.3),
|
||
),
|
||
),
|
||
SizedBox(height: 12.h),
|
||
Text(
|
||
'加载中...',
|
||
style: TextStyle(color: Colors.white, fontSize: 14.sp),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|