feat(conquerPackage, upload): 新增课前课中页面与多图上传组件,修复路由配置

- 实现攻克包模块的课前课中学习页面,包含学习计时、笔记上传、分页切换等完整逻辑
- 新增通用多图上传组件,支持编辑/查看两种业务模式
- 配置新页面的路由规则,并更新捕获列表页面的跳转逻辑为正式的go_router跳转
- 修复捕获列表页面跳转路径不匹配的问题
This commit is contained in:
chenshuainan
2026-09-11 09:37:37 +08:00
parent ee14373c6d
commit fe060ac974
11 changed files with 2230 additions and 20 deletions
@@ -0,0 +1,273 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.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/audio_player_card.dart';
import 'package:new_learn/core/components/rich_text_content.dart';
import 'package:new_learn/core/components/upload.dart';
import 'package:new_learn/core/components/video_player_view.dart';
/// ============================================================
/// 【BeforeDuringPage】单页讲义内容组件
///
/// 对标 Vue pages/conquerPackage/beforeDuringClass/beforeDuringPage.vue
///
/// 一、功能:
/// 1. 显示讲义标题(name)
/// 2. 渲染正文富文本(content → RichTextContent)
/// 3. 渲染材料列表(materials):
/// materialType=1 视频 → VideoPlayerView
/// materialType=2 音频 → AudioPlayerCard
/// materialType=3 文本 → RichTextContent
/// 4. 底部 Upload 上传笔记组件(type=handouts)
/// 5. 对外暴露方法:
/// uploadLectureNotes() 触发选图上传
/// getImageList() 获取当前笔记列表
/// getIsUploading() 是否上传中/选图中
/// pauseMedia() 暂停页面内所有媒体
///
/// 二、尺寸换算(本工程统一):
/// Vue 的 N rpx → Flutter 的 (N/2).w/.h/.sp/.r
///
/// 三、与 Vue 的差异:
/// - Flutter 端不维护 ref 数组来控制媒体暂停,
/// 滑出视口时 dispose 自动释放资源(PageView.builder 懒加载特性)。
/// - pauseMedia 方法保留为对外接口,通过 GlobalKey 调用时
/// 遍历可见子树查找播放器组件来暂停(兼容手动触发场景)。
/// ============================================================
class BeforeDuringPage extends StatefulWidget {
const BeforeDuringPage({
super.key,
required this.data,
this.isCurrent = false,
this.onUploadChanged,
});
/// 讲义数据
final HandoutItem data;
/// 是否为当前可见页(只有当前页才真正初始化视频解码器,避免硬件解码器占满)
final bool isCurrent;
/// 笔记列表变化回调
final ValueChanged<List<UploadItem>>? onUploadChanged;
@override
State<BeforeDuringPage> createState() => BeforeDuringPageState();
}
class BeforeDuringPageState extends State<BeforeDuringPage>
with AutomaticKeepAliveClientMixin {
final GlobalKey<UploadState> _uploadKey = GlobalKey<UploadState>();
@override
bool get wantKeepAlive => true;
// ===================== 对外暴露方法 =====================
/// 触发上传笔记
void uploadLectureNotes() {
_uploadKey.currentState?.chooseImage();
}
/// 获取当前笔记列表
List<UploadItem> getImageList() {
return _uploadKey.currentState?.getImageList() ?? const [];
}
/// 是否上传中或选图中
bool getIsUploading() {
final state = _uploadKey.currentState;
if (state == null) return false;
return state.uploadingCount > 0 || state.choosing;
}
/// 暂停页面内所有媒体(视频/音频)
void pauseMedia() {
context.visitChildElements((element) {
_tryPauseMedia(element);
});
}
void _tryPauseMedia(Element element) {
// 通过 GlobalKey 形式找到 State 并调用 pause()
if (element is StatefulElement) {
final state = element.state;
if (state is VideoPlayerViewState) {
state.pause();
return;
}
if (state is AudioPlayerCardState) {
state.pause();
return;
}
}
element.visitChildren(_tryPauseMedia);
}
// ===================== Build =====================
@override
Widget build(BuildContext context) {
super.build(context);
return SingleChildScrollView(
padding: EdgeInsets.only(
left: 14.w,
right: 14.w,
top: 4.h,
bottom: 90.h,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 标题
if (widget.data.name.isNotEmpty) _buildTitle(),
SizedBox(height: 10.h),
// 正文富文本
if (widget.data.content.isNotEmpty) _buildContent(),
SizedBox(height: 10.h),
// 材料列表
if (widget.data.materials.isNotEmpty) _buildMaterials(),
// 上传笔记
_buildUpload(),
],
),
);
}
Widget _buildTitle() {
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: 3.w,
height: 14.h,
margin: EdgeInsets.only(right: 7.5.w),
decoration: BoxDecoration(
color: const Color(0xFF3D7DF5),
borderRadius: BorderRadius.circular(10.r),
),
),
Expanded(
child: Text(
widget.data.name,
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.bold,
color: const Color(0xFF1D2B47),
),
),
),
],
);
}
Widget _buildContent() {
return Container(
width: double.infinity,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16.r),
),
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 12.h),
child: RichTextContent(
html: widget.data.content,
renderMode: RenderMode.column,
textStyle: TextStyle(
fontSize: 14.sp,
color: const Color(0xFF495A7A),
height: 1.5,
),
),
),
);
}
Widget _buildMaterials() {
return Column(
children: [
for (final material in widget.data.materials)
Padding(
padding: EdgeInsets.only(bottom: 10.h),
child: _buildMaterialItem(material),
),
],
);
}
Widget _buildMaterialItem(HandoutMaterial material) {
switch (material.materialType) {
case HandoutMaterialType.video:
// 只有当前页才真正初始化视频解码器,避免硬件解码器占满导致 ExoPlaybackException
if (!widget.isCurrent) {
return _buildVideoPlaceholder(material);
}
return VideoPlayerView(
url: material.materialContent,
title: '讲义视频',
autoPlay: false,
);
case HandoutMaterialType.audio:
return AudioPlayerCard(
url: material.materialContent,
title: '讲义音频',
);
case HandoutMaterialType.text:
return Container(
width: double.infinity,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16.r),
),
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 12.h),
child: RichTextContent(
html: material.materialContent,
renderMode: RenderMode.column,
textStyle: TextStyle(
fontSize: 14.sp,
color: const Color(0xFF495A7A),
height: 1.5,
),
),
),
);
case HandoutMaterialType.unknown:
return const SizedBox.shrink();
}
}
/// 视频占位图(非当前页展示,不占用硬件解码器)
Widget _buildVideoPlaceholder(HandoutMaterial material) {
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: AspectRatio(
aspectRatio: 16 / 9,
child: Container(
color: Colors.black,
alignment: Alignment.center,
child: Icon(
Icons.play_circle_outline,
size: 48.w,
color: Colors.white54,
),
),
),
);
}
Widget _buildUpload() {
return Upload(
key: _uploadKey,
type: UploadType.handouts,
ids: widget.data.id,
initialImageList: widget.data.notes
.map((note) => UploadItem.fromNoteImage(note))
.toList(),
onChanged: widget.onUploadChanged,
);
}
}
@@ -1,18 +1,870 @@
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: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(
// 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() {
// TODO: 跳转到 transition 页,等路由配置好后替换
Navigator.of(context).pop();
}
// ===================== 笔记变化 =====================
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 =====================
class CaptureListPage extends StatelessWidget {
const CaptureListPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Align 对齐布局 Demo11111')),
// ---------- 下方即为核心代码,可直接复制到 Scaffold body ----------
body: Container(
width: 300.w,
height: 300.h,
color: Colors.grey[300],
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,
),
),
),
),
),
],
);
}
}
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:go_router/go_router.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:new_learn/core/api/conquerPackage/captureList/api.dart';
import 'package:new_learn/core/api/conquerPackage/captureList/model.dart';
@@ -220,7 +221,7 @@ class _CaptureListPageState extends State<CaptureListPage> {
// 跳转分支(对标 Vue navigateToCaptureInfo)
if (res.hasHandout) {
// 有讲义 → 课前课中页
_navigateTo('/conquer/beforeDuringClass', {
_navigateTo('/conquerPackage/beforeDuringClass', {
'status': '1',
'packId': item.id,
'courseId': widget.courseId,
@@ -261,13 +262,14 @@ class _CaptureListPageState extends State<CaptureListPage> {
}
}
/// 页面跳转(占位 —— 待路由统一后替换为正式路由名)
/// 页面跳转(go_router push + extra 传参)
void _navigateTo(String path, Map<String, String> params) {
// TODO(路由): 替换为项目统一的路由跳转方式
debugPrint('navigateTo: $path $params');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('跳转 $path ${params.toString()}'), behavior: SnackBarBehavior.floating),
);
// TODO(路由): 替换为项目统一的路由跳转方式
// debugPrint('navigateTo: $path $params');
// ScaffoldMessenger.of(context).showSnackBar(
// SnackBar(content: Text('跳转 $path ${params.toString()}'), behavior: SnackBarBehavior.floating),
// );
context.push(path, extra: params);
}
/// 点击报告(跳转报告页)