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

625 lines
19 KiB
Dart
Raw Normal View History

2025-07-17 16:10:46 +08:00
import 'dart:async';
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-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';
import '../../../http/HttpManager.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-07-18 17:13:38 +08:00
// player controller extracted
VideoPlayerController? _videoController;
String _videoCoverUrl = '';
2025-07-17 16:10:46 +08:00
Timer? _faceTimer;
bool _throttleFlag = false;
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-07-16 08:37:08 +08:00
@override
void initState() {
super.initState();
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();
_videoController?.removeListener(_onTimeUpdate);
_videoController?.dispose();
2025-08-29 09:52:48 +08:00
_videoController = null;
2025-07-17 16:10:46 +08:00
_faceTimer?.cancel();
super.dispose();
}
Future<void> _showFaceIntro() async {
await showDialog(
context: context,
2025-08-29 09:52:48 +08:00
builder:
(_) => CustomAlertDialog(
title: '温馨提示',
content:
'重要提醒:尊敬的用户,根据规定我们会在您学习过程中多次进行人脸识别认证,为了保护您的隐私请您在摄像设备视野内确保衣冠整齐。',
cancelText: '取消',
confirmText: '同意并继续',
onCancel: () => Navigator.of(context).pop(),
onConfirm: () => {},
),
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
if (pd['ISFACE'] == '1') {
await ApiService.fnSetUserFaceTime(_faceTime);
}
// 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%';
final pct = ((resT / vidT) * 100).clamp(0, 100).toStringAsFixed(0);
return '$pct%';
}
Future<void> _onVideoTap(
2025-08-29 09:52:48 +08:00
Map<String, dynamic> data,
bool hasNodes,
int fi,
int ni,
) async {
2025-07-18 17:13:38 +08:00
// clear face timer on backend
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-07-18 17:13:38 +08:00
// pause existing
2025-07-17 16:10:46 +08:00
_videoController?.pause();
await _navigateFaceIfNeeded(() async {
if ((data['IS_VIDEO'] ?? 0) == 1) {
2025-07-18 17:13:38 +08:00
// document
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-07-18 17:13:38 +08:00
// video
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') {
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,
);
if (passed == true) {
await onPass();
} 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 {
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 =
(() {
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-07-17 16:10:46 +08:00
_videoController?.removeListener(_onTimeUpdate);
_videoController?.dispose();
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-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
}
void _onTimeUpdate() {
if (_videoController == null || !_videoController!.value.isPlaying) return;
final curr = _videoController!.value.position;
2025-08-29 11:05:17 +08:00
if (!_throttleFlag && (curr - _lastReported).inSeconds >= 0) {
2025-07-17 16:10:46 +08:00
_throttleFlag = true;
_lastReported = curr;
2025-08-29 09:52:48 +08:00
_submitPlayTime(
end: false,
seconds: curr.inSeconds,
).whenComplete(() => _throttleFlag = false);
2025-07-18 17:13:38 +08:00
}
final pos = _videoController!.value.position;
final dur = _videoController!.value.duration;
if (pos >= dur) {
_submitPlayTime(end: true, seconds: dur.inSeconds);
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-07-18 17:13:38 +08:00
try {
2025-08-29 09:52:48 +08:00
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,
};
final resData = await ApiService.fnSubmitPlayTime(data);
2025-07-22 13:34:34 +08:00
final pd = resData['pd'] ?? {};
2025-07-18 17:13:38 +08:00
// 更新进度显示
final comp = pd['PLAYCOUNT'] != null && pd['PLAYCOUNT'] > 0;
final resT = pd['RESOURCETIME'] ?? seconds;
2025-08-29 09:52:48 +08:00
final videoTimeRaw = _currentVideoData!['VIDEOTIME'];
final videoTime =
(videoTimeRaw is String)
? double.tryParse(videoTimeRaw) ?? 1
: (videoTimeRaw is num ? videoTimeRaw.toDouble() : 1);
final pct = comp ? 100 : (resT / videoTime * 100).clamp(0, 100);
2025-07-18 17:13:38 +08:00
final str = '${pct.floor()}%';
setState(() {
if (_hasNodes) {
2025-08-29 09:52:48 +08:00
_videoList[_currentFirstIndex]['nodes'][_currentNodeIndex]['percent'] =
str;
2025-07-18 17:13:38 +08:00
} else {
_videoList[_currentFirstIndex]['percent'] = str;
}
});
// 如果结束且可考试,弹框
if (end && pd['CANEXAM'] == '1') {
_videoController?.pause();
2025-08-29 09:52:48 +08:00
final ok =
await showDialog<bool>(
context: context,
builder:
(_) => CustomAlertDialog(
title: '提示',
content: '当前任务内所有课程均已学完,是否直接参加考试?',
confirmText: '',
cancelText: '',
),
) ??
2025-07-18 17:13:38 +08:00
false;
if (ok) {
2025-07-22 13:34:34 +08:00
_startExam(resData);
2025-07-18 17:13:38 +08:00
} else {
_videoController?.play();
}
2025-07-17 16:10:46 +08:00
}
2025-07-18 17:13:38 +08:00
} on ApiException catch (e) {
// 如果是 401 ,就登出并跳转登录
// 其他错误继续抛出
rethrow;
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':
paper['STAGEEXAMPAPERINPUT_ID'] ?? '',
...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-07-17 16:10:46 +08:00
void _startFaceTimer() {
2025-07-18 17:13:38 +08:00
_faceTimer = Timer.periodic(Duration(seconds: _faceTime), (_) async {
final res = await ApiService.fnGetUserFaceTime(_faceTime);
final isPlaying = _videoController?.value.isPlaying == true;
final isVideo = _currentVideoData?['IS_VIDEO'] == 0;
final needAuth = res['data'] == false;
if (isPlaying && isVideo && needAuth) {
_videoController!.pause();
_videoController!.removeListener(_onTimeUpdate);
await _showFaceAuthOnce();
}
2025-07-17 16:10:46 +08:00
});
}
Future<void> _showFaceAuthOnce() async {
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,
);
if (passed == true) {
2025-07-18 17:13:38 +08:00
await _videoController?.play();
setState(() {});
2025-07-17 16:10:46 +08:00
_faceTimer?.cancel();
}
}
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:
_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,
);
}
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),
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(
indicatorColor: Colors.blue,
2025-07-18 17:13:38 +08:00
labelStyle: const TextStyle(
fontSize: 16,
color: Colors.black87,
),
2025-07-17 16:10:46 +08:00
controller: _tabController,
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(
(e) => _buildVideoItem(
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-08-29 09:52:48 +08:00
Map<String, dynamic> item,
2025-07-18 17:13:38 +08:00
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-07-18 17:13:38 +08:00
Text(
"进度:${m['percent']}",
style: const TextStyle(color: Colors.blue),
),
2025-07-17 16:10:46 +08:00
if (m['IS_VIDEO'] == 0) ...[
Text(secondsCount(m['VIDEOTIME'])),
2025-08-29 09:52:48 +08:00
const SizedBox(width: 6),
2025-07-17 16:10:46 +08:00
const Icon(Icons.play_circle, color: Colors.blue),
],
CustomButton(
2025-07-18 17:13:38 +08:00
onPressed:
() => pushPage(
2025-08-29 09:52:48 +08:00
StudyPractisePage(
videoCoursewareId: m['VIDEOCOURSEWARE_ID'],
),
2025-07-18 17:13:38 +08:00
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
);
}
}