593 lines
		
	
	
		
			18 KiB
		
	
	
	
		
			Dart
		
	
	
			
		
		
	
	
			593 lines
		
	
	
		
			18 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/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']),
 | ||
|         ],
 | ||
|       ),
 | ||
|     );
 | ||
|   }
 | ||
| }
 |