748 lines
22 KiB
Dart
748 lines
22 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';
|
||
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,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|