940 lines
30 KiB
Dart
940 lines
30 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||
import 'package:go_router/go_router.dart';
|
||
import 'package:cached_network_image/cached_network_image.dart';
|
||
import 'package:new_learn/core/api/conquerPackage/captureList/api.dart';
|
||
import 'package:new_learn/core/api/conquerPackage/captureList/model.dart';
|
||
import 'package:new_learn/core/components/common_paged_list.dart';
|
||
import 'package:new_learn/core/utils/img_url.dart';
|
||
import 'package:new_learn/features/conquerPackage/classExercises/controllers/exercise_timing.dart';
|
||
|
||
/// ============================================================
|
||
/// 【CaptureListPage】攻克列表页 —— 对标 Vue pages/conquerPackage/captureList/index.vue
|
||
///
|
||
/// 入参:courseId(课程 id)
|
||
///
|
||
/// 接口:
|
||
/// - CaptureListApi.getCoursePack(courseId) → 列表 + remark
|
||
/// - CaptureListApi.startOvercome(packId) → 开始/继续攻克,跳转对应页面
|
||
///
|
||
/// 换算规则(本工程统一):Vue 的 `N rpx` → Flutter 的 `(N/2).w/.h/.sp/.r`
|
||
/// ============================================================
|
||
|
||
/// 攻克记录状态枚举(与后端 status 对齐:0未开始/1成功/2失败/3判题中)
|
||
enum CaptureRecordStatus {
|
||
none(0), // 未开始
|
||
success(1), // 成功
|
||
fail(2), // 失败
|
||
processing(3); // 判题中
|
||
|
||
final int value;
|
||
const CaptureRecordStatus(this.value);
|
||
|
||
factory CaptureRecordStatus.fromInt(int? v) {
|
||
return CaptureRecordStatus.values.firstWhere(
|
||
(e) => e.value == v,
|
||
orElse: () => CaptureRecordStatus.none,
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 攻克包状态枚举(与后端 status 对齐)
|
||
enum CapturePackStatus {
|
||
notStarted(0), // 未开始
|
||
inProgress(1), // 进行中
|
||
conquered(2), // 已攻克
|
||
failed(3), // 攻克失败
|
||
laterStudy(4), // 后续学习
|
||
judging(5); // 判题中
|
||
|
||
final int value;
|
||
const CapturePackStatus(this.value);
|
||
|
||
factory CapturePackStatus.fromInt(int? v) {
|
||
return CapturePackStatus.values.firstWhere(
|
||
(e) => e.value == v,
|
||
orElse: () => CapturePackStatus.notStarted,
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 页面内部包装模型(在 CourseInfo 基础上增加 UI 状态 isShowCaptureInfo)
|
||
class _CapturePackViewModel {
|
||
final CourseInfo info;
|
||
bool isShowCaptureInfo;
|
||
|
||
_CapturePackViewModel(this.info, {this.isShowCaptureInfo = false});
|
||
|
||
String get id => '${info.id}';
|
||
String get name => info.name;
|
||
String get baseSubjectName => info.baseSubjectName;
|
||
int get overcomeStandard => info.overcomeStandard;
|
||
int get maxOvercomeCount => info.maxOvercomeCount;
|
||
int get recordCount => info.recordCount;
|
||
CapturePackStatus get status => CapturePackStatus.fromInt(info.status);
|
||
List<CourseRecord> get records => info.records;
|
||
}
|
||
|
||
class CaptureListPage extends StatefulWidget {
|
||
const CaptureListPage({super.key, required this.courseId});
|
||
|
||
final String courseId;
|
||
|
||
@override
|
||
State<CaptureListPage> createState() => _CaptureListPageState();
|
||
}
|
||
|
||
class _CaptureListPageState extends State<CaptureListPage> {
|
||
// ========== 颜色常量 ==========
|
||
static const Color _bgColor = Color(0xFFF5F8FA);
|
||
static const Color _titleColor = Color(0xFF1D2B47);
|
||
static const Color _subtitleColor = Color(0xFF495A7A);
|
||
static const Color _primary = Color(0xFF3D7DF5);
|
||
static const Color _success = Color(0xFF00CC96);
|
||
static const Color _error = Color(0xFFFF4D4D);
|
||
static const Color _processing = Color(0xFFF5963D);
|
||
static const Color _barBg = Color(0xFFE3ECFF);
|
||
static const Color _mutedText = Color(0xFF687FAD);
|
||
|
||
// ========== 状态 ==========
|
||
String _remark = '';
|
||
bool _navigating = false; // 防止连续点击重复请求/跳转
|
||
List<String> _expandedPackIds = []; // 展开的包 id(刷新时保留展开状态)
|
||
int _requestId = 0; // 请求序号,防止并发请求错乱
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
}
|
||
|
||
// ========== 接口 ==========
|
||
|
||
/// 分页加载:CommonPagedList 回调。接口无分页,当作一页返回,hasNext=false。
|
||
Future<PagingResult<_CapturePackViewModel>> _loadPage(int pageKey) async {
|
||
final requestId = ++_requestId;
|
||
final expandedIds = _expandedPackIds.isNotEmpty
|
||
? [..._expandedPackIds]
|
||
: <String>[];
|
||
|
||
// courseId 为空直接返回空列表
|
||
if (widget.courseId.isEmpty) {
|
||
if (!mounted) return const PagingResult(data: [], hasNext: false);
|
||
setState(() => _remark = '');
|
||
return const PagingResult(data: [], hasNext: false);
|
||
}
|
||
|
||
final res = await CaptureListApi.instance.getCoursePack(widget.courseId);
|
||
if (requestId != _requestId) {
|
||
// 已被新请求覆盖,丢弃结果(返回空让库忽略也可)
|
||
return const PagingResult(data: [], hasNext: false);
|
||
}
|
||
|
||
final list = _restoreExpandedState(res.packs, expandedIds);
|
||
if (!mounted) return PagingResult(data: list, hasNext: false);
|
||
|
||
setState(() {
|
||
_remark = res.remark;
|
||
_expandedPackIds = list
|
||
.where((item) => item.isShowCaptureInfo)
|
||
.map((item) => item.id)
|
||
.toList();
|
||
});
|
||
|
||
// 只有一页
|
||
return PagingResult(data: list, hasNext: false);
|
||
}
|
||
|
||
// ========== 展开状态管理(对标 Vue saveExpandedState / restoreExpandedState) ==========
|
||
|
||
/// 恢复展开状态 + 计算 recordCount(status != 0 的记录数)
|
||
List<_CapturePackViewModel> _restoreExpandedState(
|
||
List<CourseInfo> list,
|
||
List<String> expandedIds,
|
||
) {
|
||
final expandedIdSet = expandedIds.toSet();
|
||
return list.map((info) {
|
||
final records = info.records;
|
||
final validCount = records.where((r) => r.status != 0).length;
|
||
// 构造一个新的 CourseInfo(recordCount 取有效记录数)
|
||
final adjustedInfo = CourseInfo(
|
||
id: info.id,
|
||
name: info.name,
|
||
baseSubjectName: info.baseSubjectName,
|
||
overcomeStandard: info.overcomeStandard,
|
||
estimatedTime: info.estimatedTime,
|
||
maxOvercomeCount: info.maxOvercomeCount,
|
||
recordCount: validCount,
|
||
records: records,
|
||
status: info.status,
|
||
statusName: info.statusName,
|
||
);
|
||
final hasValidRecords = records.any((r) =>
|
||
r.status == 1 || r.status == 2 || r.status == 3);
|
||
final shouldExpand =
|
||
expandedIdSet.contains('${info.id}') || hasValidRecords;
|
||
return _CapturePackViewModel(adjustedInfo,
|
||
isShowCaptureInfo: shouldExpand);
|
||
}).toList();
|
||
}
|
||
|
||
/// 切换攻克详情展开/收起
|
||
void _toggleCaptureInfo(_CapturePackViewModel item) {
|
||
if (item.records.isEmpty) return;
|
||
final validRecords = item.records.where((r) => r.status != 0).toList();
|
||
if (validRecords.isEmpty) return;
|
||
setState(() {
|
||
item.isShowCaptureInfo = !item.isShowCaptureInfo;
|
||
if (item.isShowCaptureInfo) {
|
||
if (!_expandedPackIds.contains(item.id)) {
|
||
_expandedPackIds.add(item.id);
|
||
}
|
||
} else {
|
||
_expandedPackIds.remove(item.id);
|
||
}
|
||
});
|
||
}
|
||
|
||
// ========== 点击事件 ==========
|
||
|
||
/// 点击按钮 → 开始/继续攻克(对接 startOvercome 接口)
|
||
Future<void> _navigateToCaptureInfo(_CapturePackViewModel item) async {
|
||
if (item.status == CapturePackStatus.judging) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text('该攻克正在判题中,暂不能操作'), behavior: SnackBarBehavior.floating),
|
||
);
|
||
return;
|
||
}
|
||
if (_navigating) return;
|
||
_navigating = true;
|
||
|
||
// 显示加载
|
||
showDialog(
|
||
context: context,
|
||
barrierDismissible: false,
|
||
builder: (_) => const Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
||
);
|
||
|
||
try {
|
||
final res = await CaptureListApi.instance.startOvercome(item.id);
|
||
if (!mounted) return;
|
||
Navigator.of(context).pop(); // 关闭加载
|
||
|
||
// 跳转分支(对标 Vue navigateToCaptureInfo)
|
||
if (res.hasHandout) {
|
||
// 有讲义 → 课前课中页
|
||
_navigateTo('/conquerPackage/beforeDuringClass', {
|
||
'status': '1',
|
||
'packId': item.id,
|
||
'courseId': widget.courseId,
|
||
'recordId': '${res.recordId}',
|
||
if (res.nowStudyObjectType == 11)
|
||
'nowStudyObjectId': '${res.nowStudyObjectId}',
|
||
});
|
||
return;
|
||
}
|
||
if (res.hasQuestion) {
|
||
// 有题目 → 过渡页 或 习题页
|
||
final pagePath = res.nowStudyObjectType == 0
|
||
? '/conquerPackage/transitionPage'
|
||
: '/conquerPackage/classExercises';
|
||
_navigateTo(pagePath, {
|
||
'status': '3',
|
||
'courseId': widget.courseId,
|
||
'packId': item.id,
|
||
'recordId': '${res.recordId}',
|
||
if (res.nowStudyObjectType == 12)
|
||
'nowStudyObjectId': '${res.nowStudyObjectId}',
|
||
});
|
||
return;
|
||
}
|
||
// 既没讲义也没题目
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text('该攻克暂无讲义或题目'), behavior: SnackBarBehavior.floating),
|
||
);
|
||
} catch (e) {
|
||
if (mounted) {
|
||
Navigator.of(context).pop(); // 关闭加载
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text('加载失败:${e.toString()}'), behavior: SnackBarBehavior.floating),
|
||
);
|
||
}
|
||
} finally {
|
||
_navigating = false;
|
||
}
|
||
}
|
||
|
||
/// 页面跳转(go_router push + extra 传参)
|
||
void _navigateTo(String path, Map<String, String> params) {
|
||
// TODO(路由): 替换为项目统一的路由跳转方式
|
||
// debugPrint('navigateTo: $path $params');
|
||
// ScaffoldMessenger.of(context).showSnackBar(
|
||
// SnackBar(content: Text('跳转 $path ${params.toString()}'), behavior: SnackBarBehavior.floating),
|
||
// );
|
||
context.push(path, extra: params);
|
||
}
|
||
|
||
/// 点击报告(跳转报告页,对标 Vue getReport)
|
||
/// recordId=攻克记录 id,packId=攻克包 id;判题中(status==3)不可点
|
||
void _getReport(CourseRecord record, _CapturePackViewModel pack) {
|
||
if (CaptureRecordStatus.fromInt(record.status) ==
|
||
CaptureRecordStatus.processing) return;
|
||
_navigateTo('/conquerPackage/report', {
|
||
'recordId': '${record.id}',
|
||
'packId': pack.id,
|
||
});
|
||
}
|
||
|
||
// ========== 工具方法 ==========
|
||
|
||
String _numberToChinese(int num) {
|
||
const chineseNum = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九'];
|
||
return chineseNum[num.clamp(0, 9)];
|
||
}
|
||
|
||
// ========== 构建 ==========
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Scaffold(
|
||
backgroundColor: _bgColor,
|
||
body: Stack(
|
||
children: [
|
||
const Positioned.fill(child: ColoredBox(color: _bgColor)),
|
||
// 顶部背景图
|
||
Positioned(
|
||
top: 0,
|
||
left: 0,
|
||
right: 0,
|
||
child: CachedNetworkImage(
|
||
imageUrl: ImgUrl.build(
|
||
'/StaticResource/2026-05-26/my-header-bg-default1779775984375.png',
|
||
),
|
||
fit: BoxFit.fitWidth,
|
||
width: double.infinity,
|
||
placeholder: (context, url) => const SizedBox.shrink(),
|
||
errorWidget: (context, url, error) => const SizedBox.shrink(),
|
||
),
|
||
),
|
||
// 主内容
|
||
SafeArea(
|
||
child: Column(
|
||
children: [
|
||
// 导航栏
|
||
SizedBox(
|
||
height: 44.h,
|
||
child: Stack(
|
||
children: [
|
||
const Center(
|
||
child: Text(
|
||
'攻克列表',
|
||
style: TextStyle(
|
||
color: Color.fromRGBO(0, 0, 0, 0.8),
|
||
fontSize: 18,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
),
|
||
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(),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
// 规则卡片
|
||
_buildRuleView(),
|
||
SizedBox(height: 19.h),
|
||
// 列表
|
||
Expanded(
|
||
child: CommonPagedList<_CapturePackViewModel>(
|
||
load: _loadPage,
|
||
buildItem: (context, item, index) => _buildCaptureItem(item),
|
||
padding: EdgeInsets.symmetric(horizontal: 14.w),
|
||
emptyWidget: _buildEmpty(),
|
||
noMoreText: '', // 只有一页,不显示"没有更多"
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
// ============================================================
|
||
// 子组件构建方法
|
||
// ============================================================
|
||
|
||
Widget _buildRuleView() {
|
||
return Container(
|
||
margin: EdgeInsets.fromLTRB(14.w, 16.h, 14.w, 0),
|
||
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 16.h),
|
||
decoration: BoxDecoration(
|
||
color: Colors.transparent,
|
||
borderRadius: BorderRadius.circular(16.r),
|
||
border: Border.all(color: Colors.white, width: 1.w),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: const Color.fromARGB(255, 255, 255, 255).withOpacity(0.05),
|
||
blurRadius: 20.r,
|
||
offset: Offset(0, 10.h),
|
||
),
|
||
],
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
_buildRuleTitle(),
|
||
SizedBox(height: 16.h),
|
||
_buildRuleContent(),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildRuleTitle() {
|
||
return Row(
|
||
crossAxisAlignment: CrossAxisAlignment.center,
|
||
children: [
|
||
CachedNetworkImage(
|
||
imageUrl: ImgUrl.build('/StaticResource/2026-04-10/capture-list-rule.png'),
|
||
width: 18.w,
|
||
height: 18.w,
|
||
fit: BoxFit.fitWidth,
|
||
),
|
||
SizedBox(width: 6.w),
|
||
Stack(
|
||
clipBehavior: Clip.none,
|
||
children: [
|
||
Padding(
|
||
padding: EdgeInsets.only(bottom: 2.h),
|
||
child: Text(
|
||
'攻克规则说明',
|
||
style: TextStyle(
|
||
fontSize: 17.sp,
|
||
fontWeight: FontWeight.bold,
|
||
color: _titleColor,
|
||
),
|
||
),
|
||
),
|
||
Positioned(
|
||
bottom: -1.h,
|
||
right: 0,
|
||
child: Container(
|
||
width: 59.5.w,
|
||
height: 9.h,
|
||
decoration: BoxDecoration(
|
||
gradient: const LinearGradient(
|
||
colors: [
|
||
Color(0x003D7DF5),
|
||
Color(0x331D9CFF),
|
||
Color(0x808AECFD),
|
||
],
|
||
),
|
||
borderRadius: BorderRadius.circular(50.r),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildRuleContent() {
|
||
if (_remark.isEmpty) {
|
||
return Text('暂无规则说明',
|
||
style: TextStyle(fontSize: 14.sp, color: _subtitleColor));
|
||
}
|
||
final lines = _remark.split('\n');
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: lines.asMap().entries.map((entry) {
|
||
final isLast = entry.key == lines.length - 1;
|
||
return Padding(
|
||
padding: EdgeInsets.only(bottom: isLast ? 0 : 10.h),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Container(
|
||
width: 4.w,
|
||
height: 4.w,
|
||
margin: EdgeInsets.fromLTRB(6.5.w, 7.5.h, 13.w, 0),
|
||
decoration: const BoxDecoration(
|
||
color: _primary,
|
||
shape: BoxShape.circle,
|
||
),
|
||
),
|
||
Expanded(
|
||
child: Text(
|
||
entry.value,
|
||
style: TextStyle(fontSize: 14.sp, color: _subtitleColor, height: 1.5),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}).toList(),
|
||
);
|
||
}
|
||
|
||
Widget _buildEmpty() {
|
||
return Center(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
CachedNetworkImage(
|
||
imageUrl: ImgUrl.build('/StaticResource/2026-04-11/noCoursesss.png'),
|
||
width: 200.w,
|
||
height: 200.w,
|
||
),
|
||
SizedBox(height: 16.h),
|
||
Text('暂无攻克', style: TextStyle(fontSize: 14.sp, color: _mutedText)),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildCaptureItem(_CapturePackViewModel item) {
|
||
return Container(
|
||
margin: EdgeInsets.only(bottom: 14.h),
|
||
decoration: BoxDecoration(
|
||
borderRadius: BorderRadius.circular(16.r),
|
||
border: Border.all(color: Colors.white, width: 1.w),
|
||
gradient: const LinearGradient(
|
||
begin: Alignment.topCenter,
|
||
end: Alignment.bottomCenter,
|
||
colors: [Color(0xFFFAFEFF), Color(0xFFEDF7FF)],
|
||
),
|
||
),
|
||
child: Column(
|
||
children: [
|
||
_buildCaptureInfo(item),
|
||
_buildCaptureProgress(item),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildCaptureInfo(_CapturePackViewModel item) {
|
||
return Padding(
|
||
padding: EdgeInsets.fromLTRB(14.w, 18.5.h, 14.w, 0),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Expanded(
|
||
flex: 4,
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
_buildCaptureTitle(item.name),
|
||
SizedBox(height: 10.h),
|
||
_buildSubjectTag(item.baseSubjectName),
|
||
],
|
||
),
|
||
),
|
||
SizedBox(width: 10.w),
|
||
_buildStandardBox(item.overcomeStandard),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildCaptureTitle(String name) {
|
||
return Row(
|
||
crossAxisAlignment: CrossAxisAlignment.center,
|
||
children: [
|
||
Container(
|
||
width: 3.w,
|
||
height: 14.h,
|
||
margin: EdgeInsets.only(right: 7.5.w),
|
||
decoration: BoxDecoration(
|
||
color: _primary,
|
||
borderRadius: BorderRadius.circular(10.r),
|
||
),
|
||
),
|
||
Expanded(
|
||
child: Text(
|
||
name,
|
||
style: TextStyle(
|
||
fontSize: 16.sp,
|
||
fontWeight: FontWeight.bold,
|
||
color: _titleColor,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildSubjectTag(String subject) {
|
||
return Container(
|
||
padding: EdgeInsets.symmetric(horizontal: 1.w, vertical: 1.h),
|
||
decoration: BoxDecoration(
|
||
borderRadius: BorderRadius.circular(6.r),
|
||
gradient: const LinearGradient(
|
||
colors: [Color(0xFF5A9EFF), Color(0xFFF2F8FF), Color(0xFF5A9EFF)],
|
||
begin: Alignment.centerLeft,
|
||
end: Alignment.centerRight,
|
||
),
|
||
),
|
||
child: Container(
|
||
padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 2.h),
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFFE5F0FF),
|
||
borderRadius: BorderRadius.circular(4.r),
|
||
),
|
||
child: Text(
|
||
subject,
|
||
style: TextStyle(
|
||
fontSize: 12.sp,
|
||
color: _primary,
|
||
fontWeight: FontWeight.w500,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildStandardBox(int standard) {
|
||
return Container(
|
||
width: 60.w,
|
||
height: 51.h,
|
||
padding: EdgeInsets.all(2.w),
|
||
decoration: BoxDecoration(
|
||
borderRadius: BorderRadius.circular(6.r),
|
||
gradient: const LinearGradient(
|
||
begin: Alignment.topCenter,
|
||
end: Alignment.bottomCenter,
|
||
colors: [Color(0xFF60D0C0), Color(0xFF6EE9DB)],
|
||
),
|
||
),
|
||
child: Column(
|
||
children: [
|
||
Expanded(
|
||
flex: 1,
|
||
child: Center(
|
||
child: FittedBox(
|
||
fit: BoxFit.scaleDown,
|
||
child: Text(
|
||
'攻克标准',
|
||
style: TextStyle(fontSize: 12.sp, color: Colors.white),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
Expanded(
|
||
child: Container(
|
||
margin: EdgeInsets.fromLTRB(1.5.w, 0, 1.5.w, 1.5.h),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.only(
|
||
bottomLeft: Radius.circular(6.r),
|
||
bottomRight: Radius.circular(6.r),
|
||
),
|
||
),
|
||
child: Center(
|
||
child: FittedBox(
|
||
fit: BoxFit.scaleDown,
|
||
child: Row(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
CachedNetworkImage(
|
||
imageUrl:
|
||
ImgUrl.build('/StaticResource/2026-04-10/capture-list-success.png'),
|
||
width: 14.w,
|
||
height: 14.w,
|
||
fit: BoxFit.fitWidth,
|
||
),
|
||
SizedBox(width: 5.5.w),
|
||
Text(
|
||
'$standard%',
|
||
style: TextStyle(
|
||
fontSize: 15.sp, color: _success, fontWeight: FontWeight.bold),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildCaptureProgress(_CapturePackViewModel item) {
|
||
return Container(
|
||
margin: EdgeInsets.only(top: 10.5.h),
|
||
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 16.h),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.circular(16.r),
|
||
),
|
||
child: Column(
|
||
children: [
|
||
_buildProgressTop(item),
|
||
if (item.isShowCaptureInfo) _buildProgressInfo(item),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildProgressTop(_CapturePackViewModel item) {
|
||
return Row(
|
||
children: [
|
||
Expanded(
|
||
child: GestureDetector(
|
||
onTap: () => _toggleCaptureInfo(item),
|
||
behavior: HitTestBehavior.opaque,
|
||
child: Row(
|
||
children: [
|
||
Text('攻克次数', style: TextStyle(fontSize: 12.sp, color: _mutedText)),
|
||
SizedBox(width: 10.w),
|
||
_buildProgressBars(item),
|
||
SizedBox(width: 10.w),
|
||
_buildProgressCount(item),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
_buildButtonGroup(item),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildProgressBars(_CapturePackViewModel item) {
|
||
return Row(
|
||
children: List.generate(item.maxOvercomeCount, (idx) {
|
||
final record = idx < item.records.length ? item.records[idx] : null;
|
||
Color color;
|
||
switch (CaptureRecordStatus.fromInt(record?.status)) {
|
||
case CaptureRecordStatus.success:
|
||
color = _success;
|
||
break;
|
||
case CaptureRecordStatus.fail:
|
||
color = _error;
|
||
break;
|
||
case CaptureRecordStatus.processing:
|
||
color = _processing;
|
||
break;
|
||
default:
|
||
color = _barBg;
|
||
}
|
||
return Container(
|
||
width: 20.w,
|
||
height: 4.h,
|
||
margin: EdgeInsets.only(right: idx == item.maxOvercomeCount - 1 ? 0 : 4.w),
|
||
decoration: BoxDecoration(
|
||
color: color,
|
||
borderRadius: BorderRadius.circular(4.r),
|
||
),
|
||
);
|
||
}),
|
||
);
|
||
}
|
||
|
||
Widget _buildProgressCount(_CapturePackViewModel item) {
|
||
final hasRecords = item.recordCount > 0 && item.records.isNotEmpty;
|
||
return Row(
|
||
children: [
|
||
Text(
|
||
'${item.recordCount}/${item.maxOvercomeCount}',
|
||
style: TextStyle(fontSize: 12.sp, color: _mutedText),
|
||
),
|
||
if (hasRecords)
|
||
Padding(
|
||
padding: EdgeInsets.only(left: 4.5.w),
|
||
child: Icon(
|
||
item.isShowCaptureInfo
|
||
? Icons.keyboard_arrow_up
|
||
: Icons.keyboard_arrow_down,
|
||
size: 12.sp,
|
||
color: _mutedText,
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildButtonGroup(_CapturePackViewModel item) {
|
||
switch (item.status) {
|
||
case CapturePackStatus.notStarted:
|
||
return _buildSolidButton('开始攻克', () => _navigateToCaptureInfo(item));
|
||
case CapturePackStatus.inProgress:
|
||
final text = item.records.isNotEmpty ? '继续攻克' : '开始攻克';
|
||
return _buildSolidButton(text, () => _navigateToCaptureInfo(item));
|
||
case CapturePackStatus.conquered:
|
||
return _buildOutlineButton('已攻克', _success, null, opacity: 0.5);
|
||
case CapturePackStatus.failed:
|
||
return _buildOutlineButton('攻克失败', _error,
|
||
() => _navigateToCaptureInfo(item),
|
||
opacity: 0.5);
|
||
case CapturePackStatus.laterStudy:
|
||
return Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
GestureDetector(
|
||
onTap: () => _navigateToCaptureInfo(item),
|
||
child: Text(
|
||
'后续学习',
|
||
style: TextStyle(fontSize: 11.sp, color: _primary),
|
||
),
|
||
),
|
||
SizedBox(width: 8.w),
|
||
_buildSolidButton('重新攻克', () => _navigateToCaptureInfo(item)),
|
||
],
|
||
);
|
||
case CapturePackStatus.judging:
|
||
return _buildOutlineButton('判题中', _success, null, opacity: 0.5);
|
||
}
|
||
}
|
||
|
||
Widget _buildOutlineButton(String text, Color color, VoidCallback? onTap,
|
||
{double opacity = 1.0}) {
|
||
return GestureDetector(
|
||
onTap: onTap,
|
||
behavior: HitTestBehavior.opaque,
|
||
child: Opacity(
|
||
opacity: opacity,
|
||
child: Container(
|
||
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 4.h),
|
||
height: 30.h,
|
||
alignment: Alignment.center,
|
||
decoration: BoxDecoration(
|
||
border: Border.all(color: color, width: 1.w),
|
||
borderRadius: BorderRadius.circular(30.r),
|
||
color: onTap == null ? Colors.transparent : color.withOpacity(0.08),
|
||
),
|
||
child: Text(
|
||
text,
|
||
style: TextStyle(fontSize: 13.sp, color: color, fontWeight: FontWeight.w500),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildSolidButton(String text, VoidCallback? onTap,
|
||
{double opacity = 1.0}) {
|
||
return GestureDetector(
|
||
onTap: onTap,
|
||
behavior: HitTestBehavior.opaque,
|
||
child: Opacity(
|
||
opacity: opacity,
|
||
child: Container(
|
||
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 4.h),
|
||
height: 30.h,
|
||
alignment: Alignment.center,
|
||
decoration: BoxDecoration(
|
||
borderRadius: BorderRadius.circular(30.r),
|
||
color: _primary,
|
||
),
|
||
child: Text(
|
||
text,
|
||
style: TextStyle(
|
||
fontSize: 13.sp, color: Colors.white, fontWeight: FontWeight.w500),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildProgressInfo(_CapturePackViewModel item) {
|
||
final validRecords =
|
||
item.records.where((r) => r.status != 0).toList();
|
||
if (validRecords.isEmpty) return const SizedBox.shrink();
|
||
|
||
return Container(
|
||
margin: EdgeInsets.only(top: 16.h),
|
||
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 10.5.h),
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFFFAFBFD),
|
||
borderRadius: BorderRadius.circular(6.r),
|
||
border: Border.all(color: _barBg, width: 0.5.w),
|
||
),
|
||
child: Column(
|
||
children: validRecords.asMap().entries.map((entry) {
|
||
final infoIndex = entry.key;
|
||
final record = entry.value;
|
||
return GestureDetector(
|
||
onTap: () => _getReport(record, item),
|
||
behavior: HitTestBehavior.opaque,
|
||
child: Padding(
|
||
padding: EdgeInsets.symmetric(vertical: 5.h),
|
||
child: Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Text(
|
||
'第${_numberToChinese(infoIndex + 1)}次攻克',
|
||
style: TextStyle(fontSize: 12.sp, color: _mutedText),
|
||
),
|
||
SizedBox(width: 13.w),
|
||
Text(
|
||
formatDuration(record.overcomeTimeSecond),
|
||
style: TextStyle(fontSize: 12.sp, color: _subtitleColor),
|
||
),
|
||
],
|
||
),
|
||
if (CaptureRecordStatus.fromInt(record.status) ==
|
||
CaptureRecordStatus.processing)
|
||
Text('判题中',
|
||
style: TextStyle(fontSize: 12.sp, color: _processing))
|
||
else
|
||
_buildInfoRight(record, item),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}).toList(),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildInfoRight(CourseRecord record, _CapturePackViewModel pack) {
|
||
final isSuccess = record.accuracyRate >= pack.overcomeStandard;
|
||
final bgColor =
|
||
isSuccess ? const Color(0xFFE8FAF6) : const Color(0xFFFFF0F0);
|
||
final textColor = isSuccess ? _success : _error;
|
||
final iconPath = isSuccess
|
||
? '/StaticResource/2026-04-22/capture-list-success.png'
|
||
: '/StaticResource/2026-04-22/capture-list-fail.png';
|
||
|
||
return Container(
|
||
padding: EdgeInsets.symmetric(horizontal: 6.w, vertical: 1.5.h),
|
||
decoration: BoxDecoration(
|
||
color: bgColor,
|
||
borderRadius: BorderRadius.circular(4.r),
|
||
),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
CachedNetworkImage(
|
||
imageUrl: ImgUrl.build(iconPath),
|
||
width: 14.w,
|
||
height: 14.w,
|
||
fit: BoxFit.fitWidth,
|
||
),
|
||
SizedBox(width: 5.5.w),
|
||
Text(
|
||
'${record.accuracyRate}%',
|
||
style: TextStyle(
|
||
fontSize: 12.sp, color: textColor, fontWeight: FontWeight.w500),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|