Files
App.K12.Student.Miniapp_Flu…/lib/features/conquerPackage/classExercises/class_exercises_page.dart
T
2026-09-28 20:12:34 +08:00

1464 lines
48 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// ============================================================
/// 【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),
),
],
),
),
),
);
}
}