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'; /// ============================================================ /// 【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 { // ========== 颜色常量 ========== static const Color _bgColor = Color(0xFFF5F8FA); static const Color _titleColor = Color(0xFF1D2B47); static const Color _subtitleColor = Color(0xFF99A4BA); static const Color _primary = Color(0xFF3D7DF5); static const Color _labelBg = Color(0xFFE3ECFF); // ========== 路由参数 ========== 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) { // TODO(路由): classExercises 页建好后替换 // context.push('/conquerPackage/classExercises', extra: { // 'courseId': _courseId, // 'packId': _packId, // 'recordId': _recordId, // }); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('习题页待开发'), behavior: SnackBarBehavior.floating, ), ); } } } // ===================== 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(), ), ], ), ), // 课前引导蒙层 if (_status == TransitionStatus.beforeClass && _showMask) _SlideMask( visible: _showMask, onClose: () => setState(() => _showMask = false), ), ], ), ); } // ===================== NavBar ===================== Widget _buildNavBar() { return SizedBox( height: 44.h, child: Stack( children: [ 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: () => Navigator.of(context).maybePop(), padding: EdgeInsets.zero, constraints: const BoxConstraints(), ), ), ], ), ); } // ===================== Body ===================== Widget _buildBody() { switch (_status) { case TransitionStatus.beforeClass: return _buildBeforeClass(); case TransitionStatus.duringClass: return _buildDuringClass(); case TransitionStatus.afterClass: return _buildAfterClass(); } } // ---------- 课前讲义 ---------- Widget _buildBeforeClass() { return Column( children: [ // 背景插图 CachedNetworkImage( imageUrl: ImgUrl.build( '/StaticResource/2026-04-01/before-class-bac.png', ), width: 170.w, height: 149.h, fit: BoxFit.contain, placeholder: (_, __) => SizedBox.shrink(), errorWidget: (_, __, ___) => SizedBox( width: 170.w, height: 149.h, child: const Icon(Icons.menu_book_outlined, size: 80, color: _primary), ), ), SizedBox(height: 50.h), // 标签 _buildLabel('课前讲义'), SizedBox(height: 12.h), // 标题 _buildTitle( name: _overcomeInfo.name, subtitle: '知识点讲义学习', ), const Spacer(), // 开始学习按钮 _buildStartButton('开始学习'), SizedBox(height: 46.h), ], ); } // ---------- 课中视频 ---------- Widget _buildDuringClass() { return Column( children: [ // 背景插图 CachedNetworkImage( imageUrl: ImgUrl.build( '/StaticResource/2026-04-01/during-class-bac.png', ), width: 186.w, height: 120.h, fit: BoxFit.contain, placeholder: (_, __) => SizedBox.shrink(), errorWidget: (_, __, ___) => SizedBox( width: 186.w, height: 120.h, child: const Icon(Icons.play_circle_outline, size: 80, color: _primary), ), ), SizedBox(height: 42.h), _buildLabel('课中视频'), SizedBox(height: 12.h), _buildTitle( name: _overcomeInfo.name, subtitle: '知识点视频学习', ), const Spacer(), _buildStartButton('开始学习'), SizedBox(height: 46.h), ], ); } // ---------- 课后练习 ---------- Widget _buildAfterClass() { return SingleChildScrollView( child: Column( children: [ // 背景插图 CachedNetworkImage( imageUrl: ImgUrl.build( '/StaticResource/2026-04-01/after-class-bac.png', ), width: 170.w, height: 170.h, fit: BoxFit.contain, placeholder: (_, __) => SizedBox.shrink(), errorWidget: (_, __, ___) => SizedBox( width: 170.w, height: 170.h, child: const Icon(Icons.assignment_outlined, size: 80, color: _primary), ), ), SizedBox(height: 25.h), _buildLabel('课后练习'), SizedBox(height: 12.h), if (_overcomeInfo.name.isNotEmpty) _buildTitle( name: _overcomeInfo.name, subtitle: '知识点题目攻克', padding: EdgeInsets.symmetric(horizontal: 5.w), ), SizedBox(height: 28.h), // 提示卡片 _buildAfterTipsCard(), SizedBox(height: 28.h), ], ), ); } // ===================== 公共组件 ===================== Widget _buildLabel(String text) { return Container( width: 60.w, height: 20.h, alignment: Alignment.center, decoration: BoxDecoration( color: _labelBg, borderRadius: BorderRadius.circular(4.r), ), child: Text( text, style: TextStyle( fontSize: 12.sp, color: _primary, ), ), ); } Widget _buildTitle({ required String name, required String subtitle, EdgeInsetsGeometry? padding, }) { return Padding( padding: padding ?? EdgeInsets.zero, child: Column( children: [ Text( name.isNotEmpty ? '《$name》' : '', textAlign: TextAlign.center, style: TextStyle( fontSize: 18.sp, fontWeight: FontWeight.w600, color: _titleColor, height: 1.4, ), ), SizedBox(height: 4.h), Text( subtitle, textAlign: TextAlign.center, style: TextStyle( fontSize: 18.sp, fontWeight: FontWeight.w600, color: _titleColor, height: 1.4, ), ), ], ), ); } Widget _buildStartButton(String text) { return Padding( padding: EdgeInsets.symmetric(horizontal: 14.w), child: SizedBox( width: double.infinity, height: 47.h, child: FilledButton( onPressed: _startLearning, style: FilledButton.styleFrom( backgroundColor: _primary, foregroundColor: Colors.white, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(20.r), ), textStyle: TextStyle( fontSize: 18.sp, fontWeight: FontWeight.w500, ), ), child: Text(text), ), ), ); } // ===================== 课后提示卡片 ===================== Widget _buildAfterTipsCard() { return Center( child: Container( margin: EdgeInsets.symmetric(horizontal: 14.w), constraints: BoxConstraints(maxWidth: 347.w), width: double.infinity, child: LayoutBuilder( builder: (context, constraints) { // 卡片实际宽度(平板上被 maxWidth 限制,手机上 = 屏宽 - 左右边距) final cardW = constraints.maxWidth; // 设计稿卡片宽度 = 750rpx - 28rpx*2 = 694rpx = 347dp // 内部所有水平/垂直定位都按卡片宽度比例换算, // 保证背景图(fitWidth)和内部定位永远同步 double wRatio(double designPx) => cardW * designPx / 347.0; return Container( decoration: BoxDecoration( color: const Color(0xFFF2F7FF), borderRadius: BorderRadius.circular(12.r), image: DecorationImage( image: NetworkImage( ImgUrl.build( '/StaticResource/2026-04-01/after-class-tips.png', ), ), fit: BoxFit.fitWidth, alignment: Alignment.topCenter, ), ), padding: EdgeInsets.fromLTRB( wRatio(14), wRatio(129), // 背景图随宽度缩放,padding-top 也按卡片宽比例 wRatio(14), 14.h, ), child: Stack( clipBehavior: Clip.none, children: [ // 攻克标准百分比(按卡片宽度比例定位,与背景图缩放同步) Positioned( right: wRatio(75.5), top: -wRatio(64), child: Text( '${_overcomeInfo.overcomeStandard}%', style: TextStyle( fontSize: 16.sp, fontWeight: FontWeight.w600, color: _primary, ), ), ), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildTipItem('包含单选、多选、判断、填空全题型'), SizedBox(height: 4.h), _buildTipItem('每题展示深度解析与解题逻辑'), SizedBox(height: 15.h), // 正式进入攻克按钮 Opacity( opacity: _overcomeInfo.hasQuestion ? 1.0 : 0.6, child: SizedBox( width: double.infinity, height: 47.h, child: FilledButton( onPressed: _overcomeInfo.hasQuestion ? _startLearning : null, style: FilledButton.styleFrom( backgroundColor: _primary, foregroundColor: Colors.white, disabledBackgroundColor: _primary, disabledForegroundColor: Colors.white, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(20.r), ), textStyle: TextStyle( fontSize: 18.sp, fontWeight: FontWeight.w500, ), ), child: const Text('正式进入攻克'), ), ), ), ], ), ], ), ); }, ), ), ); } Widget _buildTipItem(String text) { return Padding( padding: EdgeInsets.only(left: 24.w, bottom: 4.h), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Container( width: 4.w, height: 4.w, margin: EdgeInsets.only(right: 13.w), decoration: const BoxDecoration( color: _primary, shape: BoxShape.circle, ), ), Expanded( child: Text( text, style: TextStyle( fontSize: 14.sp, color: _subtitleColor, height: 1.5, ), ), ), ], ), ); } } /// ============================================================ /// 【_SlideMask】滑动蒙层引导组件 /// 对标 Vue pages/conquerPackage/transitionPage/slide-mask.vue /// /// 功能: /// - 半透明黑色蒙层背景 /// - 显示引导图 /// - 向左滑动超过阈值 → 整个蒙层滑出屏幕并触发 onClose /// - 滑动不足阈值 → 回弹 /// ============================================================ 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 _slideOutController; late Animation _slideOutAnim; @override void initState() { super.initState(); _slideOutController = AnimationController( vsync: this, duration: const Duration(milliseconds: 200), ); _slideOutAnim = Tween(begin: 0, end: 0).animate( CurvedAnimation(parent: _slideOutController, curve: Curves.easeInOut), ); } @override void dispose() { _slideOutController.dispose(); super.dispose(); } @override void didUpdateWidget(covariant _SlideMask oldWidget) { super.didUpdateWidget(oldWidget); if (widget.visible && !oldWidget.visible) { _offsetX = 0; _isAnimatingOut = false; _slideOutController.reset(); } } void _handleTouchStart(DragStartDetails details) { if (!widget.visible || _isAnimatingOut) return; _startX = details.globalPosition.dx; _isSliding = true; } 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) { // 超过阈值 → 滑出动画 _startSlideOut(); } else { // 回弹 setState(() => _offsetX = 0); } } void _startSlideOut() { _isAnimatingOut = true; final screenWidth = MediaQuery.of(context).size.width; _slideOutAnim = Tween(begin: _offsetX, end: -screenWidth).animate( CurvedAnimation(parent: _slideOutController, curve: Curves.easeInOut), ); _slideOutController.forward().then((_) { widget.onClose(); }); } @override Widget build(BuildContext context) { if (!widget.visible) return const SizedBox.shrink(); return AnimatedBuilder( animation: _slideOutAnim, builder: (context, child) { final dx = _isAnimatingOut ? _slideOutAnim.value : _offsetX; 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: [ // 引导图 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, ), ), ), ), ), ], ), ), ), ), ); }, ); } }