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