1044 lines
		
	
	
		
			34 KiB
		
	
	
	
		
			Dart
		
	
	
			
		
		
	
	
			1044 lines
		
	
	
		
			34 KiB
		
	
	
	
		
			Dart
		
	
	
| import 'dart:async';
 | ||
| import 'dart:math';
 | ||
| import 'dart:ui';
 | ||
| import 'package:flutter/material.dart';
 | ||
| import 'package:flutter/services.dart';
 | ||
| import 'package:permission_handler/permission_handler.dart';
 | ||
| import 'package:qhd_prevention/customWidget/remote_file_page.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/services/auth_service.dart';
 | ||
| import 'package:qhd_prevention/tools/tools.dart';
 | ||
| import 'package:video_player/video_player.dart';
 | ||
| import 'package:wakelock_plus/wakelock_plus.dart';
 | ||
| 
 | ||
| import '../../../customWidget/toast_util.dart';
 | ||
| import '../../../customWidget/video_player_widget.dart';
 | ||
| import 'face_ecognition_page.dart';
 | ||
| 
 | ||
| enum TakeExamType { video_study, strengththen, list }
 | ||
| 
 | ||
| class StudyDetailPage extends StatefulWidget {
 | ||
|   final Map studyDetailDetail;
 | ||
|   final String studentId;
 | ||
|   final Map studyData;
 | ||
| 
 | ||
|   const StudyDetailPage(this.studyDetailDetail, this.studentId, this.studyData,{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 提取出来
 | ||
|   VideoPlayerController? _videoController;
 | ||
|   String _videoCoverUrl = '';
 | ||
| 
 | ||
|   Timer? _faceTimer;
 | ||
|   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;
 | ||
| 
 | ||
|   // 上报控制相关字段
 | ||
|   bool _endReported = false; // 确保最终结束上报只执行一次
 | ||
|   Future<void>? _ongoingSubmit; // 用于串行化提交请求
 | ||
|   final int _uploadIntervalSeconds = 5; // 周期性上报间隔(秒)
 | ||
| 
 | ||
|   // 新增:防止并发加载视频
 | ||
|   bool _isLoadingVideo = false;
 | ||
| 
 | ||
|   @override
 | ||
|   void initState() {
 | ||
|     super.initState();
 | ||
|     WakelockPlus.enable();
 | ||
|     _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();
 | ||
|     WakelockPlus.disable();
 | ||
|     SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
 | ||
|     // 让未完成的提交完成(非阻塞)
 | ||
|     if (_ongoingSubmit != null) {
 | ||
|       _ongoingSubmit!.whenComplete(() {});
 | ||
|     }
 | ||
| 
 | ||
|     try {
 | ||
|       _videoController?.removeListener(_onTimeUpdate);
 | ||
|     } catch (_) {}
 | ||
|     try {
 | ||
|       _videoController?.dispose();
 | ||
|     } catch (_) {}
 | ||
|     _videoController = null;
 | ||
| 
 | ||
|     _faceTimer?.cancel();
 | ||
|     super.dispose();
 | ||
|   }
 | ||
| 
 | ||
|   Future<void> _showFaceIntro() async {
 | ||
|     final ok = await CustomAlertDialog.showConfirm(
 | ||
|       context,
 | ||
|       title: '温馨提示',
 | ||
|       content: '重要提醒:尊敬的用户,根据规定我们会在您学习过程中多次进行人脸识别认证,为了保护您的隐私请您在摄像设备视野内确保衣冠整齐。',
 | ||
|       cancelText: '取消',
 | ||
|       confirmText: '同意并继续',
 | ||
|     );
 | ||
|     if (!ok) {
 | ||
|       Navigator.of(context).pop();
 | ||
|       return;
 | ||
|     }
 | ||
|   }
 | ||
| 
 | ||
|   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
 | ||
| 
 | ||
|       // 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,
 | ||
|   }) {
 | ||
|     // 播放次数大于 0 视为 100%
 | ||
|     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%';
 | ||
| 
 | ||
|     double pct = (resT / vidT) * 100.0;
 | ||
|     // 限制在合理范围
 | ||
|     if (pct <= 0.0) return '0%';
 | ||
|     if (pct >= 100.0) return '100%';
 | ||
| 
 | ||
|     // 非零但非常小的值,例如 0.004%,显示为 <0.01%
 | ||
|     if (pct > 0.0 && pct < 0.01) return '<0.01%';
 | ||
| 
 | ||
|     // 保留最多 2 位小数,然后去掉多余的零和小数点
 | ||
|     String s = pct.toStringAsFixed(2);
 | ||
|     s = s.replaceFirst(RegExp(r'\.?0+$'), ''); // 去掉尾随的 .00 或 .0
 | ||
|     return '$s%';
 | ||
|   }
 | ||
| 
 | ||
| 
 | ||
| 
 | ||
|   Future<void> _onVideoTap(
 | ||
|     Map<String, dynamic> data,
 | ||
|     bool hasNodes,
 | ||
|     int fi,
 | ||
|     int ni,
 | ||
|   ) async {
 | ||
|     if (_isLoadingVideo) {
 | ||
|       debugPrint('_onVideoTap ignored because a video is loading');
 | ||
|       return;
 | ||
|     }
 | ||
| 
 | ||
|     // 后端清除人脸计时
 | ||
|     await ApiService.fnClearUserFaceTime();
 | ||
|     _faceTimer?.cancel();
 | ||
| 
 | ||
|     // 如果当前有视频播放且有未上报的进度,先做一次快照上报(避免丢失)
 | ||
|     if (_currentVideoData != null && _lastReported > Duration.zero) {
 | ||
|       final prevSnapshot = <String, dynamic>{
 | ||
|         'VIDEOCOURSEWARE_ID': _currentVideoData?['VIDEOCOURSEWARE_ID'] ?? '',
 | ||
|         'CURRICULUM_ID': _currentVideoData?['CURRICULUM_ID'] ?? '',
 | ||
|         'CHAPTER_ID': _currentVideoData?['CHAPTER_ID'] ?? '',
 | ||
|         'VIDEOTIME': _currentVideoData?['VIDEOTIME'] ?? 0,
 | ||
|         'IS_NODE': _hasNodes,
 | ||
|         'FIRST_INDEX': _currentFirstIndex,
 | ||
|         'NODE_INDEX': _currentNodeIndex,
 | ||
|       };
 | ||
|       await _submitPlayTime(
 | ||
|         snapshot: prevSnapshot,
 | ||
|         end: false,
 | ||
|         seconds: _lastReported.inSeconds,
 | ||
|       );
 | ||
|     }
 | ||
| 
 | ||
| 
 | ||
|     // 暂停已有播放器(安全)
 | ||
|     try {
 | ||
|       if (_videoController != null && _videoController!.value.isPlaying) {
 | ||
|         await _videoController!.pause();
 | ||
|       }
 | ||
|     } catch (_) {}
 | ||
| 
 | ||
|     await _navigateFaceIfNeeded(() async {
 | ||
|       if ((data['IS_VIDEO'] ?? 0) == 1) {
 | ||
|         // 文档
 | ||
|         if (data['VIDEOFILES'] != null) {
 | ||
|           try {
 | ||
|             await _videoController?.pause();
 | ||
|           } catch (_) {}
 | ||
|           final success = await pushPage(
 | ||
|             RemoteFilePage(
 | ||
|               fileUrl: ApiService.baseImgPath + data['VIDEOFILES'],
 | ||
|               countdownSeconds: 30,
 | ||
|             ),
 | ||
|             context,
 | ||
|           );
 | ||
|           if (success) {
 | ||
|             // 对文档直接上报完整时长(用 data 做快照)
 | ||
|             final docSnapshot = <String, dynamic>{
 | ||
|               'VIDEOCOURSEWARE_ID': data['VIDEOCOURSEWARE_ID'] ?? '',
 | ||
|               'CURRICULUM_ID': data['CURRICULUM_ID'] ?? '',
 | ||
|               'CHAPTER_ID': data['CHAPTER_ID'] ?? '',
 | ||
|               'VIDEOTIME': data['VIDEOTIME'] ?? 0,
 | ||
|               'IS_NODE': hasNodes,
 | ||
|               'FIRST_INDEX': fi,
 | ||
|               'NODE_INDEX': ni,
 | ||
|               'IS_VIDEO': 1,
 | ||
|             };
 | ||
|             await _submitPlayTime(
 | ||
|               snapshot: docSnapshot,
 | ||
|               end: true,
 | ||
|               seconds: int.parse('${data['VIDEOTIME'] ?? '0'}'),
 | ||
|             );
 | ||
|           }
 | ||
| 
 | ||
|         } else {
 | ||
|           ToastUtil.showNormal(context, '课件文件资源已失效,请联系管理员');
 | ||
|         }
 | ||
|       } else {
 | ||
|         _currentVideoData = data;
 | ||
|         _lastReported = Duration.zero;
 | ||
| 
 | ||
|         _hasNodes = hasNodes;
 | ||
|         _currentFirstIndex = fi;
 | ||
|         _currentNodeIndex = ni;
 | ||
|         LoadingDialogHelper.show();
 | ||
|         // 视频
 | ||
|         await _getVideoPlayInfo(data['VIDEOCOURSEWARE_ID']);
 | ||
|         LoadingDialogHelper.hide();
 | ||
|         _startFaceTimer();
 | ||
|       }
 | ||
|     });
 | ||
|   }
 | ||
| 
 | ||
|   Future<void> _navigateFaceIfNeeded(FutureOr<void> Function() onPass) async {
 | ||
|     if (_info?['ISFACE'] == '1') {
 | ||
|       LoadingDialogHelper.show();
 | ||
|       final resData = await ApiService.fnGetUserFace();
 | ||
|       LoadingDialogHelper.hide();
 | ||
|       final pd_data = resData['pd'];
 | ||
|       if (FormUtils.hasValue(pd_data, 'USERAVATARURL')) {
 | ||
|         // 先退出可能的全屏并锁定为竖屏,避免横屏导致人脸页面布局错乱
 | ||
|         await _exitTopRouteAndWait();
 | ||
|         await _lockPortrait();
 | ||
|         final passed = await pushPage<bool>(
 | ||
|           FaceRecognitionPage(studentId: widget.studentId,
 | ||
|               VIDEOCOURSEWARE_ID:"",CURRICULUM_ID:"",
 | ||
|               CHAPTER_ID: "",CLASS_ID: "",
 | ||
|               mode: FaceMode.auto),
 | ||
|           context,
 | ||
|         );
 | ||
|         await _restoreDefaultOrientations();
 | ||
| 
 | ||
|         if (passed == true) {
 | ||
|           LoadingDialogHelper.show();
 | ||
|           await ApiService.fnSetUserFaceTime(_faceTime);
 | ||
|           await onPass();
 | ||
|         } else {
 | ||
|           ToastUtil.showError(context, '人脸验证未通过,无法继续');
 | ||
|         }
 | ||
|       } else {
 | ||
|         final ok = await CustomAlertDialog.showConfirm(
 | ||
|           context,
 | ||
|           title: '温馨提示',
 | ||
|           content: '您当前还未进行人脸认证,请先进行认证',
 | ||
|           cancelText: '取消',
 | ||
|         );
 | ||
|         if (ok) {
 | ||
|           await _exitTopRouteAndWait();
 | ||
|           await _lockPortrait();
 | ||
|           await pushPage(
 | ||
|             const FaceRecognitionPage(studentId: '',
 | ||
|                 VIDEOCOURSEWARE_ID: '',CURRICULUM_ID: '',
 | ||
|                 CHAPTER_ID: '',CLASS_ID: '',
 | ||
|                 mode: FaceMode.manual),
 | ||
|             context,
 | ||
|           );
 | ||
|           await _restoreDefaultOrientations();
 | ||
|         }
 | ||
|       }
 | ||
|     } else {
 | ||
|       await onPass();
 | ||
|     }
 | ||
|   }
 | ||
| 
 | ||
|   Future<void> _getVideoPlayInfo(String vidId) async {
 | ||
|     if (_isLoadingVideo) {
 | ||
|       debugPrint('_getVideoPlayInfo blocked: already loading a video');
 | ||
|       return;
 | ||
|     }
 | ||
|     _isLoadingVideo = true;
 | ||
| 
 | ||
|     try {
 | ||
|       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(安全顺序:removeListener -> pause -> dispose -> null)
 | ||
|       if (_videoController != null) {
 | ||
|         try {
 | ||
|           _videoController!.removeListener(_onTimeUpdate);
 | ||
|         } catch (_) {}
 | ||
|         try {
 | ||
|           if (_videoController!.value.isPlaying) {
 | ||
|             await _videoController!.pause();
 | ||
|           }
 | ||
|         } catch (_) {}
 | ||
|         try {
 | ||
|           await _videoController!.dispose();
 | ||
|         } catch (_) {}
 | ||
|         _videoController = null;
 | ||
|       }
 | ||
| 
 | ||
|       // 创建新 controller
 | ||
|       _videoController = VideoPlayerController.networkUrl(Uri.parse(url));
 | ||
|       await _videoController!.initialize();
 | ||
| 
 | ||
|       // 新增:为新视频重置上报相关标志
 | ||
|       _endReported = false;
 | ||
|       _lastReported = Duration.zero;
 | ||
|       _ongoingSubmit = null;
 | ||
| 
 | ||
|       if (mounted) setState(() {});
 | ||
| 
 | ||
|       // 直接从上次播放点 seek,并立即播放
 | ||
|       try {
 | ||
|         await _videoController!.seekTo(Duration(seconds: seen));
 | ||
|         await _videoController!.play();
 | ||
|         // 移除(防止重复)后再添加
 | ||
|         try {
 | ||
|           _videoController!.removeListener(_onTimeUpdate);
 | ||
|         } catch (_) {}
 | ||
|         _videoController!.addListener(_onTimeUpdate);
 | ||
|       } catch (e, st) {
 | ||
|         debugPrint('Error during play/seek/addListener: $e\n$st');
 | ||
|       }
 | ||
|     } catch (e, st) {
 | ||
|       debugPrint('_getVideoPlayInfo error: $e\n$st');
 | ||
|     } finally {
 | ||
|       _isLoadingVideo = false;
 | ||
|     }
 | ||
|   }
 | ||
| 
 | ||
|   // 改进版本的 _onTimeUpdate,包含周期性上报 + 接近结束时的最终上报
 | ||
|   void _onTimeUpdate() {
 | ||
|     if (_videoController == null) return;
 | ||
|     final val = _videoController!.value;
 | ||
| 
 | ||
|     if (!val.isInitialized) return;
 | ||
|     final pos = val.position;
 | ||
|     final dur = val.duration;
 | ||
| 
 | ||
|     // 周期性上报:每隔 _uploadIntervalSeconds 秒上报一次
 | ||
|     final secondsSinceLast = (pos - _lastReported).inSeconds;
 | ||
|     if (secondsSinceLast >= _uploadIntervalSeconds && !_endReported) {
 | ||
|       _lastReported = pos;
 | ||
| 
 | ||
|       // 创建快照:避免 _currentVideoData 后续被替换影响上报
 | ||
|       final snapshot = <String, dynamic>{
 | ||
|         'VIDEOCOURSEWARE_ID': _currentVideoData?['VIDEOCOURSEWARE_ID'] ?? '',
 | ||
|         'CURRICULUM_ID': _currentVideoData?['CURRICULUM_ID'] ?? '',
 | ||
|         'CHAPTER_ID': _currentVideoData?['CHAPTER_ID'] ?? '',
 | ||
|         'VIDEOTIME': _currentVideoData?['VIDEOTIME'] ?? 0,
 | ||
|         'IS_NODE': _hasNodes,
 | ||
|         'FIRST_INDEX': _currentFirstIndex,
 | ||
|         'NODE_INDEX': _currentNodeIndex,
 | ||
|       };
 | ||
| 
 | ||
|       // 串行上报:通过 _ongoingSubmit 链式调用来保证顺序
 | ||
|       _ongoingSubmit = (_ongoingSubmit ?? Future.value())
 | ||
|           .then((_) {
 | ||
|             return _submitPlayTime(
 | ||
|               snapshot: snapshot,
 | ||
|               end: false,
 | ||
|               seconds: pos.inSeconds,
 | ||
|             );
 | ||
|           })
 | ||
|           .whenComplete(() {
 | ||
|             // 完成后清理引用
 | ||
|             _ongoingSubmit = null;
 | ||
|           });
 | ||
|     }
 | ||
| 
 | ||
|     // 接近结束的检测,容差 800ms,触发一次最终结束上报
 | ||
|     if (dur != null && dur.inMilliseconds > 0) {
 | ||
|       final remainingMs = dur.inMilliseconds - pos.inMilliseconds;
 | ||
|       final endedOrClose = remainingMs <= 800;
 | ||
|       if (endedOrClose && !_endReported) {
 | ||
|         _endReported = true;
 | ||
|         final finalSeconds = (dur.inMilliseconds / 1000.0).ceil();
 | ||
| 
 | ||
|         final snapshot = <String, dynamic>{
 | ||
|           'VIDEOCOURSEWARE_ID': _currentVideoData?['VIDEOCOURSEWARE_ID'] ?? '',
 | ||
|           'CURRICULUM_ID': _currentVideoData?['CURRICULUM_ID'] ?? '',
 | ||
|           'CHAPTER_ID': _currentVideoData?['CHAPTER_ID'] ?? '',
 | ||
|           'VIDEOTIME': _currentVideoData?['VIDEOTIME'] ?? 0,
 | ||
|           'IS_NODE': _hasNodes,
 | ||
|           'FIRST_INDEX': _currentFirstIndex,
 | ||
|           'NODE_INDEX': _currentNodeIndex,
 | ||
|         };
 | ||
| 
 | ||
|         _ongoingSubmit = (_ongoingSubmit ?? Future.value())
 | ||
|             .then((_) async {
 | ||
|               await _submitPlayTime(
 | ||
|                 snapshot: snapshot,
 | ||
|                 end: true,
 | ||
|                 seconds: finalSeconds,
 | ||
|               );
 | ||
|             })
 | ||
|             .whenComplete(() {
 | ||
|               _ongoingSubmit = null;
 | ||
|               // 最终上报完成后尝试退出顶层 route(退出全屏)
 | ||
|               _exitTopRouteIfPresent();
 | ||
|             });
 | ||
| 
 | ||
|         ApiService.fnClearUserFaceTime();
 | ||
|         _faceTimer?.cancel();
 | ||
|       }
 | ||
|     }
 | ||
|   }
 | ||
| 
 | ||
|   Future<void> _submitPlayTime({
 | ||
|     required Map<String, dynamic> snapshot,
 | ||
|     required bool end,
 | ||
|     required int seconds,
 | ||
|   }) async {
 | ||
|     // snapshot 必须包含 VIDEOCOURSEWARE_ID
 | ||
|     if (snapshot['VIDEOCOURSEWARE_ID'] == null ||
 | ||
|         snapshot['VIDEOCOURSEWARE_ID'] == '') return;
 | ||
| 
 | ||
|     // 如果已经上报结束并且当前不是结束上报,则跳过非结束上报(原逻辑)
 | ||
|     if (_endReported && !end) return;
 | ||
| 
 | ||
|     // 构造要上报的参数
 | ||
|     Map data = {
 | ||
|       'VIDEOCOURSEWARE_ID': snapshot['VIDEOCOURSEWARE_ID'] ?? '',
 | ||
|       'CURRICULUM_ID': snapshot['CURRICULUM_ID'] ?? '',
 | ||
|       'CHAPTER_ID': snapshot['CHAPTER_ID'] ?? '',
 | ||
|       'RESOURCETIME': seconds,
 | ||
|       'IS_END': end ? '1' : '0',
 | ||
|       'CLASS_ID': _classId,
 | ||
|       'CLASSCURRICULUM_ID': _classCurriculumId,
 | ||
|       'STUDENT_ID': widget.studentId,
 | ||
|       'loading': false,
 | ||
|     };
 | ||
| 
 | ||
|     // 如果这是“非视频课件”(通过 snapshot['IS_VIDEO']==1 标记),
 | ||
|     // 则按照“学完即 100%”的规则单次上报并把 UI 设置为 100%。
 | ||
|     final isNonVideo = (snapshot['IS_VIDEO'] != null && snapshot['IS_VIDEO'].toString() == '1');
 | ||
| 
 | ||
|     const int maxRetries = 2;
 | ||
|     int attempt = 0;
 | ||
|     while (true) {
 | ||
|       attempt++;
 | ||
|       try {
 | ||
|         final resData = await ApiService.fnSubmitPlayTime(data);
 | ||
|         final pd = resData['pd'] ?? {};
 | ||
| 
 | ||
|         if (isNonVideo) {
 | ||
|           // 非视频课件:强制显示 100%,不依赖后端返回的 RESOURCETIME 比例
 | ||
|           final str = '100%';
 | ||
|           if (mounted) {
 | ||
|             setState(() {
 | ||
|               if (snapshot['IS_NODE'] == true) {
 | ||
|                 final fi = snapshot['FIRST_INDEX'] as int? ?? _currentFirstIndex;
 | ||
|                 final ni = snapshot['NODE_INDEX'] as int? ?? _currentNodeIndex;
 | ||
|                 if (fi >= 0 && fi < _videoList.length) {
 | ||
|                   final nodes = _videoList[fi]['nodes'] as List<dynamic>?;
 | ||
|                   if (nodes != null && ni >= 0 && ni < nodes.length) {
 | ||
|                     _videoList[fi]['nodes'][ni]['percent'] = str;
 | ||
|                   }
 | ||
|                 }
 | ||
|               } else {
 | ||
|                 final fi = snapshot['FIRST_INDEX'] as int? ?? _currentFirstIndex;
 | ||
|                 if (fi >= 0 && fi < _videoList.length) {
 | ||
|                   _videoList[fi]['percent'] = str;
 | ||
|                 }
 | ||
|               }
 | ||
|             });
 | ||
|           }
 | ||
| 
 | ||
|           // 如果后端在 pd 中表明可以考试,按之前逻辑处理(和视频一致)
 | ||
|           if (end && pd['CANEXAM'] == '1') {
 | ||
|             _videoController?.pause();
 | ||
| 
 | ||
|             final ok = await CustomAlertDialog.showConfirm(
 | ||
|               context,
 | ||
|               title: '温馨提示',
 | ||
|               content: '当前任务内所有课程均已学完,是否直接参加考试?',
 | ||
|               cancelText: '否',
 | ||
|               confirmText: '是',
 | ||
|             );
 | ||
|             if (ok) {
 | ||
|               _startExam(resData);
 | ||
|             }
 | ||
|           }
 | ||
| 
 | ||
|           break; // 非视频已上报并处理完毕 -> 退出
 | ||
|         }
 | ||
| 
 | ||
|         // 以下为视频上报原有逻辑:解析后端返回数据并更新进度
 | ||
|         final resTraw = pd['RESOURCETIME'];
 | ||
|         final resT = (resTraw is num) ? resTraw.toDouble() : double.tryParse('$resTraw') ?? seconds.toDouble();
 | ||
| 
 | ||
|         final videoTimeRaw = snapshot['VIDEOTIME'];
 | ||
|         final videoTime = (videoTimeRaw is String)
 | ||
|             ? double.tryParse(videoTimeRaw) ?? 1.0
 | ||
|             : (videoTimeRaw is num ? videoTimeRaw.toDouble() : 1.0);
 | ||
| 
 | ||
|         final comp = pd['PLAYCOUNT'] != null && pd['PLAYCOUNT'] > 0;
 | ||
|         final pctDouble = comp ? 100.0 : ((resT / (videoTime > 0 ? videoTime : 1.0)) * 100.0);
 | ||
|         // 保留两位小数并去掉不必要末尾
 | ||
|         double pctClamped = pctDouble.clamp(0.00, 100.00);
 | ||
|         // 四舍五入到 2 位小数
 | ||
|         final pctRounded = (pctClamped * 100).round() / 100;
 | ||
|         final str = '${pctRounded}%';
 | ||
| 
 | ||
|         if (mounted) {
 | ||
|           setState(() {
 | ||
|             if (snapshot['IS_NODE'] == true) {
 | ||
|               final fi = snapshot['FIRST_INDEX'] as int? ?? _currentFirstIndex;
 | ||
|               final ni = snapshot['NODE_INDEX'] as int? ?? _currentNodeIndex;
 | ||
|               if (fi >= 0 && fi < _videoList.length) {
 | ||
|                 final nodes = _videoList[fi]['nodes'] as List<dynamic>?;
 | ||
|                 if (nodes != null && ni >= 0 && ni < nodes.length) {
 | ||
|                   _videoList[fi]['nodes'][ni]['percent'] = str;
 | ||
|                 }
 | ||
|               }
 | ||
|             } else {
 | ||
|               final fi = snapshot['FIRST_INDEX'] as int? ?? _currentFirstIndex;
 | ||
|               if (fi >= 0 && fi < _videoList.length) {
 | ||
|                 _videoList[fi]['percent'] = str;
 | ||
|               }
 | ||
|             }
 | ||
|           });
 | ||
|         }
 | ||
| 
 | ||
|         // 如果是结束上报且后端标注可考试,则弹窗处理(原逻辑)
 | ||
|         if (end && pd['CANEXAM'] == '1') {
 | ||
|           _videoController?.pause();
 | ||
| 
 | ||
|           final ok = await CustomAlertDialog.showConfirm(
 | ||
|             context,
 | ||
|             title: '温馨提示',
 | ||
|             content: '当前任务内所有课程均已学完,是否直接参加考试?',
 | ||
|             cancelText: '否',
 | ||
|             confirmText: '是',
 | ||
|           );
 | ||
|           if (ok) {
 | ||
|             _startExam(resData);
 | ||
|           }
 | ||
|         }
 | ||
| 
 | ||
|         break; // 成功 -> 退出重试循环
 | ||
|       } catch (e, st) {
 | ||
|         debugPrint('submitPlayTime failed attempt $attempt: $e\n$st');
 | ||
|         if (attempt > maxRetries) {
 | ||
|           // 超过重试次数后放弃(避免无限重试)
 | ||
|           break;
 | ||
|         }
 | ||
|         await Future.delayed(Duration(milliseconds: 500 * attempt));
 | ||
|       }
 | ||
|     }
 | ||
|   }
 | ||
| 
 | ||
| 
 | ||
|   /// 开始考试
 | ||
|   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, jumpType: 2,
 | ||
|         ),
 | ||
