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

505 lines
15 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-07-17 16:10:46 +08:00
import 'package:video_player/video_player.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/pages/home/study/study_practise_page.dart';
2025-07-16 08:37:08 +08:00
import 'package:qhd_prevention/pages/my_appbar.dart';
2025-07-17 16:10:46 +08:00
import 'package:qhd_prevention/tools/tools.dart';
2025-07-16 08:37:08 +08:00
2025-07-17 16:10:46 +08:00
import 'face_ecognition_page.dart';
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-07-16 08:37:08 +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;
VideoPlayerController? _videoController;
Map<String, dynamic>? _info;
List<dynamic> _videoList = [];
bool _loading = true;
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'] ?? '';
_classCurriculumId =
widget.studyDetailDetail['CLASSCURRICULUM_ID'] ?? '';
_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();
_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,
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'] ?? []);
// 计算 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 {
// 停掉上一轮人脸定时
_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) {
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 {
// 视频
await _getVideoPlayInfo(data['VIDEOCOURSEWARE_ID']);
// 播放并启动定时
_videoController!
..play()
..addListener(_onTimeUpdate);
_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'] ?? '';
final prog = await ApiService.fnGetVideoPlayProgress(
vidId,
_currentVideoData!['CURRICULUM_ID'],
_classId,
widget.studentId,
);
final seen = prog['pd']?['RESOURCETIME'] ?? 0;
_videoController?.removeListener(_onTimeUpdate);
_videoController?.dispose();
_videoController = VideoPlayerController.networkUrl(Uri.parse(url))
..initialize().then((_) {
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(() => Future.delayed(const Duration(seconds: 1), () {
_throttleFlag = false;
}));
}
}
Future<void> _submitPlayTime({
required bool end,
required int seconds,
}) async {
if (_currentVideoData == null) return;
final pd = (await ApiService.fnSubmitPlayTime(
_currentVideoData!['VIDEOCOURSEWARE_ID'],
_currentVideoData!['CURRICULUM_ID'],
end ? '1' : '0',
seconds,
_currentVideoData!['CHAPTER_ID'],
widget.studentId,
_classCurriculumId,
_classId,
))['pd']!;
// 更新列表 percent
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;
}
2025-07-16 08:37:08 +08:00
});
2025-07-17 16:10:46 +08:00
// 结束且可考试
if (end && pd['CANEXAM'] == '1') {
_videoController?.pause();
final ok = await showDialog<bool>(
context: context,
builder: (_) => CustomAlertDialog(
title: '提示',
content: '当前任务内所有课程均已学完,是否直接参加考试?',
confirmText: '',
cancelText: '',
),
) ??
false;
if (ok) {
Navigator.of(context).pushReplacementNamed(
'/course_exam',
arguments: {
'STAGEEXAMPAPERINPUT_ID': pd['paper']['STAGEEXAMPAPERINPUT_ID'],
'CLASS_ID': _classId,
'STUDENT_ID': widget.studentId,
'NUMBEROFEXAMS': pd['NUMBEROFEXAMS'],
},
);
} else {
_videoController?.play();
}
}
}
void _startFaceTimer() {
_faceTimer = Timer.periodic(Duration(seconds: _faceTime), (_) {
_videoController?.pause();
_showFaceAuthOnce();
});
}
Future<void> _showFaceAuthOnce() async {
final passed = await pushPage<bool>(
FaceRecognitionPage(
studentId: widget.studentId,
mode: FaceMode.auto,
),
context,
);
if (passed == true) {
_videoController?.play();
_faceTimer?.cancel();
_startFaceTimer();
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('人脸验证未通过,视频已暂停')),
);
}
}
String secondsCount(dynamic sec) {
final s = int.tryParse('$sec') ?? 0;
final h = s ~/ 3600;
final m = (s % 3600) ~/ 60;
final ss = s % 60;
return '${h.toString().padLeft(2, '0')}:'
'${m.toString().padLeft(2, '0')}:'
'${ss.toString().padLeft(2, '0')}';
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: [
SizedBox(
height: 250,
child: _videoController != null &&
_videoController!.value.isInitialized
? AspectRatio(
aspectRatio: _videoController!.value.aspectRatio,
child: VideoPlayer(_videoController!),
)
: Image.network(
ApiService.baseImgPath + (info['COVERPATH'] ?? ''),
fit: BoxFit.cover,
width: double.infinity,
),
),
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']),
],
),
2025-07-16 08:37:08 +08:00
);
}
}