Files
chenshuainan 847986f861 feat: 新增过渡页面路由、上传组件及完善跳转逻辑
- 为conquerPackage模块添加过渡页面路由并实现页面跳转逻辑
- 新增通用多图上传组件Upload,支持多种模式与图片操作功能
- 新增任务相关的API及Model基础文件
- 注释课前课中页面的测试试题内容
2026-09-14 09:09:25 +08:00

888 lines
32 KiB
Dart
Raw Permalink 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.
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,
),
),
),
),
),
],
);
}
}