|         context,
 | ||
|       );
 | ||
|     } else {
 | ||
|       ToastUtil.showError(context, '请求错误');
 | ||
|     }
 | ||
|   }
 | ||
| 
 | ||
|   void _exitTopRouteIfPresent() {
 | ||
|     final route = ModalRoute.of(context);
 | ||
|     if (route == null) return;
 | ||
|     // 当当前路由不是最上层时,说明有别的 route 在上面(通常是全屏播放器)
 | ||
|     if (!route.isCurrent) {
 | ||
|       // 只尝试 pop 一次(安全)
 | ||
|       try {
 | ||
|         Navigator.of(context).maybePop();
 | ||
|       } catch (_) {
 | ||
|         // 忽略异常
 | ||
|       }
 | ||
|     }
 | ||
|   }
 | ||
| 
 | ||
|   /// 退出顶部路由并短暂等待(用于退出全屏播放器等)
 | ||
|   Future<void> _exitTopRouteAndWait({int waitMs = 300}) async {
 | ||
|     _exitTopRouteIfPresent();
 | ||
|     await Future.delayed(Duration(milliseconds: waitMs));
 | ||
|   }
 | ||
| 
 | ||
|   /// 锁定为竖屏
 | ||
|   Future<void> _lockPortrait() async {
 | ||
|     await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
 | ||
|   }
 | ||
