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

593 lines
18 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 'package:flutter/material.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 '../../../customWidget/toast_util.dart';
import '../../../customWidget/video_player_widget.dart';
import '../../../http/HttpManager.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 extracted
VideoPlayerController? _videoController;
String _videoCoverUrl = '';
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;
@override
void initState() {
super.initState();
_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();
_videoController?.removeListener(_onTimeUpdate);
_videoController?.dispose();
_faceTimer?.cancel();
super.dispose();
}
Future<void> _showFaceIntro() async {
await showDialog(
context: context,
builder: (_) => CustomAlertDialog(
title: '温馨提示',
content:
'重要提醒:尊敬的用户,根据规定我们会在您学习过程中多次进行人脸识别认证,为了保护您的隐私请您在摄像设备视野内确保衣冠整齐。',
cancelText: '取消',
confirmText: '同意并继续',
onCancel: () => Navigator.of(context).pop(),
onConfirm: () => {},
),
);
}
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
if (pd['ISFACE'] == '1') {
await ApiService.fnSetUserFaceTime(_faceTime);
}
// 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, 100).toStringAsFixed(0);
return '$pct%';
}
Future<void> _onVideoTap(
Map<String, dynamic> data,
bool hasNodes,
int fi,
int ni,
) async {
// clear face timer on backend
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;
// pause existing
_videoController?.pause();
await _navigateFaceIfNeeded(() async {
if ((data['IS_VIDEO'] ?? 0) == 1) {
// document
if (data['VIDEOFILES'] != null) {
await pushPage(
StudyPractisePage(videoCoursewareId: data['VIDEOCOURSEWARE_ID']),
context,
);
await _submitPlayTime(
end: true,
seconds: int.parse(data['VIDEOTIME'] ?? '0'),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('课件文件资源已失效,请联系管理员')),
);
}
} else {
// video
await _getVideoPlayInfo(data['VIDEOCOURSEWARE_ID']);
_startFaceTimer();
}
});
}
Future<void> _navigateFaceIfNeeded(FutureOr<void> Function() onPass) async {
if (_info?['ISFACE'] == '1') {
final passed = await pushPage<bool>(
FaceRecognitionPage(studentId: widget.studentId, mode: FaceMode.auto),
context,
);
if (passed == true) {
await onPass();
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('人脸验证未通过,无法继续')),
);
}
} 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
_videoController?.removeListener(_onTimeUpdate);
_videoController?.dispose();
// 创建新 controller
_videoController = VideoPlayerController.networkUrl(Uri.parse(url));
await _videoController!.initialize();
setState(() {});
// 直接从上次播放点 seek并立即播放
_videoController!
..seekTo(Duration(seconds: seen))
..play()
..addListener(_onTimeUpdate);
}
void _onTimeUpdate() {
if (_videoController == null || !_videoController!.value.isPlaying) return;
final curr = _videoController!.value.position;
if (!_throttleFlag && (curr - _lastReported).inSeconds >= 5) {
_throttleFlag = true;
_lastReported = curr;
_submitPlayTime(end: false, seconds: curr.inSeconds)
.whenComplete(() => _throttleFlag = false);
}
final pos = _videoController!.value.position;
final dur = _videoController!.value.duration;
if (pos >= dur) {
_submitPlayTime(end: true, seconds: dur.inSeconds);
ApiService.fnClearUserFaceTime();
_faceTimer?.cancel();
}
}
Future<void> _submitPlayTime({
required bool end,
required int seconds,
}) async {
if (_currentVideoData == null) return;
try {
final resData = (await ApiService.fnSubmitPlayTime(
_currentVideoData!['VIDEOCOURSEWARE_ID'],
_currentVideoData!['CURRICULUM_ID'],
end ? '1' : '0',
seconds,
_currentVideoData!['CHAPTER_ID'],
widget.studentId,
_classCurriculumId,
_classId,
));
final pd = resData['pd'] ?? {};
// 更新进度显示
final comp = pd['PLAYCOUNT'] != null && pd['PLAYCOUNT'] > 0;
final resT = pd['RESOURCETIME'] ?? seconds;
final pct = comp
? 100
: (resT / (_currentVideoData!['VIDEOTIME'] ?? 1) * 100)
.clamp(0, 100);
final str = '${pct.floor()}%';
setState(() {
if (_hasNodes) {
_videoList[_currentFirstIndex]['nodes'][_currentNodeIndex]
['percent'] = str;
} else {
_videoList[_currentFirstIndex]['percent'] = str;
}
});
// 如果结束且可考试,弹框
if (end && pd['CANEXAM'] == '1') {
_videoController?.pause();
final ok = await showDialog<bool>(
context: context,
builder: (_) => CustomAlertDialog(
title: '提示',
content: '当前任务内所有课程均已学完,是否直接参加考试?',
confirmText: '',
cancelText: '',
),
) ??
false;
if (ok) {
_startExam(resData);
} else {
_videoController?.play();
}
}
} on ApiException catch (e) {
// 如果是 401 ,就登出并跳转登录
// 其他错误继续抛出
rethrow;
}
}
/// 开始考试
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 _startFaceTimer() {
_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();
}
});
}
Future<void> _showFaceAuthOnce() async {
final passed = await pushPage<bool>(
FaceRecognitionPage(studentId: widget.studentId, mode: FaceMode.auto),
context,
);
if (passed == true) {
await _videoController?.play();
setState(() {});
_faceTimer?.cancel();
}
}
void _controllerListener() {
if (mounted) setState(() {});
}
@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: [
SizedBox(
height: 250,
width: screenWidth(context),
child: VideoPlayerWidget(
allowSeek: false,
controller: _videoController,
coverUrl: _videoCoverUrl.isNotEmpty
? ApiService.baseImgPath + _videoCoverUrl
: ApiService.baseImgPath + (info['COVERPATH'] ?? ''),
aspectRatio: _videoController?.value.aspectRatio ?? 16/9,
),
),
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(
indicatorColor: Colors.blue,
labelStyle: const TextStyle(
fontSize: 16,
color: Colors.black87,
),
controller: _tabController,
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 ExpansionTile(
title: Text(item['NAME'] ?? ''),
children:
nodes
.asMap()
.entries
.map(
(e) => _buildVideoItem(
e.value as Map<String, dynamic>,
true,
idx,
e.key,
),
)
.toList(),
);
}
return _buildVideoItem(item, false, idx, 0);
},
);
}
Widget _buildVideoItem(
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),
Expanded(
child: Text(
m['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: [
Text(
"进度:${m['percent']}",
style: const TextStyle(color: Colors.blue),
),
if (m['IS_VIDEO'] == 0) ...[
Text(secondsCount(m['VIDEOTIME'])),
const Icon(Icons.play_circle, color: Colors.blue),
],
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']),
],
),
);
}
}