feat(conquerPackage, upload): 新增课前课中页面与多图上传组件,修复路由配置
- 实现攻克包模块的课前课中学习页面,包含学习计时、笔记上传、分页切换等完整逻辑 - 新增通用多图上传组件,支持编辑/查看两种业务模式 - 配置新页面的路由规则,并更新捕获列表页面的跳转逻辑为正式的go_router跳转 - 修复捕获列表页面跳转路径不匹配的问题
This commit is contained in:
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user