| 
 | ||
|   /// 恢复允许横竖
 | ||
|   Future<void> _restoreDefaultOrientations() async {
 | ||
|     await SystemChrome.setPreferredOrientations([
 | ||
|       DeviceOrientation.portraitUp,
 | ||
|       DeviceOrientation.landscapeLeft,
 | ||
|       DeviceOrientation.landscapeRight,
 | ||
|     ]);
 | ||
|   }
 | ||
| 
 | ||
|   void _startFaceTimer() {
 | ||
|     _faceTimer = Timer.periodic(Duration(seconds: _faceTime), (_) async {
 | ||
|       final res = await ApiService.fnGetUserFaceTime();
 | ||
|       final isPlaying = _videoController?.value.isPlaying == true;
 | ||
|       final isVideo = _currentVideoData?['IS_VIDEO'] == 0;
 | ||
|       final needAuth = FormUtils.hasValue(res, 'data');
 | ||
| 
 | ||
|       if (isPlaying && isVideo && !needAuth) {
 | ||
|         _exitTopRouteIfPresent();
 | ||
|         _videoController!.pause();
 | ||
|         _videoController!.removeListener(_onTimeUpdate);
 | ||
|         await _showFaceAuthOnce();
 | ||
|       }
 | ||
|     });
 | ||
|   }
 | ||
