import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:go_router/go_router.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/utils/img_url.dart'; import 'package:new_learn/features/conquerPackage/classExercises/widgets/exercise_styles.dart'; /// ============================================================ /// 【TransitionPage】课前/课中/课后 过渡页面 /// 对标 Vue pages/conquerPackage/transitionPage/transitionPage.vue /// /// 一、三种状态: /// status = 1 → 课前讲义(带滑动蒙层引导) /// status = 2 → 课中视频 /// status = 3 → 课后练习(含提示卡片 + 攻克标准) /// /// 二、路由参数(通过 extra 传入): /// - courseId 课程 id /// - packId 攻克包 id /// - recordId 学习记录 id /// - status 状态:1课前 / 2课中 / 3课后 /// /// 三、尺寸换算(本工程统一): /// Vue 的 N rpx → Flutter 的 (N/2).w/.h/.sp/.r /// /// 四、跳转: /// status 1/2 → 跳 beforeDuringClass 页 /// status 3 → 跳 classExercises 页 /// ============================================================ /// 过渡页状态 enum TransitionStatus { beforeClass(1), // 课前讲义 duringClass(2), // 课中视频 afterClass(3); // 课后练习 final int value; const TransitionStatus(this.value); factory TransitionStatus.fromInt(dynamic v) { final n = int.tryParse(v?.toString() ?? '') ?? 1; return TransitionStatus.values.firstWhere( (e) => e.value == n, orElse: () => TransitionStatus.beforeClass, ); } } class TransitionPage extends StatefulWidget { const TransitionPage({super.key, this.extra}); final Map? extra; @override State createState() => _TransitionPageState(); } class _TransitionPageState extends State { // ========== 颜色常量(对标 Vue common.scss / pages.json) ========== // pages.json globalStyle.backgroundColor static const Color _bgColor = Color(0xFFF8F8F8); // color-Subtitle(主标题 / 提示文案均用此色) static const Color _textColor = Color(0xFF495A7A); // color-default $default-color static const Color _primary = Color(0xFF3D7DF5); // $default-color-strong(渐变起点) static const Color _primaryStrong = Color(0xFF3373FF); static const Color _labelBg = Color(0xFFE3ECFF); static const Color _cardBg = Color(0xFFF2F7FF); // ========== 路由参数 ========== late String _courseId; late String _packId; late String _recordId; late TransitionStatus _status; // ========== 数据 ========== OvercomeRecordInfo _overcomeInfo = const OvercomeRecordInfo(); bool _loading = true; bool _showMask = true; // 课前蒙层是否显示 @override void initState() { super.initState(); _parseExtra(); WidgetsBinding.instance.addPostFrameCallback((_) { _loadData(); }); } // ===================== 初始化 ===================== void _parseExtra() { final extra = widget.extra ?? const {}; _courseId = (extra['courseId']?.toString()) ?? ''; _packId = (extra['packId']?.toString()) ?? ''; _recordId = (extra['recordId']?.toString()) ?? ''; _status = TransitionStatus.fromInt(extra['status']); } Future _loadData() async { setState(() => _loading = true); try { if (_recordId.isEmpty) { throw Exception('recordId 无效'); } final info = await BeforeDuringClassApi.instance.getRecord(_recordId); if (!mounted) return; setState(() => _overcomeInfo = info); } catch (e) { debugPrint('[TransitionPage] 加载失败: $e'); // 失败时回退到攻克列表页 if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('获取攻克信息失败:${e.toString()}'), behavior: SnackBarBehavior.floating, ), ); context.go('/conquerPackage/captureList', extra: _courseId); } } finally { if (mounted) setState(() => _loading = false); } } // ===================== 点击开始学习 ===================== void _startLearning() { if (_status == TransitionStatus.beforeClass || _status == TransitionStatus.duringClass) { context.push( '/conquerPackage/beforeDuringClass', extra: { 'status': '${_status.value}', 'courseId': _courseId, 'packId': _packId, 'recordId': _recordId, }, ); } else if (_status == TransitionStatus.afterClass) { if (_overcomeInfo.hasQuestion) { // 对标 Vue:课后练习跳 classExercises(仅 courseId/packId/recordId) context.push( '/conquerPackage/classExercises', extra: { 'courseId': _courseId, 'packId': _packId, 'recordId': _recordId, }, ); } } } // ===================== Build ===================== @override Widget build(BuildContext context) { return Scaffold( backgroundColor: _bgColor, body: Stack( children: [ SafeArea( child: Column( children: [ _buildNavBar(), Expanded( child: _loading ? const Center(child: CircularProgressIndicator()) : _buildBody(), ), ], ), ), // 课前/课中:开始学习按钮 fixed 底部 92rpx if (!_loading && (_status == TransitionStatus.beforeClass || _status == TransitionStatus.duringClass)) Positioned( left: 0, right: 0, bottom: 0, child: SafeArea( top: false, child: Padding( padding: EdgeInsets.fromLTRB(14.w, 0, 14.w, 46.h), child: SizedBox( height: 46.h, child: _buildPrimaryButton('开始学习', _startLearning), ), ), ), ), // 课前引导蒙层 if (_status == TransitionStatus.beforeClass && _showMask) _SlideMask( visible: _showMask, onClose: () => setState(() => _showMask = false), ), ], ), ); } // ===================== NavBar(对标 NavBar.vue:透明底 + return.png) ===================== Widget _buildNavBar() { return SizedBox( height: 44.h, child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ // side padding-left 24rpx + back padding-left 4rpx = 28rpx SizedBox(width: 14.w), GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => Navigator.of(context).maybePop(), child: Padding( padding: EdgeInsets.symmetric(vertical: 10.h), child: CachedNetworkImage( imageUrl: ImgUrl.build( '/StaticResource/2026-04-01/return.png', ), width: 24.w, height: 24.h, placeholder: (_, __) => Icon( Icons.arrow_back_ios, size: 18.w, color: const Color(0xCC000000), ), errorWidget: (_, __, ___) => Icon( Icons.arrow_back_ios, size: 18.w, color: const Color(0xCC000000), ), ), ), ), ], ), ); } // ===================== Body ===================== Widget _buildBody() { switch (_status) { case TransitionStatus.beforeClass: return _buildBeforeClass(); case TransitionStatus.duringClass: return _buildDuringClass(); case TransitionStatus.afterClass: return _buildAfterClass(); } } // ---------- 课前讲义 ---------- Widget _buildBeforeClass() { return SingleChildScrollView( padding: EdgeInsets.only(bottom: 120.h), child: Column( children: [ // 插图:340x298rpx,margin 117rpx 顶 / 100rpx 底 SizedBox(height: 58.5.h), _buildBacImage( '/StaticResource/2026-04-01/before-class-bac.png', width: 170.w, height: 149.h, fallback: Icons.menu_book_outlined, ), SizedBox(height: 50.h), // 标签 _buildLabel('课前讲义'), // 标题 _buildTitle(subtitle: '知识点讲义学习'), ], ), ); } // ---------- 课中视频 ---------- Widget _buildDuringClass() { return SingleChildScrollView( padding: EdgeInsets.only(bottom: 120.h), child: Column( children: [ // 插图:372x240rpx,margin 160rpx 顶 / 85rpx 底 SizedBox(height: 80.h), _buildBacImage( '/StaticResource/2026-04-01/during-class-bac.png', width: 186.w, height: 120.h, fallback: Icons.play_circle_outline, ), SizedBox(height: 42.5.h), _buildLabel('课中视频'), _buildTitle(subtitle: '知识点视频学习'), ], ), ); } // ---------- 课后练习 ---------- Widget _buildAfterClass() { final isPad = isPadDevice(context); return SingleChildScrollView( child: Column( children: [ // 插图:340x340rpx(Pad 400x400rpx),margin 110rpx 顶 SizedBox(height: 55.h), _buildBacImage( isPad ? '/StaticResource/2026-06-02/after-class-bac-Pad1780380978172.png' : '/StaticResource/2026-04-01/after-class-bac.png', width: isPad ? 200.w : 170.w, height: isPad ? 200.h : 170.h, fallback: Icons.assignment_outlined, ), // 底 margin:手机 50rpx / Pad 116rpx SizedBox(height: isPad ? 58.h : 25.h), _buildLabel('课后练习'), if (_overcomeInfo.name.isNotEmpty) _buildTitle( subtitle: '知识点题目攻克', padding: EdgeInsets.symmetric(horizontal: 5.w), ), // 提示卡片 _buildAfterTipsCard(isPad), SizedBox(height: 14.h), ], ), ); } // ===================== 公共组件 ===================== /// 顶部插图(对标各 .xxx-bac 样式,margin auto 居中) Widget _buildBacImage( String url, { required double width, required double height, required IconData fallback, }) { return Center( child: CachedNetworkImage( imageUrl: ImgUrl.build(url), width: width, height: height, fit: BoxFit.contain, placeholder: (_, __) => SizedBox.shrink(), errorWidget: (_, __, ___) => SizedBox( width: width, height: height, child: Icon(fallback, size: 80, color: _primary), ), ), ); } /// .label:120x40rpx,#E3ECFF 底,8rpx 圆角,24rpx 主色字 Widget _buildLabel(String text) { return Container( width: 60.w, height: 20.h, alignment: Alignment.center, margin: EdgeInsets.only(bottom: 12.h), decoration: BoxDecoration( color: _labelBg, borderRadius: BorderRadius.circular(4.r), ), child: Text( text, style: TextStyle( fontSize: 12.sp, color: _primary, ), ), ); } /// .color-Subtitle.font-36.font-bold600.tc:《name》+ 副标题两行 Widget _buildTitle({ required String subtitle, EdgeInsetsGeometry? padding, }) { final name = _overcomeInfo.name; final content = name.isNotEmpty ? '《$name》\n$subtitle' : subtitle; return Padding( padding: padding ?? EdgeInsets.zero, child: Text( content, textAlign: TextAlign.center, style: TextStyle( fontSize: 18.sp, fontWeight: FontWeight.w600, color: _textColor, ), ), ); } /// .default-button1: /// 高 92rpx、横向渐变 #3373FF→#3D7DF5、100rpx 全圆角、 /// 阴影 0 15rpx 30rpx rgba(61,125,245,.3)、36rpx 白字 Widget _buildPrimaryButton(String text, VoidCallback? onPressed) { return GestureDetector( behavior: HitTestBehavior.opaque, onTap: onPressed, child: Container( height: 46.h, alignment: Alignment.center, decoration: BoxDecoration( gradient: const LinearGradient( begin: Alignment.centerLeft, end: Alignment.centerRight, colors: [_primaryStrong, _primary], ), borderRadius: BorderRadius.circular(50.r), boxShadow: [ BoxShadow( color: const Color(0x4D3D7DF5), offset: Offset(0, 7.5.h), blurRadius: 15.h, ), ], ), child: Text( text, style: TextStyle( fontSize: 18.sp, fontWeight: FontWeight.w500, color: Colors.white, ), ), ), ); } // ===================== 课后提示卡片(.after-tips) ===================== Widget _buildAfterTipsCard(bool isPad) { return Align( alignment: Alignment.topCenter, child: Container( // 手机:margin 56rpx 28rpx 0;Pad:margin 260rpx auto 0,宽 1172rpx margin: isPad ? EdgeInsets.only(top: 130.h) : EdgeInsets.only(left: 14.w, right: 14.w, top: 28.h), width: isPad ? 586.w : double.infinity, decoration: BoxDecoration( color: _cardBg, borderRadius: BorderRadius.circular(12.r), image: DecorationImage( image: NetworkImage( ImgUrl.build( isPad ? '/StaticResource/2026-06-02/after-class-tips-Pad1780381600915.png' : '/StaticResource/2026-04-01/after-class-tips.png', ), ), fit: BoxFit.fitWidth, alignment: Alignment.topCenter, ), ), child: Stack( clipBehavior: Clip.none, children: [ // 内边距:手机 258rpx 28rpx 28rpx / Pad 358rpx 28rpx 28rpx Padding( padding: EdgeInsets.fromLTRB( 14.w, isPad ? 179.h : 129.h, 14.w, 14.h, ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildTipItem('包含单选、多选、判断、填空全题型'), _buildTipItem('每题展示深度解析与解题逻辑'), // 对标 Vue v-if="overcomeInfo.hasQuestion" if (_overcomeInfo.hasQuestion) Padding( padding: EdgeInsets.only(top: 15.h), child: SizedBox( width: double.infinity, height: 46.h, child: _buildPrimaryButton('正式进入攻克', _startLearning), ), ), ], ), ), // 攻克标准百分比(.after-tips-num 绝对定位) Positioned( top: isPad ? 95.h : 65.h, right: isPad ? 200.w : 87.5.w, child: Text( '${_overcomeInfo.overcomeStandard}%', style: TextStyle( fontSize: isPad ? 19.sp : 16.sp, fontWeight: FontWeight.w600, color: _primary, ), ), ), ], ), ), ); } /// .after-tips-li:28rpx 副色文字,左 padding 48rpx,底 padding 8rpx; /// ::before 圆点 8rpx #3D7DF5,left 14rpx 垂直居中 Widget _buildTipItem(String text) { return Stack( clipBehavior: Clip.none, children: [ Padding( padding: EdgeInsets.only(left: 24.w, bottom: 4.h), child: Text( text, style: TextStyle( fontSize: 14.sp, color: _textColor, ), ), ), Positioned( left: 7.w, top: 0, bottom: 0, child: Center( child: Container( width: 4.w, height: 4.w, decoration: const BoxDecoration( color: _primary, shape: BoxShape.circle, ), ), ), ), ], ); } } /// ============================================================ /// 【_SlideMask】滑动蒙层引导组件 /// 对标 Vue pages/conquerPackage/transitionPage/slide-mask.vue /// /// 功能: /// - 半透明黑色蒙层背景(rgba(0,0,0,.55)) /// - 显示引导图 /// - 向左滑动超过阈值 → 整个蒙层 0.2s 滑出屏幕并触发 onClose /// - 滑动不足阈值 → 0.15s 回弹 /// ============================================================ class _SlideMask extends StatefulWidget { const _SlideMask({ required this.visible, required this.onClose, this.slideThreshold = 80.0, }); final bool visible; final VoidCallback onClose; final double slideThreshold; @override State<_SlideMask> createState() => _SlideMaskState(); } class _SlideMaskState extends State<_SlideMask> with SingleTickerProviderStateMixin { double _offsetX = 0; double _startX = 0; bool _isSliding = false; bool _isAnimatingOut = false; late final AnimationController _controller; Animation _anim = const AlwaysStoppedAnimation(0); @override void initState() { super.initState(); _controller = AnimationController( vsync: this, duration: const Duration(milliseconds: 200), ); } @override void dispose() { _controller.dispose(); super.dispose(); } @override void didUpdateWidget(covariant _SlideMask oldWidget) { super.didUpdateWidget(oldWidget); if (widget.visible && !oldWidget.visible) { _resetState(); } } void _resetState() { _controller.stop(); _controller.reset(); _anim = const AlwaysStoppedAnimation(0); _offsetX = 0; _isSliding = false; _isAnimatingOut = false; } /// 偏移量动画(跟手结束后:回弹 / 滑出) void _animateTo( double target, { required int milliseconds, required Curve curve, VoidCallback? onCompleted, }) { _controller.duration = Duration(milliseconds: milliseconds); final animation = Tween(begin: _offsetX, end: target).animate( CurvedAnimation(parent: _controller, curve: curve), ); animation.addStatusListener((status) { if (status == AnimationStatus.completed) onCompleted?.call(); }); setState(() => _anim = animation); _controller.forward(from: 0); } void _handleTouchStart(DragStartDetails details) { if (!widget.visible || _isAnimatingOut) return; _controller.stop(); _startX = details.globalPosition.dx; _isSliding = true; // 对标 Vue:touchstart 时偏移量归零 setState(() => _offsetX = 0); } void _handleTouchUpdate(DragUpdateDetails details) { if (!widget.visible || !_isSliding || _isAnimatingOut) return; final moveX = details.globalPosition.dx; final diffX = _startX - moveX; // 仅处理向左滑动 if (diffX > 0) { setState(() => _offsetX = -diffX); } } void _handleTouchEnd(DragEndDetails details) { if (!widget.visible || !_isSliding) return; _isSliding = false; if (_offsetX.abs() >= widget.slideThreshold) { // 超过阈值 → 0.2s 滑出动画 _startSlideOut(); } else { // 不足阈值 → 0.15s ease-out 回弹 _animateTo( 0, milliseconds: 150, curve: Curves.easeOut, onCompleted: () { if (mounted) setState(() => _offsetX = 0); }, ); } } void _startSlideOut() { _isAnimatingOut = true; final screenWidth = MediaQuery.of(context).size.width; _animateTo( -screenWidth, milliseconds: 200, curve: Curves.easeInOut, onCompleted: () { if (mounted) widget.onClose(); }, ); } @override Widget build(BuildContext context) { if (!widget.visible) return const SizedBox.shrink(); return AnimatedBuilder( animation: _anim, builder: (context, child) { final dx = _isSliding ? _offsetX : _anim.value; return Positioned.fill( child: GestureDetector( onHorizontalDragStart: _handleTouchStart, onHorizontalDragUpdate: _handleTouchUpdate, onHorizontalDragEnd: _handleTouchEnd, behavior: HitTestBehavior.opaque, child: Transform.translate( offset: Offset(dx, 0), child: Container( color: const Color(0x8C000000), // 55% 透明黑 child: Stack( children: [ // 引导图:489x245rpx,bottom 374rpx,水平居中 Positioned( left: 0, right: 0, bottom: 187.h, child: Center( child: CachedNetworkImage( imageUrl: ImgUrl.build( '/StaticResource/2026-04-01/guidance.png', ), width: 244.5.w, height: 122.5.h, fit: BoxFit.contain, placeholder: (_, __) => SizedBox.shrink(), errorWidget: (_, __, ___) => SizedBox( width: 244.5.w, height: 122.5.h, child: const Icon( Icons.swipe_left, color: Colors.white, size: 60, ), ), ), ), ), ], ), ), ), ), ); }, ); } }