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 createState() => _StudyDetailPageState(); } class _StudyDetailPageState extends State with SingleTickerProviderStateMixin { late TabController _tabController; Map? _info; List _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? _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 _showFaceIntro() async { await showDialog( context: context, builder: (_) => CustomAlertDialog( title: '温馨提示', content: '重要提醒:尊敬的用户,根据规定我们会在您学习过程中多次进行人脸识别认证,为了保护您的隐私请您在摄像设备视野内确保衣冠整齐。', cancelText: '取消', confirmText: '同意并继续', onCancel: () => Navigator.of(context).pop(), onConfirm: () => {}, ), ); } Future _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 _onVideoTap( Map 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 _navigateFaceIfNeeded(FutureOr Function() onPass) async { if (_info?['ISFACE'] == '1') { final passed = await pushPage( 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 _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 _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( 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 _showFaceAuthOnce() async { final passed = await pushPage( 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; final nodes = item['nodes'] as List?; if (nodes != null && nodes.isNotEmpty) { return ExpansionTile( title: Text(item['NAME'] ?? ''), children: nodes .asMap() .entries .map( (e) => _buildVideoItem( e.value as Map, true, idx, e.key, ), ) .toList(), ); } return _buildVideoItem(item, false, idx, 0); }, ); } Widget _buildVideoItem( Map 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']), ], ), ); } }