| 
 | ||
|   Future<void> _showFaceAuthOnce() async {
 | ||
|     // 先 cancel 定时器,避免跳转过程中再次触发
 | ||
|     _faceTimer?.cancel();
 | ||
|     _faceTimer = null;
 | ||
| 
 | ||
|     // 退出横屏/全屏并锁定竖屏,然后 push 人脸识别页面
 | ||
|     await _exitTopRouteAndWait();
 | ||
|     await _lockPortrait();
 | ||
| 
 | ||
|     final passed = await pushPage<bool>(
 | ||
|       FaceRecognitionPage(studentId: widget.studentId,
 | ||
|           VIDEOCOURSEWARE_ID: "",CURRICULUM_ID: "",
 | ||
|           CHAPTER_ID: "",CLASS_ID: "",
 | ||
|           mode: FaceMode.auto),
 | ||
|       context,
 | ||
|     );
 | ||
| 
 | ||
|     await _restoreDefaultOrientations();
 | ||
| 
 | ||
|     if (passed == true) {
 | ||
|       await ApiService.fnSetUserFaceTime(_faceTime);
 | ||
|       // 认证通过后 —— 恢复播放、重新添加 listener,并更新上报基准
 | ||
|       try {
 | ||
|         // 如果 controller 还存在且已初始化
 | ||
|         if (_videoController != null && _videoController!.value.isInitialized) {
 | ||
|           // 先确保 listener 没有重复添加
 | ||
|           try {
 | ||
|             _videoController!.removeListener(_onTimeUpdate);
 | ||
|           } catch (_) {}
 | ||
| 
 | ||
|           // 更新上报基准,避免立即触发一次短时间内的重复上报
 | ||
|           try {
 | ||
|             final currentPos = _videoController!.value.position;
 | ||
|             _lastReported = currentPos;
 | ||
|             debugPrint(
 | ||
|               'Face auth passed — resetting _lastReported to $currentPos',
 | ||
|             );
 | ||
|           } catch (_) {
 | ||
|             _lastReported = Duration.zero;
 | ||
|           }
 | ||
| 
 | ||
|           // 恢复播放并绑定 listener(保证周期性进度上报恢复)
 | ||
|           await _videoController!.play();
 | ||
|           _videoController!.addListener(_onTimeUpdate);
 | ||
|         } else {
 | ||
|           // 如果 controller 为空或未初始化,仅尝试更新 UI
 | ||
|           debugPrint(
 | ||
|             'Face auth passed but _videoController is null or not initialized',
 | ||
|           );
 | ||
|         }
 | ||
|         setState(() {});
 | ||
|       } catch (e, st) {
 | ||
|         debugPrint('Error resuming playback after face auth: $e\n$st');
 | ||
|       }
 | ||
|       // 恢复人脸验证计时器
 | ||
|       _startFaceTimer();
 | ||
|     } else {
 | ||
|       ToastUtil.showError(context, '人脸验证未通过,无法继续');
 | ||
|       if (_videoController != null) {
 | ||
|         try {
 | ||
|           _videoController?.removeListener(_onTimeUpdate);
 | ||
|         } catch (_) {}
 | ||
|         try {
 | ||
|           _videoController?.dispose();
 | ||
|         } catch (_) {}
 | ||
|         _videoController = null;
 | ||
|       }
 | ||
|       _faceTimer?.cancel();
 | ||
|       setState(() {});
 | ||
|     }
 | ||
|   }
 | ||
