This commit is contained in:
chenshuainan
2026-09-28 20:12:34 +08:00
parent 847986f861
commit 5f53bd8f94
60 changed files with 14624 additions and 398 deletions
@@ -5,6 +5,7 @@ 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】课前/课中/课后 过渡页面
@@ -57,12 +58,17 @@ class TransitionPage extends StatefulWidget {
}
class _TransitionPageState extends State<TransitionPage> {
// ========== 颜色常量 ==========
static const Color _bgColor = Color(0xFFF5F8FA);
static const Color _titleColor = Color(0xFF1D2B47);
static const Color _subtitleColor = Color(0xFF99A4BA);
// ========== 颜色常量(对标 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;
@@ -136,17 +142,14 @@ class _TransitionPageState extends State<TransitionPage> {
);
} 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,
),
// 对标 Vue:课后练习跳 classExercises(仅 courseId/packId/recordId)
context.push(
'/conquerPackage/classExercises',
extra: {
'courseId': _courseId,
'packId': _packId,
'recordId': _recordId,
},
);
}
}
@@ -172,6 +175,25 @@ class _TransitionPageState extends State<TransitionPage> {
],
),
),
// 课前/课中:开始学习按钮 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(
@@ -183,23 +205,38 @@ class _TransitionPageState extends State<TransitionPage> {
);
}
// ===================== NavBar =====================
// ===================== NavBar(对标 NavBar.vue:透明底 + return.png) =====================
Widget _buildNavBar() {
return SizedBox(
height: 44.h,
child: Stack(
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
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(),
// 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),
),
),
),
),
],
@@ -223,111 +260,79 @@ class _TransitionPageState extends State<TransitionPage> {
// ---------- 课前讲义 ----------
Widget _buildBeforeClass() {
return Column(
children: [
// 背景插图
CachedNetworkImage(
imageUrl: ImgUrl.build(
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,
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),
fallback: Icons.menu_book_outlined,
),
),
SizedBox(height: 50.h),
// 标签
_buildLabel('课前讲义'),
SizedBox(height: 12.h),
// 标题
_buildTitle(
name: _overcomeInfo.name,
subtitle: '知识点讲义学习',
),
const Spacer(),
// 开始学习按钮
_buildStartButton('开始学习'),
SizedBox(height: 46.h),
],
SizedBox(height: 50.h),
// 标签
_buildLabel('课前讲义'),
// 标题
_buildTitle(subtitle: '知识点讲义学习'),
],
),
);
}
// ---------- 课中视频 ----------
Widget _buildDuringClass() {
return Column(
children: [
// 背景插图
CachedNetworkImage(
imageUrl: ImgUrl.build(
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,
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),
fallback: Icons.play_circle_outline,
),
),
SizedBox(height: 42.h),
_buildLabel('课中视频'),
SizedBox(height: 12.h),
_buildTitle(
name: _overcomeInfo.name,
subtitle: '知识点视频学习',
),
const Spacer(),
_buildStartButton('开始学习'),
SizedBox(height: 46.h),
],
SizedBox(height: 42.5.h),
_buildLabel('课中视频'),
_buildTitle(subtitle: '知识点视频学习'),
],
),
);
}
// ---------- 课后练习 ----------
Widget _buildAfterClass() {
final isPad = isPadDevice(context);
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),
),
// 插图: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,
),
SizedBox(height: 25.h),
// 底 margin:手机 50rpx / Pad 116rpx
SizedBox(height: isPad ? 58.h : 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),
_buildAfterTipsCard(isPad),
SizedBox(height: 14.h),
],
),
);
@@ -335,11 +340,36 @@ class _TransitionPageState extends State<TransitionPage> {
// ===================== 公共组件 =====================
/// 顶部插图(对标各 .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),
@@ -354,191 +384,171 @@ class _TransitionPageState extends State<TransitionPage> {
);
}
/// .color-Subtitle.font-36.font-bold600.tc:《name》+ 副标题两行
Widget _buildTitle({
required String name,
required String subtitle,
EdgeInsetsGeometry? padding,
}) {
final name = _overcomeInfo.name;
final content = name.isNotEmpty ? '《$name》\n$subtitle' : subtitle;
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),
child: Text(
content,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.w600,
color: _textColor,
),
),
);
}
// ===================== 课后提示卡片 =====================
Widget _buildAfterTipsCard() {
return Center(
/// .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(
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;
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,
),
),
),
);
}
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,
),
// ===================== 课后提示卡片(.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(
wRatio(14),
wRatio(129), // 背景图随宽度缩放,padding-top 也按卡片宽比例
wRatio(14),
14.w,
isPad ? 179.h : 129.h,
14.w,
14.h,
),
child: Stack(
clipBehavior: Clip.none,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 攻克标准百分比(按卡片宽度比例定位,与背景图缩放同步)
Positioned(
right: wRatio(75.5),
top: -wRatio(64),
child: Text(
'${_overcomeInfo.overcomeStandard}%',
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w600,
color: _primary,
_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),
),
),
),
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('正式进入攻克'),
),
),
),
],
),
],
),
);
},
),
// 攻克标准百分比(.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 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,
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,
),
),
Expanded(
child: Text(
text,
style: TextStyle(
fontSize: 14.sp,
color: _subtitleColor,
height: 1.5,
),
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,
),
),
),
],
),
),
],
);
}
}
@@ -548,10 +558,10 @@ class _TransitionPageState extends State<TransitionPage> {
/// 对标 Vue pages/conquerPackage/transitionPage/slide-mask.vue
///
/// 功能:
/// - 半透明黑色蒙层背景
/// - 半透明黑色蒙层背景(rgba(0,0,0,.55))
/// - 显示引导图
/// - 向左滑动超过阈值 → 整个蒙层滑出屏幕并触发 onClose
/// - 滑动不足阈值 → 回弹
/// - 向左滑动超过阈值 → 整个蒙层 0.2s 滑出屏幕并触发 onClose
/// - 滑动不足阈值 → 0.15s 回弹
/// ============================================================
class _SlideMask extends StatefulWidget {
const _SlideMask({
@@ -574,24 +584,21 @@ class _SlideMaskState extends State<_SlideMask>
double _startX = 0;
bool _isSliding = false;
bool _isAnimatingOut = false;
late final AnimationController _slideOutController;
late Animation<double> _slideOutAnim;
late final AnimationController _controller;
Animation<double> _anim = const AlwaysStoppedAnimation<double>(0);
@override
void initState() {
super.initState();
_slideOutController = AnimationController(
_controller = 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();
_controller.dispose();
super.dispose();
}
@@ -599,16 +606,44 @@ class _SlideMaskState extends State<_SlideMask>
void didUpdateWidget(covariant _SlideMask oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.visible && !oldWidget.visible) {
_offsetX = 0;
_isAnimatingOut = false;
_slideOutController.reset();
_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) {
@@ -626,23 +661,32 @@ class _SlideMaskState extends State<_SlideMask>
_isSliding = false;
if (_offsetX.abs() >= widget.slideThreshold) {
// 超过阈值 → 滑出动画
// 超过阈值 → 0.2s 滑出动画
_startSlideOut();
} else {
// 回弹
setState(() => _offsetX = 0);
// 不足阈值 → 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;
_slideOutAnim = Tween<double>(begin: _offsetX, end: -screenWidth).animate(
CurvedAnimation(parent: _slideOutController, curve: Curves.easeInOut),
_animateTo(
-screenWidth,
milliseconds: 200,
curve: Curves.easeInOut,
onCompleted: () {
if (mounted) widget.onClose();
},
);
_slideOutController.forward().then((_) {
widget.onClose();
});
}
@override
@@ -650,9 +694,9 @@ class _SlideMaskState extends State<_SlideMask>
if (!widget.visible) return const SizedBox.shrink();
return AnimatedBuilder(
animation: _slideOutAnim,
animation: _anim,
builder: (context, child) {
final dx = _isAnimatingOut ? _slideOutAnim.value : _offsetX;
final dx = _isSliding ? _offsetX : _anim.value;
return Positioned.fill(
child: GestureDetector(
onHorizontalDragStart: _handleTouchStart,
@@ -665,7 +709,7 @@ class _SlideMaskState extends State<_SlideMask>
color: const Color(0x8C000000), // 55% 透明黑
child: Stack(
children: [
// 引导图
// 引导图:489x245rpx,bottom 374rpx,水平居中
Positioned(
left: 0,
right: 0,