Files
App.K12.Student.Miniapp_Flu…/lib/features/conquerPackage/transitionPage/transition_page.dart
T
chenshuainan 847986f861 feat: 新增过渡页面路由、上传组件及完善跳转逻辑
- 为conquerPackage模块添加过渡页面路由并实现页面跳转逻辑
- 新增通用多图上传组件Upload,支持多种模式与图片操作功能
- 新增任务相关的API及Model基础文件
- 注释课前课中页面的测试试题内容
2026-09-14 09:09:25 +08:00

704 lines
21 KiB
Dart

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<String, dynamic>? extra;
@override
State<TransitionPage> createState() => _TransitionPageState();
}
class _TransitionPageState extends State<TransitionPage> {
// ========== 颜色常量 ==========
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 <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) {
// 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<double> _slideOutAnim;
@override
void initState() {
super.initState();
_slideOutController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 200),
);
_slideOutAnim = Tween<double>(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<double>(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,
),
),
),
),
),
],
),
),
),
),
);
},
);
}
}