import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:new_learn/core/utils/img_url.dart'; /// ============================================================ /// 【CaptureListPage】攻克列表页 —— 对标 Vue pages/conquerPackage/captureList/index.vue /// /// 一、Vue 源结构: /// HeaderBac + NavBar(title: "攻克列表", bg-color: #ADD1FF00) /// ├── rule-view 攻克规则说明卡片(remark 富文本) /// └── capture-list-refresh-wrap /// └── up-pull-refresh /// ├── captureList 攻克项列表 /// │ └── capture-item /// │ ├── capture-info (左:标题+学科标签;右:攻克标准百分比) /// │ └── capture-progress /// │ ├── progress-top (进度条 + 右侧按钮) /// │ └── capture-progress-info (展开的历次攻克详情) /// └── DataEmpty 空状态 /// /// 二、换算规则(本工程统一):Vue 的 `N rpx` → Flutter 的 `(N/2).w/.h/.sp/.r` /// 例:间距 28rpx → 14.w;圆角 32rpx → 16.r;字号 28rpx → 14.sp /// /// 三、状态(StatefulWidget): /// - remark: 规则说明文字 /// - data: 攻克包列表 /// - refreshing: 下拉刷新状态 /// - isDataEmpty: 空数据标记 /// /// 四、接口暂未对接,使用 Mock 数据展示页面效果。 /// ============================================================ /// 攻克记录状态枚举 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, ); } } /// 攻克包状态 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, ); } } /// 攻克记录模型 class CaptureRecord { final String id; final CaptureRecordStatus status; final int? accuracyRate; // 正确率百分比 final int? overcomeTimeSecond; // 攻克用时(秒) const CaptureRecord({ required this.id, required this.status, this.accuracyRate, this.overcomeTimeSecond, }); } /// 攻克包模型 class CapturePack { final String id; final String name; final String baseSubjectName; final int overcomeStandard; // 攻克标准百分比 final int maxOvercomeCount; // 最大攻克次数 final int recordCount; // 已进行次数 final CapturePackStatus status; final List records; bool isShowCaptureInfo; // 是否展开详情 CapturePack({ required this.id, required this.name, required this.baseSubjectName, required this.overcomeStandard, required this.maxOvercomeCount, required this.recordCount, required this.status, required this.records, this.isShowCaptureInfo = false, }); } class CaptureListPage extends StatefulWidget { const CaptureListPage({super.key}); @override State createState() => _CaptureListPageState(); } class _CaptureListPageState extends State { // ========== 颜色常量 ========== 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 = ''; List _data = []; bool _refreshing = false; bool _isDataEmpty = false; @override void initState() { super.initState(); _loadMockData(); } /// Mock 数据 —— 替代真实接口,仅用于展示页面效果 void _loadMockData() { _remark = '1. 每次攻克需达到指定正确率才算成功;\n' '2. 攻克失败可重新挑战,最多可挑战 N 次;\n' '3. 判题中状态下不能进行操作;\n' '4. 详情可查看每次攻克的用时与正确率。'; _data = [ CapturePack( id: '1', name: '一元一次方程入门', baseSubjectName: '数学', overcomeStandard: 80, maxOvercomeCount: 3, recordCount: 2, status: CapturePackStatus.inProgress, records: [ CaptureRecord( id: 'r1-1', status: CaptureRecordStatus.fail, accuracyRate: 60, overcomeTimeSecond: 480, ), CaptureRecord( id: 'r1-2', status: CaptureRecordStatus.success, accuracyRate: 90, overcomeTimeSecond: 600, ), ], isShowCaptureInfo: true, ), CapturePack( id: '2', name: '英语单词 Unit 3', baseSubjectName: '英语', overcomeStandard: 70, maxOvercomeCount: 5, recordCount: 0, status: CapturePackStatus.notStarted, records: [], ), CapturePack( id: '3', name: '文言文翻译技巧', baseSubjectName: '语文', overcomeStandard: 75, maxOvercomeCount: 3, recordCount: 3, status: CapturePackStatus.conquered, records: [ CaptureRecord( id: 'r3-1', status: CaptureRecordStatus.fail, accuracyRate: 50, overcomeTimeSecond: 300, ), CaptureRecord( id: 'r3-2', status: CaptureRecordStatus.fail, accuracyRate: 65, overcomeTimeSecond: 420, ), CaptureRecord( id: 'r3-3', status: CaptureRecordStatus.success, accuracyRate: 88, overcomeTimeSecond: 540, ), ], ), CapturePack( id: '4', name: '物理力学综合', baseSubjectName: '物理', overcomeStandard: 85, maxOvercomeCount: 3, recordCount: 1, status: CapturePackStatus.judging, records: [ CaptureRecord( id: 'r4-1', status: CaptureRecordStatus.processing, accuracyRate: null, overcomeTimeSecond: 700, ), ], isShowCaptureInfo: true, ), CapturePack( id: '5', name: '化学方程式配平', baseSubjectName: '化学', overcomeStandard: 80, maxOvercomeCount: 3, recordCount: 3, status: CapturePackStatus.failed, records: [ CaptureRecord( id: 'r5-1', status: CaptureRecordStatus.fail, accuracyRate: 40, overcomeTimeSecond: 200, ), CaptureRecord( id: 'r5-2', status: CaptureRecordStatus.fail, accuracyRate: 55, overcomeTimeSecond: 300, ), CaptureRecord( id: 'r5-3', status: CaptureRecordStatus.fail, accuracyRate: 60, overcomeTimeSecond: 400, ), ], ), CapturePack( id: '6', name: '历史时间轴梳理', baseSubjectName: '历史', overcomeStandard: 70, maxOvercomeCount: 3, recordCount: 2, status: CapturePackStatus.laterStudy, records: [ CaptureRecord( id: 'r6-1', status: CaptureRecordStatus.success, accuracyRate: 80, overcomeTimeSecond: 360, ), CaptureRecord( id: 'r6-2', status: CaptureRecordStatus.success, accuracyRate: 95, overcomeTimeSecond: 300, ), ], ), ]; _isDataEmpty = _data.isEmpty; } /// 下拉刷新 Future _onRefresh() async { if (_refreshing) return; setState(() => _refreshing = true); // 模拟网络请求延迟 await Future.delayed(const Duration(milliseconds: 800)); if (mounted) { setState(() { _loadMockData(); _refreshing = false; }); } } /// 切换攻克详情展开/收起 void _toggleCaptureInfo(CapturePack item) { final validRecords = item.records.where((r) => r.status != CaptureRecordStatus.none).toList(); if (validRecords.isEmpty) return; setState(() { item.isShowCaptureInfo = !item.isShowCaptureInfo; }); } /// 点击按钮跳转(占位,不接接口) void _navigateToCaptureInfo(CapturePack item) { if (item.status == CapturePackStatus.judging) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('该攻克正在判题中,暂不能操作'), behavior: SnackBarBehavior.floating), ); return; } // TODO(业务逻辑): 对接 GetStartOvercome 接口后跳转 } /// 点击报告(占位,不接接口) void _getReport(CaptureRecord record, CapturePack pack) { if (record.status == CaptureRecordStatus.processing) return; // TODO(业务逻辑): 跳转报告页 } /// 数字转中文(1-9) String _numberToChinese(int num) { const chineseNum = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九']; return chineseNum[num.clamp(0, 9)]; } /// 秒数格式化:mm分ss秒 / h时mm分 String _formatDuration(int seconds) { if (seconds <= 0) return '0秒'; final h = seconds ~/ 3600; final m = (seconds % 3600) ~/ 60; final s = seconds % 60; if (h > 0) return '${h}时${m}分'; if (m > 0) return '${m}分${s}秒'; return '${s}秒'; } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: _bgColor, body: Stack( children: [ // 页面底色 const Positioned.fill(child: ColoredBox(color: _bgColor)), // 顶部背景图(对标 HeaderBac,bg-size: 100% auto → width 100%,高度自适应) 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, ), ), // 主内容(安全区内,含透明 AppBar + 规则卡片 + 列表) SafeArea( child: Column( children: [ // 顶部导航栏(对标 NavBar,bg-color 透明,白色文字) SizedBox( height: 44.h, child: Stack( children: [ const Center( child: Text( '攻克列表', style: TextStyle( color: Colors.white, fontSize: 18, fontWeight: FontWeight.w600, ), ), ), Positioned( left: 16.w, top: 0, bottom: 0, child: IconButton( icon: const Icon(Icons.arrow_back_ios, color: Colors.white, size: 20), onPressed: () => Navigator.of(context).maybePop(), padding: EdgeInsets.zero, constraints: const BoxConstraints(), ), ), ], ), ), // 规则卡片(透明底 + 白边,置于渐变背景之上) _buildRuleView(), SizedBox(height: 19.h), // 列表区域 Expanded( child: RefreshIndicator( onRefresh: _onRefresh, child: CustomScrollView( physics: const AlwaysScrollableScrollPhysics(), slivers: [ if (_isDataEmpty) SliverFillRemaining(child: _buildEmpty()) else SliverPadding( padding: EdgeInsets.symmetric(horizontal: 14.w), sliver: SliverList( delegate: SliverChildBuilderDelegate( (context, index) => _buildCaptureItem(_data[index]), childCount: _data.length, ), ), ), SliverToBoxAdapter(child: SizedBox(height: 20.h)), ], ), ), ), ], ), ), ], ), ); } // ============================================================ // 子组件构建方法 // ============================================================ /// 攻克规则说明卡片(透明底 + 白色边框 + 阴影,对标 Vue rule-view) 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() { 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(CapturePack 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(CapturePack 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: LinearGradient( colors: const [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: Text( '攻克标准', style: TextStyle(fontSize: 12.sp, color: Colors.white), ), ), ), Container( padding: EdgeInsets.symmetric(vertical: 1.5.h, horizontal: 4.w), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.only( bottomLeft: Radius.circular(6.r), bottomRight: Radius.circular(6.r), ), ), child: Row( mainAxisSize: MainAxisSize.min, 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(CapturePack 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(CapturePack 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(CapturePack item) { return Row( children: List.generate(item.maxOvercomeCount, (idx) { final record = idx < item.records.length ? item.records[idx] : null; Color color; switch (record?.status ?? CaptureRecordStatus.none) { 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(CapturePack 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(CapturePack item) { switch (item.status) { case CapturePackStatus.notStarted: return _buildOutlineButton('开始攻克', _primary, () => _navigateToCaptureInfo(item)); case CapturePackStatus.inProgress: final text = item.records.isNotEmpty ? '继续攻克' : '开始攻克'; return _buildOutlineButton(text, _primary, () => _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), _buildOutlineButton('重新攻克', _primary, () => _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 _buildProgressInfo(CapturePack item) { final validRecords = item.records.where((r) => r.status != CaptureRecordStatus.none).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 ?? 0), style: TextStyle(fontSize: 12.sp, color: _subtitleColor), ), ], ), if (record.status == CaptureRecordStatus.processing) Text('判题中', style: TextStyle(fontSize: 12.sp, color: _processing)) else _buildInfoRight(record, item), ], ), ), ); }).toList(), ), ); } Widget _buildInfoRight(CaptureRecord record, CapturePack item) { final isSuccess = (record.accuracyRate ?? 0) >= item.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 ?? 0}%', style: TextStyle(fontSize: 12.sp, color: textColor, fontWeight: FontWeight.w500), ), ], ), ); } }