import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:new_learn/core/api/conquerPackage/beforeDuringClass/api.dart'; import 'package:new_learn/core/api/conquerPackage/beforeDuringClass/model.dart'; import 'package:new_learn/core/api/public_upload/model.dart'; import 'package:new_learn/core/components/center_toast.dart'; import 'package:new_learn/core/components/confirm_box.dart'; import 'package:new_learn/core/components/upload.dart'; import 'package:new_learn/core/utils/img_url.dart'; import 'package:new_learn/features/conquerPackage/beforeDuringClass/before_during_page.dart'; // ===================== 自定义滑动物理 ===================== /// 单向滑动控制: /// - 向右滑(上一页,index 减小)始终允许 /// - 向左滑(下一页,index 增大)需要满足时长 + 笔记条件 /// ============================================================ /// 【BeforeDuringClassPage】课前课中(讲义学习)页面 /// 对标 Vue pages/conquerPackage/beforeDuringClass/beforeDuringClass.vue /// /// 一、页面结构: /// ┌──────────────────────────────────┐ /// │ HeaderBac 背景图 + NavBar │ /// │ ┌────────────────────────────┐ │ /// │ │ 计时图标+时间 进度条 1/10 │ │ /// │ └────────────────────────────┘ │ /// ├──────────────────────────────────┤ /// │ │ /// │ PageView.builder (懒加载) │ /// │ 每页一个 BeforeDuringPage │ /// │ (仅构建视口 ±1 页,滑出回收) │ /// │ │ /// ├──────────────────────────────────┤ /// │ 底部操作栏:上传笔记 / 上一步 / │ /// │ 下一步(最后一页→完成学习) │ /// └──────────────────────────────────┘ /// /// 二、核心功能: /// 1. PageView.builder 懒加载:physics 禁止滑动(按钮翻页), /// allowImplicitScrolling=true 预构建相邻页 /// 2. 学习计时:Ticker 驱动,onPageChange 结算时长 /// 3. 建议时长倒计时:当前页 planStudyTimeSecond 剩余时间 /// 4. 笔记必传校验:isUploadNotes 页必须有笔记才能进下一页 /// 5. 时长不足提示弹窗(可跳过) /// 6. 笔记必传提示弹窗 /// /// 三、尺寸换算(本工程统一): /// Vue 的 N rpx → Flutter 的 (N/2).w/.h/.sp/.r /// /// 四、路由参数(通过 extra 传入): /// - courseId 课程 id /// - packId 攻克包 id /// - recordId 学习记录 id /// - type edit / view(默认 edit) /// - isWrong 是否错题模式(默认 false) /// ============================================================ /// 页面模式 enum BeforeDuringMode { edit, view } class BeforeDuringClassPage extends StatefulWidget { const BeforeDuringClassPage({super.key, this.extra}); final Map? extra; @override State createState() => _BeforeDuringClassPageState(); } class _BeforeDuringClassPageState extends State with TickerProviderStateMixin { // ========== 颜色常量 ========== static const Color _bgColor = Color(0xFFF5F8FA); static const Color _titleColor = Color(0xFF1D2B47); static const Color _subtitleColor = Color(0xFF99A4BA); static const Color _primary = Color(0xFF3D7DF5); // ========== Controller ========== final PageController _pageController = PageController(); final Map> _pageKeys = {}; // ========== 路由参数 ========== late BeforeDuringMode _mode; late String _courseId; late String _packId; late String _recordId; bool _isWrong = false; // ========== 数据 ========== List _pageList = []; int _currentIndex = 0; bool _loading = true; // ========== 全局学习计时 ========== late final Ticker _timingTicker; Duration _timingOffset = Duration.zero; Duration _timingElapsed = Duration.zero; bool _timingRunning = false; // ========== 当前页倒计时 ========== late final Ticker _countdownTicker; Duration _countdownRemaining = Duration.zero; int _lastCountdownTickSecond = 0; DateTime _pageStartTime = DateTime.now(); bool _isNavigatingAway = false; @override void initState() { super.initState(); _parseExtra(); // 全局学习计时 _timingTicker = createTicker((elapsed) { if (_timingRunning) { _timingElapsed = elapsed + _timingOffset; setState(() {}); } }); if (_mode == BeforeDuringMode.edit) { _timingTicker.start(); _timingRunning = true; } // 单页倒计时(基于真实流逝时间,每秒更新 UI) _countdownTicker = createTicker((elapsed) { final elapsedSeconds = elapsed.inSeconds; if (_lastCountdownTickSecond == elapsedSeconds) return; _lastCountdownTickSecond = elapsedSeconds; if (_countdownRemaining.inSeconds > 0) { _countdownRemaining -= const Duration(seconds: 1); setState(() {}); } else { _countdownTicker.stop(); _markCurrentPagePlanTimeFinished(); } }); WidgetsBinding.instance.addPostFrameCallback((_) { _loadData(); }); } @override void dispose() { _timingTicker.dispose(); _countdownTicker.dispose(); _pageController.dispose(); super.dispose(); } // ===================== 初始化 ===================== void _parseExtra() { final extra = widget.extra ?? const {}; _courseId = (extra['courseId']?.toString()) ?? ''; _packId = (extra['packId']?.toString()) ?? ''; _recordId = (extra['recordId']?.toString()) ?? ''; _isWrong = extra['isWrong'] == true || extra['isWrong'] == 'true'; final typeStr = (extra['type'] as String?) ?? 'edit'; _mode = typeStr == 'view' ? BeforeDuringMode.view : BeforeDuringMode.edit; } Future _loadData() async { setState(() => _loading = true); try { final HandoutListResponse resp; if (_mode == BeforeDuringMode.view) { resp = await BeforeDuringClassApi.instance.getSubmitReport(_recordId); } else { resp = await BeforeDuringClassApi.instance .getListByRecordHandout(_recordId); } _pageList = List.from(resp.items); // _pageList = List.from(resp.items).map((item) { // return item.copyWith( // isUploadNotes: true, // isFinishPlanTime: false, // planStudyTimeSecond: 900, // studyTimeSecond: 0, // ); // }).toList(); // 测试:复制一份追加到数组末尾(翻倍页数) // _pageList.addAll(_pageList.map((item) => item.copyWith()).toList()); // 汇总已有学习时长 if (_mode == BeforeDuringMode.edit) { final totalSeconds = _pageList.fold( 0, (acc, item) => acc + item.studyTimeSecond, ); _timingOffset = Duration(seconds: totalSeconds); } if (_pageList.isEmpty) { _goTransitionPage(); return; } _pageStartTime = DateTime.now(); _startCountdownForCurrentPage(); } catch (e) { debugPrint('[BeforeDuringClass] 加载失败: $e'); if (mounted) { CenterToast.show(context, '加载失败,请重试'); } } finally { if (mounted) setState(() => _loading = false); } } // ===================== 计时工具 ===================== String get _timingText => _formatDuration(_timingElapsed); String _formatDuration(Duration d) { final m = d.inMinutes.remainder(60).toString().padLeft(2, '0'); final s = d.inSeconds.remainder(60).toString().padLeft(2, '0'); if (d.inHours > 0) { final h = d.inHours.toString().padLeft(2, '0'); return '$h:$m:$s'; } return '$m:$s'; } // ===================== 倒计时 ===================== void _startCountdownForCurrentPage() { if (_mode == BeforeDuringMode.view) return; final page = _currentPage; if (page == null) return; if (page.planStudyTimeSecond <= 0 || page.isFinishPlanTime) { _countdownRemaining = Duration.zero; return; } final remain = page.planStudyRemainTimeSecond > 0 ? page.planStudyRemainTimeSecond : page.planStudyTimeSecond - page.studyTimeSecond; if (remain <= 0) { _markCurrentPagePlanTimeFinished(); _countdownRemaining = Duration.zero; return; } _countdownRemaining = Duration(seconds: remain); _lastCountdownTickSecond = 0; if (!_countdownTicker.isActive) { _countdownTicker.start(); } } void _markCurrentPagePlanTimeFinished() { if (_currentIndex >= _pageList.length) return; final page = _pageList[_currentIndex]; if (page.isFinishPlanTime) return; _pageList[_currentIndex] = page.copyWith( isFinishPlanTime: true, planStudyRemainTimeSecond: 0, ); setState(() {}); } void _saveCurrentPageRemainTime() { if (_mode == BeforeDuringMode.view) return; if (_currentIndex >= _pageList.length) return; final page = _pageList[_currentIndex]; if (page.planStudyTimeSecond <= 0 || page.isFinishPlanTime) return; final remain = _countdownRemaining.inSeconds; if (remain > 0 && remain <= page.planStudyTimeSecond) { _pageList[_currentIndex] = page.copyWith(planStudyRemainTimeSecond: remain); } } String get _countdownText { final d = _countdownRemaining; final m = d.inMinutes.remainder(60).toString().padLeft(2, '0'); final s = d.inSeconds.remainder(60).toString().padLeft(2, '0'); return '$m:$s'; } // ===================== 计算属性 ===================== int get _totalCount => _pageList.length; int get _currentLabel => _totalCount == 0 ? 0 : _currentIndex + 1; double get _percentage => _totalCount == 0 ? 0 : _currentLabel / _totalCount; HandoutItem? get _currentPage => _currentIndex < _pageList.length ? _pageList[_currentIndex] : null; String get _title { if (_mode == BeforeDuringMode.view) { return _isWrong ? '错题解析' : '全部解析'; } return _currentPage?.title ?? ''; } /// 最低时长是否未完成 bool get _isTimeDisabled { if (_mode == BeforeDuringMode.view) return false; final p = _currentPage; if (p == null) return false; if (p.planStudyTimeSecond > 0 && !p.isFinishPlanTime) return true; return false; } /// 是否必须上传笔记但还没传 bool get _isNoteDisabled { if (_mode == BeforeDuringMode.view) return false; final p = _currentPage; if (p == null) return false; if (p.isUploadNotes && p.notes.isEmpty) return true; return false; } /// 滑动到下一页是否被拦截(时长不足 或 笔记必传未传,都不能滑) bool get _isSwipeNextBlocked { if (_mode == BeforeDuringMode.view) return false; return _isTimeDisabled || _isNoteDisabled; } /// 倒计时气泡是否显示 bool get _showTimeBubble { if (_mode == BeforeDuringMode.view) return false; final p = _currentPage; if (p == null) return false; if (p.planStudyTimeSecond <= 0 || p.isFinishPlanTime) return false; return _countdownRemaining.inSeconds > 0; } // ===================== 页面切换 ===================== void _onPageChanged(int index) { if (index == _currentIndex) return; _saveCurrentPageRemainTime(); _pausePageMedia(_currentIndex); _renewHandoutTimeForIndex(_currentIndex); setState(() { _currentIndex = index; }); _pageStartTime = DateTime.now(); _startCountdownForCurrentPage(); } /// 手势滑动翻页(自己控制,不受 PageView physics 影响) /// - 向右滑(primaryVelocity > 0)→ 上一页,始终允许 /// - 向左滑(primaryVelocity < 0)→ 下一页,需满足时长 + 笔记条件 void _onHorizontalDragEnd(DragEndDetails details) { final velocity = details.primaryVelocity ?? 0; const kMinVelocity = 200.0; // 最小滑动速度阈值 if (velocity.abs() < kMinVelocity) return; // 滑动速度不够,忽略 if (velocity > 0) { // 向右滑 → 上一页 if (_currentIndex > 0) { _pageController.animateToPage( _currentIndex - 1, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ); } } else { // 向左滑 → 下一页(时长不足或笔记必传未传,都不能滑) if (_currentIndex < _pageList.length - 1 && !_isSwipeNextBlocked) { _pageController.animateToPage( _currentIndex + 1, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ); } } } Future _renewHandoutTimeForIndex(int index) async { if (_mode == BeforeDuringMode.view) return; if (index >= _pageList.length) return; final page = _pageList[index]; if (page.id.isEmpty) return; try { await BeforeDuringClassApi.instance.renewHandoutTime( id: page.id, courseId: _courseId, startTime: _pageStartTime.toIso8601String(), endTime: DateTime.now().toIso8601String(), ); } catch (e) { debugPrint('[BeforeDuringClass] RenewTime 失败: $e'); } } void _pausePageMedia(int index) { _pageKeys[index]?.currentState?.pauseMedia(); } // ===================== 底部按钮操作 ===================== void _onUploadNotes() { _pageKeys[_currentIndex]?.currentState?.uploadLectureNotes(); } Future _onPrevious() async { if (_currentIndex == 0) return; _saveCurrentPageRemainTime(); _pausePageMedia(_currentIndex); _pageController.previousPage( duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, ); } Future _onNext() async { // 上传中禁止跳转 final currentState = _pageKeys[_currentIndex]?.currentState; if (currentState != null && currentState.getIsUploading()) { CenterToast.show(context, '图片上传中,请稍后再试'); return; } // 时长不足(可跳过) if (_isTimeDisabled) { final timeText = _formatDuration( Duration(seconds: _currentPage?.planStudyTimeSecond ?? 0), ); final ok = await showConfirmBox( context, title: '建议学习时长 $timeText', content: '仓促速学掌握不牢,之后还要重新复习\n本次攻克成功,学习时长会逐步减少', cancelText: '继续学习', confirmText: '跳过', ); if (ok != true) return; } // 笔记必传(不可跳过,放在时长之后,点跳过时长后仍会检查) if (_isNoteDisabled) { final ok = await showConfirmBox( context, title: '该讲义必须上传笔记', content: '上传笔记,有助于更好记录课程内容,提升学习效率', cancelText: '取消', confirmText: '上传笔记', ); if (ok == true) { _onUploadNotes(); } return; } // 最后一页 → 完成学习 if (_currentIndex == _pageList.length - 1) { if (_mode == BeforeDuringMode.edit) { await _finishAll(); } return; } _saveCurrentPageRemainTime(); _pausePageMedia(_currentIndex); _pageController.nextPage( duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, ); } Future _finishAll() async { _isNavigatingAway = true; _saveCurrentPageRemainTime(); _pausePageMedia(_currentIndex); await _renewHandoutTimeForIndex(_currentIndex); if (mounted) { _goTransitionPage(); } } void _goTransitionPage() { // TODO: 跳转到 transition 页,等路由配置好后替换 Navigator.of(context).pop(); } // ===================== 笔记变化 ===================== void _onUploadChanged(List list) { if (_currentIndex >= _pageList.length) return; final page = _pageList[_currentIndex]; _pageList[_currentIndex] = page.copyWith( notes: list .where((item) => item.id != null) .map((item) => NoteImage( id: item.id, url: item.url, thumbnailUrl: item.thumbnailUrl, )) .toList(), ); setState(() {}); } // ===================== 返回 ===================== Future _onWillPop() async { if (_mode == BeforeDuringMode.edit && !_isNavigatingAway) { _saveCurrentPageRemainTime(); await _renewHandoutTimeForIndex(_currentIndex); } return true; } // ===================== Build ===================== @override Widget build(BuildContext context) { return PopScope( canPop: true, onPopInvoked: (didPop) async { if (didPop) return; await _onWillPop(); }, child: Scaffold( backgroundColor: _bgColor, body: _loading ? const Center(child: CircularProgressIndicator()) : _pageList.isEmpty ? const Center(child: Text('暂无讲义')) : Stack( children: [ // 顶部背景图 Positioned( top: 0, left: 0, right: 0, child: CachedNetworkImage( imageUrl: ImgUrl.build( '/StaticResource/2026-05-26/my-header-bg-default1779775984375.png', ), fit: BoxFit.fitWidth, width: double.infinity, placeholder: (_, __) => const SizedBox.shrink(), errorWidget: (_, __, ___) => const SizedBox.shrink(), ), ), SafeArea( child: Column( children: [ // 导航栏 _buildNavBar(), // 计时 + 进度 _buildHeader(), // 主体(外层手势识别,自己控制左右滑动) Expanded( child: GestureDetector( onHorizontalDragEnd: _onHorizontalDragEnd, behavior: HitTestBehavior.translucent, child: PageView.builder( controller: _pageController, itemCount: _pageList.length, onPageChanged: _onPageChanged, physics: const NeverScrollableScrollPhysics(), allowImplicitScrolling: true, itemBuilder: (context, index) { _pageKeys[index] ??= GlobalKey(); return BeforeDuringPage( key: _pageKeys[index], data: _pageList[index], isCurrent: index == _currentIndex, onUploadChanged: _onUploadChanged, ); }, ), ), ), // 底部操作栏 _buildFooter(), ], ), ), ], ), ), ); } // ===================== NavBar ===================== Widget _buildNavBar() { return SizedBox( height: 44.h, child: Stack( children: [ Center( child: Text( _title, style: const TextStyle( color: Color.fromRGBO(0, 0, 0, 0.8), fontSize: 18, fontWeight: FontWeight.w600, ), ), ), Positioned( left: 16.w, top: 0, bottom: 0, child: IconButton( icon: const Icon(Icons.arrow_back_ios, color: Color.fromRGBO(0, 0, 0, 0.8), size: 20), onPressed: () async { await _onWillPop(); if (mounted) Navigator.of(context).maybePop(); }, padding: EdgeInsets.zero, constraints: const BoxConstraints(), ), ), ], ), ); } // ===================== Header(计时 + 进度) ===================== Widget _buildHeader() { return Padding( padding: EdgeInsets.fromLTRB(14.w, 4.h, 14.w, 10.h), child: Row( children: [ // 左侧:计时 _buildTiming(), const Spacer(), // 右侧:进度条 + 页码 _buildProgress(), ], ), ); } Widget _buildTiming() { if (_mode == BeforeDuringMode.view) { return const SizedBox.shrink(); } return Container( padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 6.h), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(30.r), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ CachedNetworkImage( imageUrl: ImgUrl.build('/StaticResource/2026-04-02/timing.png'), width: 18.w, height: 18.w, errorWidget: (_, __, ___) => Icon(Icons.timer, size: 18.w, color: _primary), ), SizedBox(width: 5.w), Text( _timingText, style: TextStyle( fontSize: 13.sp, color: _titleColor, fontWeight: FontWeight.w500, ), ), ], ), ); } Widget _buildProgress() { return Row( mainAxisSize: MainAxisSize.min, children: [ SizedBox( width: 150.w, child: ClipRRect( borderRadius: BorderRadius.circular(4.r), child: LinearProgressIndicator( value: _percentage, minHeight: 4, backgroundColor: Colors.white, valueColor: const AlwaysStoppedAnimation(_primary), ), ), ), SizedBox(width: 10.w), RichText( text: TextSpan( children: [ TextSpan( text: '$_currentLabel', style: TextStyle( fontSize: 15.sp, fontWeight: FontWeight.w600, color: _titleColor, ), ), TextSpan( text: '/$_totalCount', style: TextStyle( fontSize: 12.sp, color: _subtitleColor, ), ), ], ), ), ], ); } // ===================== Footer ===================== Widget _buildFooter() { return SafeArea( top: false, child: Container( padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 10.h), decoration: BoxDecoration( color: Colors.white, border: Border( top: BorderSide(color: const Color(0xFFEEEEEE), width: 1.w), ), ), child: Row( children: [ // 上传笔记按钮 if (_mode == BeforeDuringMode.edit) _buildUploadNoteButton(), if (_mode == BeforeDuringMode.edit) SizedBox(width: 10.w), // 上一步 Expanded( child: _buildStepButton( text: '上一步', disabled: _currentIndex == 0, onTap: _onPrevious, isPrimary: false, ), ), SizedBox(width: 10.w), // 下一步 Expanded( child: _buildNextButton(), ), ], ), ), ); } Widget _buildUploadNoteButton() { return GestureDetector( onTap: _onUploadNotes, behavior: HitTestBehavior.opaque, child: Column( mainAxisSize: MainAxisSize.min, children: [ CachedNetworkImage( imageUrl: ImgUrl.build('/StaticResource/2026-04-02/note.png'), width: 22.w, height: 22.w, errorWidget: (_, __, ___) => Icon( Icons.note_add_outlined, size: 22.w, color: _primary, ), ), SizedBox(height: 3.h), Text( '上传笔记', style: TextStyle( fontSize: 10.sp, color: _titleColor, ), ), ], ), ); } Widget _buildStepButton({ required String text, required bool disabled, required VoidCallback onTap, required bool isPrimary, }) { final bgColor = isPrimary ? _primary : const Color(0xFFF0F2F5); final textColor = isPrimary ? Colors.white : _titleColor; return Opacity( opacity: disabled ? 0.5 : 1.0, child: FilledButton( onPressed: disabled ? null : onTap, style: FilledButton.styleFrom( backgroundColor: bgColor, foregroundColor: textColor, disabledBackgroundColor: bgColor, disabledForegroundColor: textColor, minimumSize: Size.fromHeight(47.h), padding: EdgeInsets.zero, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(20.r), ), textStyle: TextStyle( fontSize: 13.sp, fontWeight: FontWeight.w500, ), ), child: Text(text), ), ); } Widget _buildNextButton() { final isLast = _currentIndex == _pageList.length - 1; final text = isLast && _mode == BeforeDuringMode.edit ? '完成学习' : '下一步'; // 只有 view 模式最后一页才禁用;edit 模式永远可点(点了弹引导弹窗) final disabled = _mode == BeforeDuringMode.view && isLast; return Stack( clipBehavior: Clip.none, children: [ _buildStepButton( text: text, disabled: disabled, onTap: _onNext, isPrimary: true, ), if (_showTimeBubble) Positioned( right: 4.w, top: -12.h, child: Container( height: 20.h, padding: EdgeInsets.fromLTRB(8.w, 0, 8.w, 3.h), alignment: Alignment.center, decoration: BoxDecoration( image: DecorationImage( image: NetworkImage( ImgUrl.build('/StaticResource/2026-04-17/bubble.png'), ), fit: BoxFit.fill, ), ), child: Transform.translate( offset: Offset(0, -1.h), child: Text( _countdownText, style: TextStyle( color: Colors.white, fontSize: 11.sp, ), ), ), ), ), ], ); } }