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

782 lines
24 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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']),
],
),
);
}
}