- 为conquerPackage模块添加过渡页面路由并实现页面跳转逻辑 - 新增通用多图上传组件Upload,支持多种模式与图片操作功能 - 新增任务相关的API及Model基础文件 - 注释课前课中页面的测试试题内容
888 lines
32 KiB
Dart
888 lines
32 KiB
Dart
import 'package:cached_network_image/cached_network_image.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter/scheduler.dart';
|
||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||
import 'package:go_router/go_router.dart';
|
||
import 'package:new_learn/core/api/conquerPackage/beforeDuringClass/api.dart';
|
||
import 'package:new_learn/core/api/conquerPackage/beforeDuringClass/model.dart';
|
||
import 'package:new_learn/core/api/public_upload/model.dart';
|
||
import 'package:new_learn/core/components/center_toast.dart';
|
||
import 'package:new_learn/core/components/confirm_box.dart';
|
||
import 'package:new_learn/core/components/upload.dart';
|
||
import 'package:new_learn/core/utils/img_url.dart';
|
||
import 'package:new_learn/features/conquerPackage/beforeDuringClass/before_during_page.dart';
|
||
|
||
// ===================== 自定义滑动物理 =====================
|
||
|
||
/// 单向滑动控制:
|
||
/// - 向右滑(上一页,index 减小)始终允许
|
||
/// - 向左滑(下一页,index 增大)需要满足时长 + 笔记条件
|
||
/// ============================================================
|
||
/// 【BeforeDuringClassPage】课前课中(讲义学习)页面
|
||
/// 对标 Vue pages/conquerPackage/beforeDuringClass/beforeDuringClass.vue
|
||
///
|
||
/// 一、页面结构:
|
||
/// ┌──────────────────────────────────┐
|
||
/// │ HeaderBac 背景图 + NavBar │
|
||
/// │ ┌────────────────────────────┐ │
|
||
/// │ │ 计时图标+时间 进度条 1/10 │ │
|
||
/// │ └────────────────────────────┘ │
|
||
/// ├──────────────────────────────────┤
|
||
/// │ │
|
||
/// │ PageView.builder (懒加载) │
|
||
/// │ 每页一个 BeforeDuringPage │
|
||
/// │ (仅构建视口 ±1 页,滑出回收) │
|
||
/// │ │
|
||
/// ├──────────────────────────────────┤
|
||
/// │ 底部操作栏:上传笔记 / 上一步 / │
|
||
/// │ 下一步(最后一页→完成学习) │
|
||
/// └──────────────────────────────────┘
|
||
///
|
||
/// 二、核心功能:
|
||
/// 1. PageView.builder 懒加载:physics 禁止滑动(按钮翻页),
|
||
/// allowImplicitScrolling=true 预构建相邻页
|
||
/// 2. 学习计时:Ticker 驱动,onPageChange 结算时长
|
||
/// 3. 建议时长倒计时:当前页 planStudyTimeSecond 剩余时间
|
||
/// 4. 笔记必传校验:isUploadNotes 页必须有笔记才能进下一页
|
||
/// 5. 时长不足提示弹窗(可跳过)
|
||
/// 6. 笔记必传提示弹窗
|
||
///
|
||
/// 三、尺寸换算(本工程统一):
|
||
/// Vue 的 N rpx → Flutter 的 (N/2).w/.h/.sp/.r
|
||
///
|
||
/// 四、路由参数(通过 extra 传入):
|
||
/// - courseId 课程 id
|
||
/// - packId 攻克包 id
|
||
/// - recordId 学习记录 id
|
||
/// - type edit / view(默认 edit)
|
||
/// - isWrong 是否错题模式(默认 false)
|
||
/// ============================================================
|
||
|
||
/// 页面模式
|
||
enum BeforeDuringMode { edit, view }
|
||
|
||
class BeforeDuringClassPage extends StatefulWidget {
|
||
const BeforeDuringClassPage({super.key, this.extra});
|
||
|
||
final Map<String, dynamic>? extra;
|
||
|
||
@override
|
||
State<BeforeDuringClassPage> createState() => _BeforeDuringClassPageState();
|
||
}
|
||
|
||
class _BeforeDuringClassPageState extends State<BeforeDuringClassPage>
|
||
with TickerProviderStateMixin {
|
||
// ========== 颜色常量 ==========
|
||
static const Color _bgColor = Color(0xFFF5F8FA);
|
||
static const Color _titleColor = Color(0xFF1D2B47);
|
||
static const Color _subtitleColor = Color(0xFF99A4BA);
|
||
static const Color _primary = Color(0xFF3D7DF5);
|
||
|
||
// ========== Controller ==========
|
||
final PageController _pageController = PageController();
|
||
final Map<int, GlobalKey<BeforeDuringPageState>> _pageKeys = {};
|
||
|
||
// ========== 路由参数 ==========
|
||
late BeforeDuringMode _mode;
|
||
late String _courseId;
|
||
late String _packId;
|
||
late String _recordId;
|
||
bool _isWrong = false;
|
||
|
||
// ========== 数据 ==========
|
||
List<HandoutItem> _pageList = [];
|
||
int _currentIndex = 0;
|
||
bool _loading = true;
|
||
|
||
// ========== 全局学习计时 ==========
|
||
late final Ticker _timingTicker;
|
||
Duration _timingOffset = Duration.zero;
|
||
Duration _timingElapsed = Duration.zero;
|
||
bool _timingRunning = false;
|
||
|
||
// ========== 当前页倒计时 ==========
|
||
late final Ticker _countdownTicker;
|
||
Duration _countdownRemaining = Duration.zero;
|
||
int _lastCountdownTickSecond = 0;
|
||
DateTime _pageStartTime = DateTime.now();
|
||
bool _isNavigatingAway = false;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_parseExtra();
|
||
|
||
// 全局学习计时
|
||
_timingTicker = createTicker((elapsed) {
|
||
if (_timingRunning) {
|
||
_timingElapsed = elapsed + _timingOffset;
|
||
setState(() {});
|
||
}
|
||
});
|
||
if (_mode == BeforeDuringMode.edit) {
|
||
_timingTicker.start();
|
||
_timingRunning = true;
|
||
}
|
||
|
||
// 单页倒计时(基于真实流逝时间,每秒更新 UI)
|
||
_countdownTicker = createTicker((elapsed) {
|
||
final elapsedSeconds = elapsed.inSeconds;
|
||
if (_lastCountdownTickSecond == elapsedSeconds) return;
|
||
_lastCountdownTickSecond = elapsedSeconds;
|
||
|
||
if (_countdownRemaining.inSeconds > 0) {
|
||
_countdownRemaining -= const Duration(seconds: 1);
|
||
setState(() {});
|
||
} else {
|
||
_countdownTicker.stop();
|
||
_markCurrentPagePlanTimeFinished();
|
||
}
|
||
});
|
||
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
_loadData();
|
||
});
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_timingTicker.dispose();
|
||
_countdownTicker.dispose();
|
||
_pageController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
// ===================== 初始化 =====================
|
||
|
||
void _parseExtra() {
|
||
final extra = widget.extra ?? const <String, dynamic>{};
|
||
_courseId = (extra['courseId']?.toString()) ?? '';
|
||
_packId = (extra['packId']?.toString()) ?? '';
|
||
_recordId = (extra['recordId']?.toString()) ?? '';
|
||
_isWrong = extra['isWrong'] == true || extra['isWrong'] == 'true';
|
||
final typeStr = (extra['type'] as String?) ?? 'edit';
|
||
_mode = typeStr == 'view'
|
||
? BeforeDuringMode.view
|
||
: BeforeDuringMode.edit;
|
||
}
|
||
|
||
Future<void> _loadData() async {
|
||
setState(() => _loading = true);
|
||
try {
|
||
final HandoutListResponse resp;
|
||
if (_mode == BeforeDuringMode.view) {
|
||
resp = await BeforeDuringClassApi.instance.getSubmitReport(_recordId);
|
||
} else {
|
||
resp = await BeforeDuringClassApi.instance
|
||
.getListByRecordHandout(_recordId);
|
||
}
|
||
_pageList = List<HandoutItem>.from(resp.items);
|
||
|
||
// _pageList = List<HandoutItem>.from(resp.items).map((item) {
|
||
// return item.copyWith(
|
||
// content: item.content +
|
||
// '<div class="hxdb-stem" name="hxdbStem"><div class="hxdb-stem-content" name="hxdbStem-content" data-hxdb-type="stem"><p>某兴趣小组利用 Pt1000 铂电阻测量温度。实验中没有电压表,现将电流表 <img src="https://hx.cnzhyx.cn/K12LatexToSvg/ProductionV2/LatexSvg/2026-08-27/878136bf67384b3ab5998cc6fc15bc32_0_398c9b77.svg" width="25" height="17" style="width: 25px; height: 17px; vertical-align: middle;"> 与已知电阻 <img src="https://hx.cnzhyx.cn/K12LatexToSvg/ProductionV2/LatexSvg/2026-08-27/5a406b64eafd47f3a8686bba4741c29a_1_b71473bc.svg" width="26" height="17" style="width: 26px; height: 17px; vertical-align: middle;"> 串联组成一条支路,将电流表 <img src="https://hx.cnzhyx.cn/K12LatexToSvg/ProductionV2/LatexSvg/2026-08-27/167759d79f984e45a0d48c31a3c6566e_2_fb3fd646.svg" width="26" height="17" style="width: 26px; height: 17px; vertical-align: middle;"> 与待测铂电阻 <img src="https://hx.cnzhyx.cn/K12LatexToSvg/ProductionV2/LatexSvg/2026-08-27/f769c572150d4ce782daa8935a9ceb83_3_b1a4eeff.svg" width="23" height="16" style="width: 23px; height: 16px; vertical-align: middle;"> 串联组成另一条支路,两支路并联。忽略两电流表内阻,<img src="https://hx.cnzhyx.cn/K12LatexToSvg/ProductionV2/LatexSvg/2026-08-27/d37447ecfe6440d1bd97e900cacf3b64_4_398c9b77.svg" width="25" height="17" style="width: 25px; height: 17px; vertical-align: middle;">、<img src="https://hx.cnzhyx.cn/K12LatexToSvg/ProductionV2/LatexSvg/2026-08-27/8a5d7731a3b744d193a514eeb253ca92_5_fb3fd646.svg" width="26" height="17" style="width: 26px; height: 17px; vertical-align: middle;"> 的示数分别为 <img src="https://hx.cnzhyx.cn/K12LatexToSvg/ProductionV2/LatexSvg/2026-08-27/9265c18c28934f61bc64bef449f1e641_6_6c3f86e2.svg" width="19" height="16" style="width: 19px; height: 16px; vertical-align: middle;">、<img src="https://hx.cnzhyx.cn/K12LatexToSvg/ProductionV2/LatexSvg/2026-08-27/b0fdd5ecc5a0477eb7a245d2c0fcf041_7_93189365.svg" width="20" height="17" style="width: 20px; height: 17px; vertical-align: middle;">。铂电阻阻值 <img src="https://hx.cnzhyx.cn/K12LatexToSvg/ProductionV2/LatexSvg/2026-08-27/340415fadcf24d87a7ed79eb81a64c30_8_b1a4eeff.svg" width="23" height="16" style="width: 23px; height: 16px; vertical-align: middle;"> 的表达式为</p><p><br><img src="https://hx.cnzhyx.cn/K12/Question/png/2026-08-24/85cb7f0925a841c6b894ce351678be4f.png" class="preview-content" style="max-width:100%;"><br></p></div><table class="hxdb-option-table" name="hxdbOptionTable" style="width: 100%; table-layout: fixed; border-collapse: collapse;"><tbody><tr class="hxdb-option-table-tr firstRow" name="hxdbOptionTableTr"><td class="hxdb-option-table-td" name="hxdbOptionTableTd" valign="top" style="word-break: break-all;"><span class="hxdb-option-label">A.</span> <img src="https://hx.cnzhyx.cn/K12LatexToSvg/ProductionV2/LatexSvg/2026-08-27/3094744dbf1644bb80aceae639e61c6a_9_7f8b9fc5.svg" width="92" height="40" style="width: 92px; height: 40px; vertical-align: middle;"></td></tr><tr class="hxdb-option-table-tr firstRow" name="hxdbOptionTableTr"><td class="hxdb-option-table-td" name="hxdbOptionTableTd" valign="top" style="word-break: break-all;"><span class="hxdb-option-label">B.</span> <img src="https://hx.cnzhyx.cn/K12LatexToSvg/ProductionV2/LatexSvg/2026-08-27/96acf8a47eda4cdf81ef01707a28abff_10_fa759021.svg" width="92" height="40" style="width: 92px; height: 40px; vertical-align: middle;"></td></tr><tr class="hxdb-option-table-tr firstRow" name="hxdbOptionTableTr"><td class="hxdb-option-table-td" name="hxdbOptionTableTd" valign="top" style="word-break: break-all;"><span class="hxdb-option-label">C.</span> <img src="https://hx.cnzhyx.cn/K12LatexToSvg/ProductionV2/LatexSvg/2026-08-27/aecc386b27d341e393bbc45ac86e9e20_11_7cd89d59.svg" width="144" height="42" style="width: 144px; height: 42px; vertical-align: middle;"></td></tr><tr class="hxdb-option-table-tr firstRow" name="hxdbOptionTableTr"><td class="hxdb-option-table-td" name="hxdbOptionTableTd" valign="top" style="word-break: break-all;"><span class="hxdb-option-label">D.</span> <img src="https://hx.cnzhyx.cn/K12LatexToSvg/ProductionV2/LatexSvg/2026-08-27/6c8a4a6004f04529b9d6b48b4d7560c3_12_1f4bbe90.svg" width="144" height="42" style="width: 144px; height: 42px; vertical-align: middle;"></td></tr></tbody></table></div>',
|
||
// );
|
||
// }).toList();
|
||
// _pageList = List<HandoutItem>.from(resp.items).map((item) {
|
||
// return item.copyWith(
|
||
// isUploadNotes: true,
|
||
// isFinishPlanTime: false,
|
||
// planStudyTimeSecond: 900,
|
||
// studyTimeSecond: 0,
|
||
// );
|
||
// }).toList();
|
||
// 测试:复制一份追加到数组末尾(翻倍页数)
|
||
// _pageList.addAll(_pageList.map((item) => item.copyWith()).toList());
|
||
|
||
// 汇总已有学习时长
|
||
if (_mode == BeforeDuringMode.edit) {
|
||
final totalSeconds = _pageList.fold<int>(
|
||
0,
|
||
(acc, item) => acc + item.studyTimeSecond,
|
||
);
|
||
_timingOffset = Duration(seconds: totalSeconds);
|
||
}
|
||
|
||
if (_pageList.isEmpty) {
|
||
_goTransitionPage();
|
||
return;
|
||
}
|
||
|
||
_pageStartTime = DateTime.now();
|
||
_startCountdownForCurrentPage();
|
||
} catch (e) {
|
||
debugPrint('[BeforeDuringClass] 加载失败: $e');
|
||
if (mounted) {
|
||
CenterToast.show(context, '加载失败,请重试');
|
||
}
|
||
} finally {
|
||
if (mounted) setState(() => _loading = false);
|
||
}
|
||
}
|
||
|
||
// ===================== 计时工具 =====================
|
||
|
||
String get _timingText => _formatDuration(_timingElapsed);
|
||
|
||
String _formatDuration(Duration d) {
|
||
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
|
||
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
|
||
if (d.inHours > 0) {
|
||
final h = d.inHours.toString().padLeft(2, '0');
|
||
return '$h:$m:$s';
|
||
}
|
||
return '$m:$s';
|
||
}
|
||
|
||
// ===================== 倒计时 =====================
|
||
|
||
void _startCountdownForCurrentPage() {
|
||
if (_mode == BeforeDuringMode.view) return;
|
||
final page = _currentPage;
|
||
if (page == null) return;
|
||
if (page.planStudyTimeSecond <= 0 || page.isFinishPlanTime) {
|
||
_countdownRemaining = Duration.zero;
|
||
return;
|
||
}
|
||
final remain = page.planStudyRemainTimeSecond > 0
|
||
? page.planStudyRemainTimeSecond
|
||
: page.planStudyTimeSecond - page.studyTimeSecond;
|
||
if (remain <= 0) {
|
||
_markCurrentPagePlanTimeFinished();
|
||
_countdownRemaining = Duration.zero;
|
||
return;
|
||
}
|
||
_countdownRemaining = Duration(seconds: remain);
|
||
_lastCountdownTickSecond = 0;
|
||
if (!_countdownTicker.isActive) {
|
||
_countdownTicker.start();
|
||
}
|
||
}
|
||
|
||
void _markCurrentPagePlanTimeFinished() {
|
||
if (_currentIndex >= _pageList.length) return;
|
||
final page = _pageList[_currentIndex];
|
||
if (page.isFinishPlanTime) return;
|
||
_pageList[_currentIndex] = page.copyWith(
|
||
isFinishPlanTime: true,
|
||
planStudyRemainTimeSecond: 0,
|
||
);
|
||
setState(() {});
|
||
}
|
||
|
||
void _saveCurrentPageRemainTime() {
|
||
if (_mode == BeforeDuringMode.view) return;
|
||
if (_currentIndex >= _pageList.length) return;
|
||
final page = _pageList[_currentIndex];
|
||
if (page.planStudyTimeSecond <= 0 || page.isFinishPlanTime) return;
|
||
final remain = _countdownRemaining.inSeconds;
|
||
if (remain > 0 && remain <= page.planStudyTimeSecond) {
|
||
_pageList[_currentIndex] =
|
||
page.copyWith(planStudyRemainTimeSecond: remain);
|
||
}
|
||
}
|
||
|
||
String get _countdownText {
|
||
final d = _countdownRemaining;
|
||
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
|
||
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
|
||
return '$m:$s';
|
||
}
|
||
|
||
// ===================== 计算属性 =====================
|
||
|
||
int get _totalCount => _pageList.length;
|
||
int get _currentLabel => _totalCount == 0 ? 0 : _currentIndex + 1;
|
||
double get _percentage => _totalCount == 0 ? 0 : _currentLabel / _totalCount;
|
||
|
||
HandoutItem? get _currentPage =>
|
||
_currentIndex < _pageList.length ? _pageList[_currentIndex] : null;
|
||
|
||
String get _title {
|
||
if (_mode == BeforeDuringMode.view) {
|
||
return _isWrong ? '错题解析' : '全部解析';
|
||
}
|
||
return _currentPage?.title ?? '';
|
||
}
|
||
|
||
/// 最低时长是否未完成
|
||
bool get _isTimeDisabled {
|
||
if (_mode == BeforeDuringMode.view) return false;
|
||
final p = _currentPage;
|
||
if (p == null) return false;
|
||
if (p.planStudyTimeSecond > 0 && !p.isFinishPlanTime) return true;
|
||
return false;
|
||
}
|
||
|
||
/// 是否必须上传笔记但还没传
|
||
bool get _isNoteDisabled {
|
||
if (_mode == BeforeDuringMode.view) return false;
|
||
final p = _currentPage;
|
||
if (p == null) return false;
|
||
if (p.isUploadNotes && p.notes.isEmpty) return true;
|
||
return false;
|
||
}
|
||
|
||
/// 滑动到下一页是否被拦截(时长不足 或 笔记必传未传,都不能滑)
|
||
bool get _isSwipeNextBlocked {
|
||
if (_mode == BeforeDuringMode.view) return false;
|
||
return _isTimeDisabled || _isNoteDisabled;
|
||
}
|
||
|
||
/// 倒计时气泡是否显示
|
||
bool get _showTimeBubble {
|
||
if (_mode == BeforeDuringMode.view) return false;
|
||
final p = _currentPage;
|
||
if (p == null) return false;
|
||
if (p.planStudyTimeSecond <= 0 || p.isFinishPlanTime) return false;
|
||
return _countdownRemaining.inSeconds > 0;
|
||
}
|
||
|
||
// ===================== 页面切换 =====================
|
||
|
||
void _onPageChanged(int index) {
|
||
if (index == _currentIndex) return;
|
||
|
||
_saveCurrentPageRemainTime();
|
||
_pausePageMedia(_currentIndex);
|
||
_renewHandoutTimeForIndex(_currentIndex);
|
||
|
||
setState(() {
|
||
_currentIndex = index;
|
||
});
|
||
|
||
_pageStartTime = DateTime.now();
|
||
_startCountdownForCurrentPage();
|
||
}
|
||
|
||
/// 手势滑动翻页(自己控制,不受 PageView physics 影响)
|
||
/// - 向右滑(primaryVelocity > 0)→ 上一页,始终允许
|
||
/// - 向左滑(primaryVelocity < 0)→ 下一页,需满足时长 + 笔记条件
|
||
void _onHorizontalDragEnd(DragEndDetails details) {
|
||
final velocity = details.primaryVelocity ?? 0;
|
||
const kMinVelocity = 200.0; // 最小滑动速度阈值
|
||
|
||
if (velocity.abs() < kMinVelocity) return; // 滑动速度不够,忽略
|
||
|
||
if (velocity > 0) {
|
||
// 向右滑 → 上一页
|
||
if (_currentIndex > 0) {
|
||
_pageController.animateToPage(
|
||
_currentIndex - 1,
|
||
duration: const Duration(milliseconds: 300),
|
||
curve: Curves.easeOut,
|
||
);
|
||
}
|
||
} else {
|
||
// 向左滑 → 下一页(时长不足或笔记必传未传,都不能滑)
|
||
if (_currentIndex < _pageList.length - 1 && !_isSwipeNextBlocked) {
|
||
_pageController.animateToPage(
|
||
_currentIndex + 1,
|
||
duration: const Duration(milliseconds: 300),
|
||
curve: Curves.easeOut,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
Future<void> _renewHandoutTimeForIndex(int index) async {
|
||
if (_mode == BeforeDuringMode.view) return;
|
||
if (index >= _pageList.length) return;
|
||
final page = _pageList[index];
|
||
if (page.id.isEmpty) return;
|
||
try {
|
||
await BeforeDuringClassApi.instance.renewHandoutTime(
|
||
id: page.id,
|
||
courseId: _courseId,
|
||
startTime: _pageStartTime.toIso8601String(),
|
||
endTime: DateTime.now().toIso8601String(),
|
||
);
|
||
} catch (e) {
|
||
debugPrint('[BeforeDuringClass] RenewTime 失败: $e');
|
||
}
|
||
}
|
||
|
||
void _pausePageMedia(int index) {
|
||
_pageKeys[index]?.currentState?.pauseMedia();
|
||
}
|
||
|
||
// ===================== 底部按钮操作 =====================
|
||
|
||
void _onUploadNotes() {
|
||
_pageKeys[_currentIndex]?.currentState?.uploadLectureNotes();
|
||
}
|
||
|
||
Future<void> _onPrevious() async {
|
||
if (_currentIndex == 0) return;
|
||
_saveCurrentPageRemainTime();
|
||
_pausePageMedia(_currentIndex);
|
||
_pageController.previousPage(
|
||
duration: const Duration(milliseconds: 300),
|
||
curve: Curves.easeInOut,
|
||
);
|
||
}
|
||
|
||
Future<void> _onNext() async {
|
||
// 上传中禁止跳转
|
||
final currentState = _pageKeys[_currentIndex]?.currentState;
|
||
if (currentState != null && currentState.getIsUploading()) {
|
||
CenterToast.show(context, '图片上传中,请稍后再试');
|
||
return;
|
||
}
|
||
|
||
// 时长不足(可跳过)
|
||
if (_isTimeDisabled) {
|
||
final timeText = _formatDuration(
|
||
Duration(seconds: _currentPage?.planStudyTimeSecond ?? 0),
|
||
);
|
||
final ok = await showConfirmBox(
|
||
context,
|
||
title: '建议学习时长 $timeText',
|
||
content: '仓促速学掌握不牢,之后还要重新复习\n本次攻克成功,学习时长会逐步减少',
|
||
cancelText: '继续学习',
|
||
confirmText: '跳过',
|
||
);
|
||
if (ok != true) return;
|
||
}
|
||
|
||
// 笔记必传(不可跳过,放在时长之后,点跳过时长后仍会检查)
|
||
if (_isNoteDisabled) {
|
||
final ok = await showConfirmBox(
|
||
context,
|
||
title: '该讲义必须上传笔记',
|
||
content: '上传笔记,有助于更好记录课程内容,提升学习效率',
|
||
cancelText: '取消',
|
||
confirmText: '上传笔记',
|
||
);
|
||
if (ok == true) {
|
||
_onUploadNotes();
|
||
}
|
||
return;
|
||
}
|
||
|
||
// 最后一页 → 完成学习
|
||
if (_currentIndex == _pageList.length - 1) {
|
||
if (_mode == BeforeDuringMode.edit) {
|
||
await _finishAll();
|
||
}
|
||
return;
|
||
}
|
||
|
||
_saveCurrentPageRemainTime();
|
||
_pausePageMedia(_currentIndex);
|
||
_pageController.nextPage(
|
||
duration: const Duration(milliseconds: 300),
|
||
curve: Curves.easeInOut,
|
||
);
|
||
}
|
||
|
||
Future<void> _finishAll() async {
|
||
_isNavigatingAway = true;
|
||
_saveCurrentPageRemainTime();
|
||
_pausePageMedia(_currentIndex);
|
||
await _renewHandoutTimeForIndex(_currentIndex);
|
||
if (mounted) {
|
||
_goTransitionPage();
|
||
}
|
||
}
|
||
|
||
void _goTransitionPage() {
|
||
// 判断下一个状态:课前(1)→课中(2)→课后(3)
|
||
// 此处简化:完成学习后默认进入课后练习过渡页(status=3)
|
||
context.push(
|
||
'/conquerPackage/transitionPage',
|
||
extra: {
|
||
'courseId': _courseId,
|
||
'packId': _packId,
|
||
'recordId': _recordId,
|
||
'status': '3',
|
||
},
|
||
);
|
||
}
|
||
|
||
// ===================== 笔记变化 =====================
|
||
|
||
void _onUploadChanged(List<UploadItem> list) {
|
||
if (_currentIndex >= _pageList.length) return;
|
||
final page = _pageList[_currentIndex];
|
||
_pageList[_currentIndex] = page.copyWith(
|
||
notes: list
|
||
.where((item) => item.id != null)
|
||
.map((item) => NoteImage(
|
||
id: item.id,
|
||
url: item.url,
|
||
thumbnailUrl: item.thumbnailUrl,
|
||
))
|
||
.toList(),
|
||
);
|
||
setState(() {});
|
||
}
|
||
|
||
// ===================== 返回 =====================
|
||
|
||
Future<bool> _onWillPop() async {
|
||
if (_mode == BeforeDuringMode.edit && !_isNavigatingAway) {
|
||
_saveCurrentPageRemainTime();
|
||
await _renewHandoutTimeForIndex(_currentIndex);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// ===================== Build =====================
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return PopScope(
|
||
canPop: true,
|
||
onPopInvoked: (didPop) async {
|
||
if (didPop) return;
|
||
await _onWillPop();
|
||
},
|
||
child: Scaffold(
|
||
backgroundColor: _bgColor,
|
||
body: _loading
|
||
? const Center(child: CircularProgressIndicator())
|
||
: _pageList.isEmpty
|
||
? const Center(child: Text('暂无讲义'))
|
||
: Stack(
|
||
children: [
|
||
// 顶部背景图
|
||
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(
|
||
child: Column(
|
||
children: [
|
||
// 导航栏
|
||
_buildNavBar(),
|
||
// 计时 + 进度
|
||
_buildHeader(),
|
||
// 主体(外层手势识别,自己控制左右滑动)
|
||
Expanded(
|
||
child: GestureDetector(
|
||
onHorizontalDragEnd: _onHorizontalDragEnd,
|
||
behavior: HitTestBehavior.translucent,
|
||
child: PageView.builder(
|
||
controller: _pageController,
|
||
itemCount: _pageList.length,
|
||
onPageChanged: _onPageChanged,
|
||
physics: const NeverScrollableScrollPhysics(),
|
||
allowImplicitScrolling: true,
|
||
itemBuilder: (context, index) {
|
||
_pageKeys[index] ??=
|
||
GlobalKey<BeforeDuringPageState>();
|
||
return BeforeDuringPage(
|
||
key: _pageKeys[index],
|
||
data: _pageList[index],
|
||
isCurrent: index == _currentIndex,
|
||
onUploadChanged: _onUploadChanged,
|
||
);
|
||
},
|
||
),
|
||
),
|
||
),
|
||
// 底部操作栏
|
||
_buildFooter(),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ===================== NavBar =====================
|
||
|
||
Widget _buildNavBar() {
|
||
return SizedBox(
|
||
height: 44.h,
|
||
child: Stack(
|
||
children: [
|
||
Center(
|
||
child: Text(
|
||
_title,
|
||
style: const TextStyle(
|
||
color: Color.fromRGBO(0, 0, 0, 0.8),
|
||
fontSize: 18,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
),
|
||
Positioned(
|
||
left: 16.w,
|
||
top: 0,
|
||
bottom: 0,
|
||
child: IconButton(
|
||
icon: const Icon(Icons.arrow_back_ios,
|
||
color: Color.fromRGBO(0, 0, 0, 0.8), size: 20),
|
||
onPressed: () async {
|
||
await _onWillPop();
|
||
if (mounted) Navigator.of(context).maybePop();
|
||
},
|
||
padding: EdgeInsets.zero,
|
||
constraints: const BoxConstraints(),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
// ===================== Header(计时 + 进度) =====================
|
||
|
||
Widget _buildHeader() {
|
||
return Padding(
|
||
padding: EdgeInsets.fromLTRB(14.w, 4.h, 14.w, 10.h),
|
||
child: Row(
|
||
children: [
|
||
// 左侧:计时
|
||
_buildTiming(),
|
||
const Spacer(),
|
||
// 右侧:进度条 + 页码
|
||
_buildProgress(),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildTiming() {
|
||
if (_mode == BeforeDuringMode.view) {
|
||
return const SizedBox.shrink();
|
||
}
|
||
return Container(
|
||
padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 6.h),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.circular(30.r),
|
||
),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
CachedNetworkImage(
|
||
imageUrl: ImgUrl.build('/StaticResource/2026-04-02/timing.png'),
|
||
width: 18.w,
|
||
height: 18.w,
|
||
errorWidget: (_, __, ___) =>
|
||
Icon(Icons.timer, size: 18.w, color: _primary),
|
||
),
|
||
SizedBox(width: 5.w),
|
||
Text(
|
||
_timingText,
|
||
style: TextStyle(
|
||
fontSize: 13.sp,
|
||
color: _titleColor,
|
||
fontWeight: FontWeight.w500,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildProgress() {
|
||
return Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
SizedBox(
|
||
width: 150.w,
|
||
child: ClipRRect(
|
||
borderRadius: BorderRadius.circular(4.r),
|
||
child: LinearProgressIndicator(
|
||
value: _percentage,
|
||
minHeight: 4,
|
||
backgroundColor: Colors.white,
|
||
valueColor: const AlwaysStoppedAnimation(_primary),
|
||
),
|
||
),
|
||
),
|
||
SizedBox(width: 10.w),
|
||
RichText(
|
||
text: TextSpan(
|
||
children: [
|
||
TextSpan(
|
||
text: '$_currentLabel',
|
||
style: TextStyle(
|
||
fontSize: 15.sp,
|
||
fontWeight: FontWeight.w600,
|
||
color: _titleColor,
|
||
),
|
||
),
|
||
TextSpan(
|
||
text: '/$_totalCount',
|
||
style: TextStyle(
|
||
fontSize: 12.sp,
|
||
color: _subtitleColor,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
// ===================== Footer =====================
|
||
|
||
Widget _buildFooter() {
|
||
return SafeArea(
|
||
top: false,
|
||
child: Container(
|
||
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 10.h),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
border: Border(
|
||
top: BorderSide(color: const Color(0xFFEEEEEE), width: 1.w),
|
||
),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
// 上传笔记按钮
|
||
if (_mode == BeforeDuringMode.edit) _buildUploadNoteButton(),
|
||
if (_mode == BeforeDuringMode.edit) SizedBox(width: 10.w),
|
||
// 上一步
|
||
Expanded(
|
||
child: _buildStepButton(
|
||
text: '上一步',
|
||
disabled: _currentIndex == 0,
|
||
onTap: _onPrevious,
|
||
isPrimary: false,
|
||
),
|
||
),
|
||
SizedBox(width: 10.w),
|
||
// 下一步
|
||
Expanded(
|
||
child: _buildNextButton(),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildUploadNoteButton() {
|
||
return GestureDetector(
|
||
onTap: _onUploadNotes,
|
||
behavior: HitTestBehavior.opaque,
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
CachedNetworkImage(
|
||
imageUrl: ImgUrl.build('/StaticResource/2026-04-02/note.png'),
|
||
width: 22.w,
|
||
height: 22.w,
|
||
errorWidget: (_, __, ___) => Icon(
|
||
Icons.note_add_outlined,
|
||
size: 22.w,
|
||
color: _primary,
|
||
),
|
||
),
|
||
SizedBox(height: 3.h),
|
||
Text(
|
||
'上传笔记',
|
||
style: TextStyle(
|
||
fontSize: 10.sp,
|
||
color: _titleColor,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildStepButton({
|
||
required String text,
|
||
required bool disabled,
|
||
required VoidCallback onTap,
|
||
required bool isPrimary,
|
||
}) {
|
||
final bgColor = isPrimary ? _primary : const Color(0xFFF0F2F5);
|
||
final textColor = isPrimary ? Colors.white : _titleColor;
|
||
|
||
return Opacity(
|
||
opacity: disabled ? 0.5 : 1.0,
|
||
child: FilledButton(
|
||
onPressed: disabled ? null : onTap,
|
||
style: FilledButton.styleFrom(
|
||
backgroundColor: bgColor,
|
||
foregroundColor: textColor,
|
||
disabledBackgroundColor: bgColor,
|
||
disabledForegroundColor: textColor,
|
||
minimumSize: Size.fromHeight(47.h),
|
||
padding: EdgeInsets.zero,
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(20.r),
|
||
),
|
||
textStyle: TextStyle(
|
||
fontSize: 13.sp,
|
||
fontWeight: FontWeight.w500,
|
||
),
|
||
),
|
||
child: Text(text),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildNextButton() {
|
||
final isLast = _currentIndex == _pageList.length - 1;
|
||
final text = isLast && _mode == BeforeDuringMode.edit
|
||
? '完成学习'
|
||
: '下一步';
|
||
// 只有 view 模式最后一页才禁用;edit 模式永远可点(点了弹引导弹窗)
|
||
final disabled = _mode == BeforeDuringMode.view && isLast;
|
||
|
||
return Stack(
|
||
clipBehavior: Clip.none,
|
||
children: [
|
||
_buildStepButton(
|
||
text: text,
|
||
disabled: disabled,
|
||
onTap: _onNext,
|
||
isPrimary: true,
|
||
),
|
||
if (_showTimeBubble)
|
||
Positioned(
|
||
right: 4.w,
|
||
top: -12.h,
|
||
child: Container(
|
||
height: 20.h,
|
||
padding: EdgeInsets.fromLTRB(8.w, 0, 8.w, 3.h),
|
||
alignment: Alignment.center,
|
||
decoration: BoxDecoration(
|
||
image: DecorationImage(
|
||
image: NetworkImage(
|
||
ImgUrl.build('/StaticResource/2026-04-17/bubble.png'),
|
||
),
|
||
fit: BoxFit.fill,
|
||
),
|
||
),
|
||
child: Transform.translate(
|
||
offset: Offset(0, -1.h),
|
||
child: Text(
|
||
_countdownText,
|
||
style: TextStyle(
|
||
color: Colors.white,
|
||
fontSize: 11.sp,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|