542 lines
16 KiB
Dart
542 lines
16 KiB
Dart
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/video_player_widget.dart';
|
|
import '../../../http/HttpManager.dart';
|
|
import 'face_ecognition_page.dart';
|
|
|
|
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(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 seen = (double.tryParse(prog['pd']?['RESOURCETIME']) ?? 0.0).toInt();
|
|
|
|
_videoController?.removeListener(_onTimeUpdate);
|
|
_videoController?.dispose();
|
|
_videoController = VideoPlayerController.networkUrl(Uri.parse(url));
|
|
await _videoController!.initialize();
|
|
setState(() {});
|
|
|
|
_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 pd = (await ApiService.fnSubmitPlayTime(
|
|
_currentVideoData!['VIDEOCOURSEWARE_ID'],
|
|
_currentVideoData!['CURRICULUM_ID'],
|
|
end ? '1' : '0',
|
|
seconds,
|
|
_currentVideoData!['CHAPTER_ID'],
|
|
widget.studentId,
|
|
_classCurriculumId,
|
|
_classId,
|
|
))['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) {
|
|
final arguments = {
|
|
'STAGEEXAMPAPERINPUT_ID':
|
|
pd['paper']['STAGEEXAMPAPERINPUT_ID'],
|
|
'CLASS_ID': _classId,
|
|
'STUDENT_ID': widget.studentId,
|
|
'NUMBEROFEXAMS': pd['NUMBEROFEXAMS'],
|
|
};
|
|
pushPage(TakeExamPage(arguments), context);
|
|
} else {
|
|
_videoController?.play();
|
|
}
|
|
}
|
|
} on ApiException catch (e) {
|
|
// 如果是 401 ,就登出并跳转登录
|
|
|
|
// 其他错误继续抛出
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
|
|
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,
|
|
child: VideoPlayerWidget(
|
|
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(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']),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|