| 
 | ||
|   Widget _buildVideoOrCover(double containerW, double containerH) {
 | ||
|     final c = _videoController;
 | ||
|     if (c != null && c.value.isInitialized) {
 | ||
|       return VideoPlayerWidget(
 | ||
|         allowSeek: false,
 | ||
|         controller: _videoController,
 | ||
|         coverUrl:
 | ||
|             _videoCoverUrl.isNotEmpty
 | ||
|                 ? ApiService.baseImgPath + _videoCoverUrl
 | ||
|                 : ApiService.baseImgPath + (_info?['COVERPATH'] ?? ''),
 | ||
|         aspectRatio: _videoController?.value.aspectRatio ?? 16 / 9,
 | ||
|       );
 | ||
|     } else {
 | ||
|       // controller 被销毁或未初始化,显示封面
 | ||
|       return Image.network(
 | ||
|         '${ApiService.baseImgPath}${_info?['COVERPATH'] ?? ''}',
 | ||
|         fit: BoxFit.fill,
 | ||
|         width: containerW,
 | ||
|         height: containerH,
 | ||
|       );
 | ||
|     }
 | ||
|   }
 | ||
| 
 | ||
|   @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: [
 | ||
|             _buildVideoOrCover(screenWidth(context), 250),
 | ||
|             const SizedBox(height: 5),
 | ||
|             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(
 | ||
|                 controller: _tabController,
 | ||
|                 labelStyle: const TextStyle(fontSize: 16),
 | ||
|                 indicator: const UnderlineTabIndicator(
 | ||
|                   borderSide: BorderSide(width: 3.0, color: Colors.blue),
 | ||
|                   insets: EdgeInsets.symmetric(horizontal: 0.0),
 | ||
|                 ),
 | ||
|                 labelColor: Colors.blue,
 | ||
|                 unselectedLabelColor: Colors.grey,
 | ||
|                 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 Column(
 | ||
|             crossAxisAlignment: CrossAxisAlignment.start,
 | ||
|             children: [
 | ||
|               // 章节头
 | ||
|               Container(
 | ||
|                 width: double.infinity,
 | ||
|                 color: Colors.white,
 | ||
|                 padding: const EdgeInsets.symmetric(
 | ||
|                   horizontal: 16,
 | ||
|                   vertical: 12,
 | ||
|                 ),
 | ||
|                 child: Row(
 | ||
|                   children: [
 | ||
|                     Icon(
 | ||
|                       item['IS_VIDEO'] == 0
 | ||
|                           ? Icons.video_collection_rounded
 | ||
|                           : Icons.file_copy_rounded,
 | ||
|                       color: Colors.grey,
 | ||
|                       size: 20,
 | ||
|                     ),
 | ||
|                     const SizedBox(width: 5),
 | ||
|                     Text(
 | ||
|                       (item['NAME'] ?? '').toString(),
 | ||
|                       style: const TextStyle(
 | ||
|                         fontSize: 15,
 | ||
|                       ),
 | ||
|                     ),
 | ||
|                   ],
 | ||
|                 ),
 | ||
|                 // Text(
 | ||
|                 //   (item['NAME'] ?? '').toString(),
 | ||
|                 //   style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
 | ||
|                 // ),
 | ||
|               ),
 | ||
| 
 | ||
|               // 子项列表
 | ||
|               ...nodes.asMap().entries.map((e) {
 | ||
|                 final node = e.value as Map<String, dynamic>;
 | ||
|                 return _buildVideoItem(item, node, true, idx, e.key);
 | ||
|               }).toList(),
 | ||
| 
 | ||
|               const Divider(height: 1),
 | ||
|             ],
 | ||
|           );
 | ||
|         }
 | ||
| 
 | ||
|         // 没有子节点,把 item 本身当成单条记录显示
 | ||
|         return _buildVideoItem(item, item, false, idx, 0);
 | ||
|       },
 | ||
