Files
App.K12.Student.Miniapp_Flu…/lib/features/conquerPackage/transitionPage/transition_page.dart
T
2026-09-28 20:12:34 +08:00

748 lines
22 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<String, dynamic>? extra;
@override
State<TransitionPage> createState() => _TransitionPageState();
}
class _TransitionPageState extends State<TransitionPage> {
// ========== 颜色常量(对标 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 <String, dynamic>{};
_courseId = (extra['courseId']?.toString()) ?? '';
_packId = (extra['packId']?.toString()) ?? '';
_recordId = (extra['recordId']?.toString()) ?? '';
_status = TransitionStatus.fromInt(extra['status']);
}
Future<void> _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<double> _anim = const AlwaysStoppedAnimation<double>(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<double>(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<double>(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,
),
),
),
),
),
],
),
),
),
),
);
},
);
}
}