flutter_integrated_whb/lib/pages/home/study/study_detail_page.dart

782 lines
24 KiB
Dart
Raw Normal View History

2025-07-17 16:10:46 +08:00
import 'dart:async';
2025-08-29 20:33:23 +08:00
import 'dart:math';
import 'dart:ui';
2025-07-16 08:37:08 +08:00
import 'package:flutter/material.dart';
2025-08-29 09:52:48 +08:00
import 'package:qhd_prevention/customWidget/remote_file_page.dart';
2025-07-18 17:13:38 +08:00
import 'package:qhd_prevention/pages/home/study/take_exam_page.dart';
import 'package:qhd_prevention/pages/home/study/study_practise_page.dart';
import 'package:qhd_prevention/pages/my_appbar.dart';
2025-07-17 16:10:46 +08:00
import 'package:qhd_prevention/customWidget/custom_alert_dialog.dart';
import 'package:qhd_prevention/customWidget/custom_button.dart';
import 'package:qhd_prevention/http/ApiService.dart';
import 'package:qhd_prevention/tools/tools.dart';
2025-07-18 17:13:38 +08:00
import 'package:video_player/video_player.dart';
2025-08-29 20:33:23 +08:00
import 'package:wakelock_plus/wakelock_plus.dart';
2025-07-16 08:37:08 +08:00
2025-07-22 13:34:34 +08:00
import '../../../customWidget/toast_util.dart';
2025-07-18 17:13:38 +08:00
import '../../../customWidget/video_player_widget.dart';
2025-07-17 16:10:46 +08:00
import 'face_ecognition_page.dart';
2025-07-16 08:37:08 +08:00
2025-08-29 09:52:48 +08:00
enum TakeExamType { video_study, strengththen, list }
2025-07-22 13:34:34 +08:00
2025-07-16 08:37:08 +08:00
class StudyDetailPage extends StatefulWidget {
2025-07-17 16:10:46 +08:00
final Map studyDetailDetail;
final String studentId;
2025-08-29 09:52:48 +08:00
2025-07-17 16:10:46 +08:00
const StudyDetailPage(this.studyDetailDetail, this.studentId, {super.key});
2025-07-16 08:37:08 +08:00
@override
State<StudyDetailPage> createState() => _StudyDetailPageState();
}
2025-07-17 16:10:46 +08:00
class _StudyDetailPageState extends State<StudyDetailPage>
with SingleTickerProviderStateMixin {
late TabController _tabController;
Map<String, dynamic>? _info;
List<dynamic> _videoList = [];
bool _loading = true;
2025-08-29 20:33:23 +08:00
// player controller 提取出来
2025-07-18 17:13:38 +08:00
VideoPlayerController? _videoController;
String _videoCoverUrl = '';
2025-07-17 16:10:46 +08:00
Timer? _faceTimer;
late int _faceTime;
late String _classId;
late String _classCurriculumId;
Map<String, dynamic>? _currentVideoData;
int _currentFirstIndex = 0;
int _currentNodeIndex = 0;
bool _hasNodes = false;
Duration _lastReported = Duration.zero;
2025-08-29 20:33:23 +08:00
// 上报控制相关字段
bool _endReported = false; // 确保最终结束上报只执行一次
Future<void>? _ongoingSubmit; // 用于串行化提交请求
final int _uploadIntervalSeconds = 1; // 周期性上报间隔(秒)
2025-07-16 08:37:08 +08:00
@override
void initState() {
super.initState();
2025-08-29 20:33:23 +08:00
WakelockPlus.enable();
2025-07-17 16:10:46 +08:00
_classId = widget.studyDetailDetail['CLASS_ID'] ?? '';
2025-07-18 17:13:38 +08:00
_classCurriculumId = widget.studyDetailDetail['CLASSCURRICULUM_ID'] ?? '';
2025-07-17 16:10:46 +08:00
_tabController = TabController(length: 2, vsync: this);
2025-07-16 08:37:08 +08:00
WidgetsBinding.instance.addPostFrameCallback((_) {
2025-07-17 16:10:46 +08:00
_showFaceIntro();
_loadData();
});
}
@override
void dispose() {
_tabController.dispose();
2025-08-29 20:33:23 +08:00
WakelockPlus.disable();
// 最佳努力:让未完成的提交完成(非阻塞)
if (_ongoingSubmit != null) {
_ongoingSubmit!.whenComplete(() {});
}
try {
_videoController?.removeListener(_onTimeUpdate);
} catch (_) {}
try {
_videoController?.dispose();
} catch (_) {}
2025-08-29 09:52:48 +08:00
_videoController = null;
2025-08-29 20:33:23 +08:00
2025-07-17 16:10:46 +08:00
_faceTimer?.cancel();
super.dispose();
}
Future<void> _showFaceIntro() async {
2025-08-29 20:33:23 +08:00
final ok = await CustomAlertDialog.showConfirm(
context,
title: '温馨提示',
content: '重要提醒:尊敬的用户,根据规定我们会在您学习过程中多次进行人脸识别认证,为了保护您的隐私请您在摄像设备视野内确保衣冠整齐。',
cancelText: '取消',
2025-09-01 17:25:55 +08:00
confirmText: '同意并继续',
2025-07-17 16:10:46 +08:00
);
2025-08-29 20:33:23 +08:00
if (ok) {}
2025-07-17 16:10:46 +08:00
}
Future<void> _loadData() async {
try {
final res = await ApiService.getStudyDetailList(
_classId,
_classCurriculumId,
widget.studentId,
2025-07-16 08:37:08 +08:00
);
2025-07-17 16:10:46 +08:00
final pd = res['pd'] ?? {};
_faceTime = int.tryParse(pd['FACE_TIME']?.toString() ?? '10') ?? 10;
_videoList = List.from(pd['VIDEOLIST'] ?? []);
2025-07-18 17:13:38 +08:00
// set initial face time
2025-09-01 17:25:55 +08:00
2025-07-18 17:13:38 +08:00
// compute percent
2025-07-17 16:10:46 +08:00
for (var item in _videoList) {
if (item['nodes'] is List && (item['nodes'] as List).isNotEmpty) {
for (var node in item['nodes']) {
node['percent'] = _calcPercentStr(
playCount: node['PLAYCOUNT'],
resourceTime: node['RESOURCETIME'],
videoTime: node['VIDEOTIME'],
);
}
} else {
item['percent'] = _calcPercentStr(
playCount: item['PLAYCOUNT'],
resourceTime: item['RESOURCETIME'],
videoTime: item['VIDEOTIME'],
);
}
}
setState(() {
_info = pd;
_loading = false;
});
} catch (_) {
setState(() => _loading = false);
}
}
String _calcPercentStr({
required dynamic playCount,
required dynamic resourceTime,
required dynamic videoTime,
}) {
final seen = int.tryParse('$playCount') ?? 0;
if (seen > 0) return '100%';
final resT = double.tryParse('$resourceTime') ?? 0.0;
final vidT = double.tryParse('$videoTime') ?? 0.0;
if (vidT == 0) return '0%';
2025-08-29 20:33:23 +08:00
final pct = ((resT / vidT) * 100).clamp(0.0, 100.0).toStringAsFixed(0);
2025-07-17 16:10:46 +08:00
return '$pct%';
}
Future<void> _onVideoTap(
2025-09-01 17:25:55 +08:00
Map<String, dynamic> data,
bool hasNodes,
int fi,
int ni,
) async {
2025-08-29 20:33:23 +08:00
// 后端清除人脸计时
2025-07-18 17:13:38 +08:00
await ApiService.fnClearUserFaceTime();
2025-07-17 16:10:46 +08:00
_faceTimer?.cancel();
if (_currentVideoData != null && _lastReported > Duration.zero) {
await _submitPlayTime(end: false, seconds: _lastReported.inSeconds);
}
_lastReported = Duration.zero;
_currentVideoData = data;
_hasNodes = hasNodes;
_currentFirstIndex = fi;
_currentNodeIndex = ni;
2025-08-29 20:33:23 +08:00
// 暂停已有播放器
2025-07-17 16:10:46 +08:00
_videoController?.pause();
await _navigateFaceIfNeeded(() async {
if ((data['IS_VIDEO'] ?? 0) == 1) {
2025-08-29 20:33:23 +08:00
// 文档
2025-07-17 16:10:46 +08:00
if (data['VIDEOFILES'] != null) {
2025-08-29 09:52:48 +08:00
_videoController?.pause();
2025-07-17 16:10:46 +08:00
await pushPage(
2025-08-29 09:52:48 +08:00
RemoteFilePage(
fileUrl: ApiService.baseImgPath + data['VIDEOFILES'],
countdownSeconds: 10,
),
2025-07-17 16:10:46 +08:00
context,
);
2025-07-18 17:13:38 +08:00
await _submitPlayTime(
end: true,
seconds: int.parse(data['VIDEOTIME'] ?? '0'),
);
2025-07-17 16:10:46 +08:00
} else {
2025-08-29 09:52:48 +08:00
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('课件文件资源已失效,请联系管理员')));
2025-07-17 16:10:46 +08:00
}
} else {
2025-08-29 20:33:23 +08:00
// 视频
2025-07-17 16:10:46 +08:00
await _getVideoPlayInfo(data['VIDEOCOURSEWARE_ID']);
_startFaceTimer();
}
});
}
Future<void> _navigateFaceIfNeeded(FutureOr<void> Function() onPass) async {
if (_info?['ISFACE'] == '1') {
2025-09-01 17:25:55 +08:00
LoadingDialogHelper.show();
final resData = await ApiService.fnGetUserFace();
LoadingDialogHelper.hide();
final pd_data = resData['pd'];
if (FormUtils.hasValue(pd_data, 'USERAVATARURL')) {
final passed = await pushPage<bool>(
FaceRecognitionPage(studentId: widget.studentId, mode: FaceMode.auto),
context,
);
if (passed == true) {
await ApiService.fnSetUserFaceTime(_faceTime);
await onPass();
} else {
ToastUtil.showError(context, '人脸验证未通过,无法继续');
}
2025-07-17 16:10:46 +08:00
} else {
2025-09-01 17:25:55 +08:00
final ok = await CustomAlertDialog.showConfirm(
2025-08-29 09:52:48 +08:00
context,
2025-09-01 17:25:55 +08:00
title: '温馨提示',
content: '您当前还未进行人脸认证,请先进行认证',
cancelText: '取消',
);
if (ok) {
await pushPage(
const FaceRecognitionPage(studentId: '', mode: FaceMode.manual),
context,
);
}
2025-07-17 16:10:46 +08:00
}
} else {
await onPass();
}
}
Future<void> _getVideoPlayInfo(String vidId) async {
final res = await ApiService.fnGetVideoPlayInfo(vidId);
final url = res['videoList']?[0]?['playURL'] ?? '';
2025-07-18 17:13:38 +08:00
_videoCoverUrl = res['videoBase']?['coverURL'] ?? '';
2025-07-17 16:10:46 +08:00
final prog = await ApiService.fnGetVideoPlayProgress(
vidId,
_currentVideoData!['CURRICULUM_ID'],
_classId,
widget.studentId,
);
2025-07-18 17:13:38 +08:00
2025-07-22 13:34:34 +08:00
final raw = prog['pd']?['RESOURCETIME'];
2025-08-29 09:52:48 +08:00
final seen =
2025-09-01 17:25:55 +08:00
(() {
if (raw == null) return 0;
// 如果本身就是数字
if (raw is num) return raw.toInt();
// 否则转成字符串再 parse
final s = raw.toString();
return (double.tryParse(s) ?? 0.0).toInt();
})();
2025-07-22 13:34:34 +08:00
// 先销毁旧 controller
2025-08-29 20:33:23 +08:00
try {
_videoController?.removeListener(_onTimeUpdate);
} catch (_) {}
try {
_videoController?.dispose();
} catch (_) {}
_videoController = null;
2025-07-22 13:34:34 +08:00
// 创建新 controller
2025-07-18 17:13:38 +08:00
_videoController = VideoPlayerController.networkUrl(Uri.parse(url));
await _videoController!.initialize();
2025-07-22 13:34:34 +08:00
2025-08-29 20:33:23 +08:00
// 新增:为新视频重置上报相关标志
_endReported = false;
_lastReported = Duration.zero;
_ongoingSubmit = null;
2025-07-18 17:13:38 +08:00
setState(() {});
2025-07-22 13:34:34 +08:00
// 直接从上次播放点 seek并立即播放
2025-07-18 17:13:38 +08:00
_videoController!
..seekTo(Duration(seconds: seen))
..play()
..addListener(_onTimeUpdate);
2025-07-17 16:10:46 +08:00
}
2025-08-29 20:33:23 +08:00
// 改进版本的 _onTimeUpdate包含周期性上报 + 接近结束时的最终上报
2025-07-17 16:10:46 +08:00
void _onTimeUpdate() {
2025-08-29 20:33:23 +08:00
if (_videoController == null) return;
final val = _videoController!.value;
if (!val.isInitialized) return;
final pos = val.position;
final dur = val.duration;
// 周期性上报:每隔 _uploadIntervalSeconds 秒上报一次
final secondsSinceLast = (pos - _lastReported).inSeconds;
if (secondsSinceLast >= _uploadIntervalSeconds && !_endReported) {
_lastReported = pos;
// 串行上报:通过 _ongoingSubmit 链式调用来保证顺序
2025-09-01 17:25:55 +08:00
_ongoingSubmit = (_ongoingSubmit ?? Future.value())
.then((_) {
return _submitPlayTime(end: false, seconds: pos.inSeconds);
})
.whenComplete(() {
// 完成后清理引用
_ongoingSubmit = null;
});
2025-07-18 17:13:38 +08:00
}
2025-08-29 20:33:23 +08:00
// 接近结束的检测,容差 800ms触发一次最终结束上报
if (dur != null && dur.inMilliseconds > 0) {
final remainingMs = dur.inMilliseconds - pos.inMilliseconds;
final endedOrClose = remainingMs <= 800;
if (endedOrClose && !_endReported) {
_endReported = true;
final finalSeconds = (dur.inMilliseconds / 1000.0).ceil();
2025-09-01 17:25:55 +08:00
_ongoingSubmit = (_ongoingSubmit ?? Future.value())
.then((_) async {
await _submitPlayTime(end: true, seconds: finalSeconds);
})
.whenComplete(() {
_ongoingSubmit = null;
// 最终上报完成后尝试退出顶层 route退出全屏
_exitTopRouteIfPresent();
});
2025-08-29 20:33:23 +08:00
ApiService.fnClearUserFaceTime();
_faceTimer?.cancel();
}
2025-07-17 16:10:46 +08:00
}
}
Future<void> _submitPlayTime({
required bool end,
required int seconds,
}) async {
if (_currentVideoData == null) return;
2025-08-29 20:33:23 +08:00
// 如果已经上报结束并且当前不是结束上报,则跳过非结束上报
if (_endReported && !end) return;
Map data = {
'VIDEOCOURSEWARE_ID': _currentVideoData!['VIDEOCOURSEWARE_ID'] ?? '',
'CURRICULUM_ID': _currentVideoData!['CURRICULUM_ID'] ?? '',
'CHAPTER_ID': _currentVideoData!['CHAPTER_ID'] ?? '',
'RESOURCETIME': seconds,
'IS_END': end ? '1' : '0',
'CLASS_ID': _classId,
'CLASSCURRICULUM_ID': _classCurriculumId,
'STUDENT_ID': widget.studentId,
'loading': false,
};
const int maxRetries = 2;
int attempt = 0;
while (true) {
attempt++;
try {
final resData = await ApiService.fnSubmitPlayTime(data);
final pd = resData['pd'] ?? {};
// 解析后端返回的 RESOURCETIME可能是数字或字符串
final resTraw = pd['RESOURCETIME'];
2025-09-01 17:25:55 +08:00
final resT =
(resTraw is num)
? resTraw.toDouble()
: double.tryParse('$resTraw') ?? seconds.toDouble();
2025-08-29 20:33:23 +08:00
// 从当前视频数据中安全解析 videoTime
final videoTimeRaw = _currentVideoData!['VIDEOTIME'];
2025-09-01 17:25:55 +08:00
final videoTime =
(videoTimeRaw is String)
? double.tryParse(videoTimeRaw) ?? 1.0
: (videoTimeRaw is num ? videoTimeRaw.toDouble() : 1.0);
2025-08-29 20:33:23 +08:00
final comp = pd['PLAYCOUNT'] != null && pd['PLAYCOUNT'] > 0;
2025-09-01 17:25:55 +08:00
final pctDouble =
comp ? 100.0 : ((resT / (videoTime > 0 ? videoTime : 1.0)) * 100.0);
final pct = (pctDouble.clamp(0.00, 100.00) * 100).round() / 100;
2025-08-29 20:33:23 +08:00
final str = '${pct}%';
if (mounted) {
setState(() {
if (_hasNodes) {
_videoList[_currentFirstIndex]['nodes'][_currentNodeIndex]['percent'] =
str;
} else {
_videoList[_currentFirstIndex]['percent'] = str;
}
});
2025-07-18 17:13:38 +08:00
}
2025-08-29 20:33:23 +08:00
// 如果是结束上报且后端标注可考试,则弹窗处理
if (end && pd['CANEXAM'] == '1') {
_videoController?.pause();
final ok = await CustomAlertDialog.showConfirm(
2025-09-01 17:25:55 +08:00
context,
title: '温馨提示',
content: '当前任务内所有课程均已学完,是否直接参加考试?',
cancelText: '',
confirmText: '',
2025-08-29 20:33:23 +08:00
);
if (ok) {
_startExam(resData);
}
2025-07-18 17:13:38 +08:00
}
2025-08-29 20:33:23 +08:00
break; // 成功 -> 退出重试循环
} catch (e, st) {
debugPrint('submitPlayTime failed attempt $attempt: $e\n$st');
if (attempt > maxRetries) {
// 超过重试次数后放弃(避免无限重试)
break;
}
await Future.delayed(Duration(milliseconds: 500 * attempt));
}
2025-07-17 16:10:46 +08:00
}
}
2025-07-22 13:34:34 +08:00
/// 开始考试
Future<void> _startExam(Map resData) async {
Map pd = resData['pd'] ?? {};
Map paper = resData['paper'] ?? {};
setState(() {
_loading = true;
});
final arguments = {
2025-08-29 09:52:48 +08:00
'STAGEEXAMPAPERINPUT_ID': paper['STAGEEXAMPAPERINPUT_ID'] ?? '',
'STAGEEXAMPAPER_ID': paper['STAGEEXAMPAPER_ID'] ?? '',
2025-07-22 13:34:34 +08:00
'CLASS_ID': _classId,
'POST_ID': pd['POST_ID'] ?? '',
'STUDENT_ID': widget.studentId,
2025-08-29 09:52:48 +08:00
'NUMBEROFEXAMS': pd['NUMBEROFEXAMS'] ?? '',
2025-07-22 13:34:34 +08:00
};
print('--_startExam data---$arguments');
final data = await ApiService.getStartExam(arguments);
setState(() {
_loading = false;
});
if (data['result'] == 'success') {
2025-08-29 09:52:48 +08:00
pushPage(
TakeExamPage(
examInfo: {
'CLASS_ID': _classId,
'POST_ID': pd['POST_ID'] ?? '',
'STUDENT_ID': widget.studentId,
'STRENGTHEN_PAPER_QUESTION_ID':
2025-09-01 17:25:55 +08:00
paper['STAGEEXAMPAPERINPUT_ID'] ?? '',
2025-08-29 09:52:48 +08:00
...data,
},
examType: TakeExamType.video_study,
),
context,
);
} else {
2025-07-22 13:34:34 +08:00
ToastUtil.showError(context, '请求错误');
}
}
2025-07-18 17:13:38 +08:00
2025-08-29 20:33:23 +08:00
void _exitTopRouteIfPresent() {
final route = ModalRoute.of(context);
if (route == null) return;
// 当当前路由不是最上层时,说明有别的 route 在上面(通常是全屏播放器)
if (!route.isCurrent) {
// 只尝试 pop 一次(安全)
try {
Navigator.of(context).maybePop();
} catch (_) {
// 忽略异常
}
}
}
2025-09-01 17:25:55 +08:00
2025-07-17 16:10:46 +08:00
void _startFaceTimer() {
2025-07-18 17:13:38 +08:00
_faceTimer = Timer.periodic(Duration(seconds: _faceTime), (_) async {
2025-09-01 17:25:55 +08:00
final res = await ApiService.fnGetUserFaceTime();
2025-07-18 17:13:38 +08:00
final isPlaying = _videoController?.value.isPlaying == true;
final isVideo = _currentVideoData?['IS_VIDEO'] == 0;
2025-08-29 20:33:23 +08:00
final needAuth = FormUtils.hasValue(res, 'data');
2025-07-18 17:13:38 +08:00
2025-09-01 17:25:55 +08:00
if (isPlaying && isVideo && !needAuth) {
_exitTopRouteIfPresent();
2025-07-18 17:13:38 +08:00
_videoController!.pause();
_videoController!.removeListener(_onTimeUpdate);
await _showFaceAuthOnce();
}
2025-07-17 16:10:46 +08:00
});
}
Future<void> _showFaceAuthOnce() async {
2025-09-01 17:25:55 +08:00
// 先 cancel 定时器,避免跳转过程中再次触发
_faceTimer?.cancel();
_faceTimer = null;
2025-07-17 16:10:46 +08:00
final passed = await pushPage<bool>(
2025-07-18 17:13:38 +08:00
FaceRecognitionPage(studentId: widget.studentId, mode: FaceMode.auto),
2025-07-17 16:10:46 +08:00
context,
);
2025-09-01 17:25:55 +08:00
2025-07-17 16:10:46 +08:00
if (passed == true) {
2025-09-01 17:25:55 +08:00
await ApiService.fnSetUserFaceTime(_faceTime);
// 认证通过后 —— 恢复播放、重新添加 listener并更新上报基准
try {
// 如果 controller 还存在且已初始化
if (_videoController != null && _videoController!.value.isInitialized) {
// 先确保 listener 没有重复添加
try {
_videoController!.removeListener(_onTimeUpdate);
} catch (_) {}
// 更新上报基准,避免立即触发一次短时间内的重复上报
try {
final currentPos = _videoController!.value.position;
_lastReported = currentPos;
debugPrint('Face auth passed — resetting _lastReported to $currentPos');
} catch (_) {
_lastReported = Duration.zero;
}
// 恢复播放并绑定 listener保证周期性进度上报恢复
await _videoController!.play();
_videoController!.addListener(_onTimeUpdate);
} else {
// 如果 controller 为空或未初始化,仅尝试更新 UI
debugPrint('Face auth passed but _videoController is null or not initialized');
}
setState(() {});
} catch (e, st) {
debugPrint('Error resuming playback after face auth: $e\n$st');
}
// 恢复人脸验证计时器
_startFaceTimer();
} else {
ToastUtil.showError(context, '人脸验证未通过,无法继续');
if (_videoController != null) {
try {
_videoController?.removeListener(_onTimeUpdate);
} catch (_) {}
try {
_videoController?.dispose();
} catch (_) {}
_videoController = null;
}
2025-07-17 16:10:46 +08:00
_faceTimer?.cancel();
2025-09-01 17:25:55 +08:00
setState(() {
});
2025-07-17 16:10:46 +08:00
}
}
2025-09-01 17:25:55 +08:00
2025-08-29 09:52:48 +08:00
Widget _buildVideoOrCover(double containerW, double containerH) {
final c = _videoController;
if (c != null && c.value.isInitialized) {
return VideoPlayerWidget(
allowSeek: false,
controller: _videoController,
coverUrl:
2025-09-01 17:25:55 +08:00
_videoCoverUrl.isNotEmpty
? ApiService.baseImgPath + _videoCoverUrl
: ApiService.baseImgPath + (_info?['COVERPATH'] ?? ''),
2025-08-29 09:52:48 +08:00
aspectRatio: _videoController?.value.aspectRatio ?? 16 / 9,
);
} else {
// controller 被销毁或未初始化,显示封面
return Image.network(
'${ApiService.baseImgPath}${_info?['COVERPATH'] ?? ''}',
fit: BoxFit.fill,
width: containerW,
height: containerH,
);
}
2025-07-16 08:37:08 +08:00
}
@override
Widget build(BuildContext context) {
2025-07-17 16:10:46 +08:00
if (_loading) {
return Scaffold(
appBar: MyAppbar(title: '学习详情'),
body: const Center(child: CircularProgressIndicator()),
);
}
final info = _info!;
2025-07-16 08:37:08 +08:00
return Scaffold(
2025-07-17 16:10:46 +08:00
appBar: MyAppbar(title: '学习详情'),
body: SafeArea(
child: Column(
children: [
2025-08-29 09:52:48 +08:00
_buildVideoOrCover(screenWidth(context), 250),
2025-09-01 17:25:55 +08:00
const SizedBox(height: 5),
2025-07-17 16:10:46 +08:00
Container(
width: double.infinity,
color: Colors.white,
padding: const EdgeInsets.all(10.0),
child: Text(
info['CURRICULUMNAME'] ?? '',
style: const TextStyle(
2025-07-18 17:13:38 +08:00
fontSize: 20,
fontWeight: FontWeight.bold,
),
2025-07-17 16:10:46 +08:00
),
),
const SizedBox(height: 10),
Container(
color: Colors.white,
child: TabBar(
controller: _tabController,
2025-09-01 17:25:55 +08:00
labelStyle: const TextStyle(fontSize: 16),
indicator: const UnderlineTabIndicator(
borderSide: BorderSide(width: 3.0, color: Colors.blue),
insets: EdgeInsets.symmetric(horizontal: 0.0),
),
labelColor: Colors.blue,
unselectedLabelColor: Colors.grey,
2025-07-17 16:10:46 +08:00
tabs: const [Tab(text: '课件目录'), Tab(text: '详情')],
),
),
const SizedBox(height: 10),
Expanded(
child: TabBarView(
controller: _tabController,
2025-07-18 17:13:38 +08:00
children: [_buildVideoList(), _buildDetailView(info)],
2025-07-17 16:10:46 +08:00
),
),
],
),
),
);
}
Widget _buildVideoList() {
return ListView.builder(
itemCount: _videoList.length,
itemBuilder: (ctx, idx) {
final item = _videoList[idx] as Map<String, dynamic>;
final nodes = item['nodes'] as List<dynamic>?;
if (nodes != null && nodes.isNotEmpty) {
2025-08-29 09:52:48 +08:00
// 章节标题 + 直接展开的子项列表(不折叠)
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
...nodes.asMap().entries.map(
2025-09-01 17:25:55 +08:00
(e) => _buildVideoItem(
2025-08-29 09:52:48 +08:00
item,
e.value as Map<String, dynamic>,
true,
idx,
e.key,
),
),
const Divider(height: 1), // 每章节后一个分割线
],
2025-07-17 16:10:46 +08:00
);
}
2025-08-29 09:52:48 +08:00
// 没有子节点,直接显示该条目
return _buildVideoItem(item, item, false, idx, 0);
2025-07-17 16:10:46 +08:00
},
);
}
Widget _buildVideoItem(
2025-09-01 17:25:55 +08:00
Map<String, dynamic> item,
Map<String, dynamic> m,
bool hasNodes,
int fi,
int ni,
) {
2025-07-17 16:10:46 +08:00
return Container(
color: Colors.white,
padding: const EdgeInsets.all(20),
child: Column(
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
2025-07-18 17:13:38 +08:00
const Icon(Icons.file_copy_rounded, color: Colors.grey, size: 20),
2025-08-29 09:52:48 +08:00
const SizedBox(width: 8),
2025-07-17 16:10:46 +08:00
Expanded(
2025-07-18 17:13:38 +08:00
child: Text(
2025-08-29 09:52:48 +08:00
item['NAME'] ?? '',
2025-07-18 17:13:38 +08:00
style: const TextStyle(fontSize: 14),
),
),
2025-07-17 16:10:46 +08:00
],
),
const SizedBox(height: 10),
GestureDetector(
onTap: () => _onVideoTap(m, hasNodes, fi, ni),
child: Container(
height: 80,
2025-07-18 17:13:38 +08:00
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 8),
2025-07-17 16:10:46 +08:00
decoration: BoxDecoration(
color: const Color(0xFFFAFAFA),
borderRadius: BorderRadius.circular(5),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
2025-07-18 17:13:38 +08:00
child: Text(
m['COURSEWARENAME'] ?? '',
style: const TextStyle(fontSize: 14),
),
2025-07-17 16:10:46 +08:00
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
2025-09-01 17:25:55 +08:00
Expanded(
child: Text(
"进度:${m['percent']}",
style: const TextStyle(color: Colors.blue),
),
2025-07-18 17:13:38 +08:00
),
2025-07-17 16:10:46 +08:00
if (m['IS_VIDEO'] == 0) ...[
Text(secondsCount(m['VIDEOTIME'])),
2025-09-01 17:25:55 +08:00
const SizedBox(width: 20),
2025-07-17 16:10:46 +08:00
const Icon(Icons.play_circle, color: Colors.blue),
],
2025-09-01 17:25:55 +08:00
SizedBox(width: 20),
2025-07-17 16:10:46 +08:00
CustomButton(
2025-07-18 17:13:38 +08:00
onPressed:
() => pushPage(
2025-09-01 17:25:55 +08:00
StudyPractisePage(
videoCoursewareId: m['VIDEOCOURSEWARE_ID'],
),
context,
),
2025-07-17 16:10:46 +08:00
text: "课后练习",
backgroundColor: Colors.blue,
height: 30,
2025-07-18 17:13:38 +08:00
textStyle: const TextStyle(
fontSize: 12,
color: Colors.white,
),
2025-07-17 16:10:46 +08:00
padding: const EdgeInsets.symmetric(
2025-07-18 17:13:38 +08:00
vertical: 2,
horizontal: 12,
),
2025-07-17 16:10:46 +08:00
borderRadius: 15,
),
],
),
],
),
),
2025-07-18 17:13:38 +08:00
),
2025-07-17 16:10:46 +08:00
],
),
);
}
Widget _buildDetailView(Map info) {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
2025-07-18 17:13:38 +08:00
Text(
info['CURRICULUMINTRODUCE'] ?? '',
style: const TextStyle(fontSize: 16),
),
2025-07-17 16:10:46 +08:00
const SizedBox(height: 16),
if (info['COVERPATH'] != null)
Image.network(ApiService.baseImgPath + info['COVERPATH']),
],
),
2025-07-16 08:37:08 +08:00
);
}
}