|     );
 | ||
|   }
 | ||
| 
 | ||
|   Widget _buildVideoItem(
 | ||
|     Map<String, dynamic> item,
 | ||
|     Map<String, dynamic> m,
 | ||
|     bool hasNodes,
 | ||
|     int fi,
 | ||
|     int ni,
 | ||
|   ) {
 | ||
|     return Container(
 | ||
|       color: Colors.white,
 | ||
|       padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
 | ||
|       child: Column(
 | ||
|         children: [
 | ||
|           // Row(
 | ||
|           //   crossAxisAlignment: CrossAxisAlignment.start,
 | ||
|           //   children: [
 | ||
|           //     Icon(m['IS_VIDEO'] == 0 ? Icons.video_collection_rounded : Icons.file_copy_rounded, color: Colors.grey, size: 20),
 | ||
|           //     const SizedBox(width: 8),
 | ||
|           //     Expanded(
 | ||
|           //       child: Text(
 | ||
|           //         item['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: [
 | ||
|                       Expanded(
 | ||
|                         child: Text(
 | ||
|                           "进度:${m['percent']}",
 | ||
|                           style: const TextStyle(color: Colors.blue),
 | ||
|                         ),
 | ||
|                       ),
 | ||
|                       if (m['IS_VIDEO'] == 0) ...[
 | ||
|                         Text(secondsCount(m['VIDEOTIME'])),
 | ||
|                         const SizedBox(width: 20),
 | ||
|                         const Icon(Icons.play_circle, color: Colors.blue),
 | ||
|                       ],
 | ||
|                       if (m['IS_VIDEO'] != 0)
 | ||
|                         Row(
 | ||
|                           mainAxisAlignment: MainAxisAlignment.spaceBetween,
 | ||
|                           children: [Text('-'), const SizedBox(width: 50)],
 | ||
|                         ),
 | ||
|                       SizedBox(width: 20),
 | ||
|                       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']),
 | ||
|         ],
 | ||
|       ),
 | ||
|     );
 | ||
|   }
 | ||
| }
 |