flutter_integrated_whb/lib/pages/home/study/study_detail_page.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']),
],
),
);
}
}