import 'dart:ui' as ui; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:new_learn/core/api/exam/api.dart'; import 'package:new_learn/core/api/exam/model.dart'; import 'package:new_learn/core/components/common_paged_list.dart'; import 'package:new_learn/core/logger/app_talker.dart'; import 'package:new_learn/core/utils/img_url.dart'; import 'package:new_learn/core/utils/subject_icons.dart'; /// ============================================================ /// 【ExamPage】我的考试列表页 —— 对标 Vue pages/myPackage/exam/exam.vue /// /// 结构: /// AppBar + Tab 栏(全部/各学科,胶囊样式,动态拉取) /// + 分页列表(CommonPagedList,下拉刷新/触底加载) /// + 卡片(学科图标 + 考试名 + 时间 + 得分渐变文字) /// /// 点击卡片 → 跳考试报告详情 /// (先留 TODO,后续考试详情页做了再接入路由) /// ============================================================ class ExamPage extends StatefulWidget { const ExamPage({super.key}); @override State createState() => _ExamPageState(); } class _ExamPageState extends State { final List _tabs = []; ExamSubjectTab _currentTab = const ExamSubjectTab(id: null, name: '全部'); bool _tabsLoading = true; @override void initState() { super.initState(); _tabs.add(const ExamSubjectTab(id: null, name: '全部')); _loadTabs(); } Future _loadTabs() async { try { final list = await ExamApi.instance.getSubjects(); if (!mounted) return; setState(() { _tabs ..clear() ..add(const ExamSubjectTab(id: null, name: '全部')) ..addAll(list); _currentTab = _tabs.first; _tabsLoading = false; }); } catch (e) { talker.error('获取考试科目Tab失败', e); if (!mounted) return; setState(() { _tabs ..clear() ..add(const ExamSubjectTab(id: null, name: '全部')); _currentTab = _tabs.first; _tabsLoading = false; }); } } /// 分页加载回调 Future> _loadPage(int pageKey) async { final res = await ExamApi.instance.search( baseSubjectId: _currentTab.id, pageIndex: pageKey, pageSize: 10, ); // hasNext:当前页返回数据满 pageSize 且总数更大 final hasMore = res.data.length >= res.pageSize && res.total > pageKey * res.pageSize; return PagingResult(data: res.data, hasNext: hasMore); } void _onTapTab(ExamSubjectTab tab) { if (_currentTab.id == tab.id) return; setState(() => _currentTab = tab); // tab 切换后,通过 ValueKey 让 CommonPagedList 重建 State,自动首屏加载新数据 } void _goToExamDetail(ExamDto item) { // TODO: 接入考试报告详情页路由,对应 Vue /pages/examPackage/examReport/index?recordId=${item.id} talker.debug('点击考试详情: ${item.taskName}, id=${item.id}'); } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.white, appBar: AppBar( title: const Text('我的考试'), backgroundColor: Colors.white, foregroundColor: const Color(0xFF1D2B47), elevation: 0, centerTitle: true, bottom: PreferredSize( preferredSize: Size.fromHeight(44.h), child: _buildTabBar(), ), ), body: _tabsLoading ? const Center(child: CircularProgressIndicator()) : Container( color: const Color(0xFFF5F8FA), child: CommonPagedList( key: ValueKey('exam-list-${_currentTab.id}'), load: _loadPage, padding: EdgeInsets.fromLTRB(12.w, 24.h, 12.w, 0), // separatorBuilder: (_, __) => SizedBox(height: 14.h), noMoreText: '- AI 爱学蝶变提醒你 没有更多了 -', emptyWidget: _buildEmpty(), buildItem: (ctx, item, index) { return _ExamCard( item: item, onTap: () => _goToExamDetail(item), ); }, ), ), ); } // ===== Tab 栏(对标 up-tabs + 自定义 activeStyle/inactiveStyle)===== Widget _buildTabBar() { return Container( padding: EdgeInsets.symmetric(horizontal: 6.w, vertical: 12.h), color: Colors.white, alignment: Alignment.centerLeft, child: SingleChildScrollView( scrollDirection: Axis.horizontal, physics: const BouncingScrollPhysics(), child: Row( children: _tabs.map((tab) { final isActive = tab.id == _currentTab.id; return Padding( padding: EdgeInsets.symmetric(horizontal: 6.w), child: GestureDetector( onTap: () => _onTapTab(tab), behavior: HitTestBehavior.opaque, child: Container( height: 37.h, width: 54.w, // padding: EdgeInsets.symmetric(horizontal: 16.w), decoration: BoxDecoration( color: isActive ? const Color(0xFF3D7DF5) : const Color(0xFFF5F8FA), borderRadius: BorderRadius.circular(999), ), alignment: Alignment.center, child: Text( tab.name, style: TextStyle( fontSize: 15.spMin, color: isActive ? Colors.white : const Color(0xFF687FAD), fontWeight: FontWeight.w400, ), ), ), ), ); }).toList(), ), ), ); } // ===== 空状态(对标 DataEmpty + exam_empty_bg)===== Widget _buildEmpty() { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ CachedNetworkImage( imageUrl: ImgUrl.build( '/StaticResource/2026-06-10/exam_empty_bg1781078431804.png', ), width: 200.w, fit: BoxFit.fitWidth, errorWidget: (_, _, _) => SizedBox(width: 200.w, height: 200.h), ), SizedBox(height: 12.h), Text( '暂无内容', style: TextStyle( fontSize: 14.spMin, color: const Color(0xFF687FAD), ), ), ], ), ); } } // ==================== 考试卡片 ==================== class _ExamCard extends StatelessWidget { const _ExamCard({required this.item, required this.onTap}); final ExamDto item; final VoidCallback onTap; static const String _cardBgBook = '/StaticResource/2026-09-10/book_icon_final1789026573675.png'; // 书图标设计尺寸(目标渲染尺寸) static const double _bookWidth = 124; static const double _bookHeight = 124; // 卡片尺寸 static const double _cardHeight = 92; static const double _cardRadius = 24; // ⚠ book_icon.png 自带透明 padding,下面这些偏移用于补偿图片留白 // 让书图案的左下角对齐卡片左下角。换图时需重新调整。 static const double _bookOffsetLeft = -14; // 左补留白(宽度加大了,偏移也加大) static const double _bookOffsetBottom = 6; // 底补留白 static const double _bookExtraHeight = 0; // 放大补偿,让图案达到目标高度 String get _timeRange { // 日期相同只展示 endTime 的时分,去除秒 final startParts = item.startTime.split(' '); final startDate = startParts.isNotEmpty ? startParts[0] : ''; final startHhMm = startParts.length >= 2 ? startParts[1].split(':').take(2).join(':') : ''; final endParts = item.endTime.split(' '); final endHhMm = endParts.length >= 2 ? endParts[1].split(':').take(2).join(':') : ''; return '$startDate $startHhMm - $endHhMm'; } @override Widget build(BuildContext context) { // 学科图标:按 baseSubjectName 动态取,无匹配兜底数学 final subjectIcon = subjectIconUrl(item.baseSubjectName); return GestureDetector( behavior: HitTestBehavior.opaque, onTap: onTap, child: SizedBox( // ⚠ 书图/卡片尺寸统一用 .w:宽高同基准,锁定盒子宽高比, // 避免手机(.w按375稿/.h按812稿)与pad(设备尺寸)缩放系数不同导致 // BoxFit.contain 缩放分支翻转、书图被压扁。 height: (_bookHeight + _bookExtraHeight).w, // 整体占位 = 书图容器高度(溢出卡片) child: Stack( clipBehavior: Clip.none, children: [ // 底层:白色圆角卡片(垂直居中) Positioned( left: 0, right: 0, top: (_bookHeight + _bookExtraHeight - _cardHeight).w / 2, child: Container( height: _cardHeight.w, decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(_cardRadius.r), boxShadow: const [ BoxShadow( color: Color(0x0A1D2B47), blurRadius: 20, offset: Offset(0, 6), ), ], ), ), ), // 中层:左侧书形装饰图 // 图片自带透明 padding,用 _bookOffsetLeft/Bottom/ExtraHeight 补偿 Positioned( left: _bookOffsetLeft.w, bottom: _bookOffsetBottom.w, width: _bookWidth.w, height: (_bookHeight + _bookExtraHeight).w, child: CachedNetworkImage( imageUrl: ImgUrl.build(_cardBgBook), fit: BoxFit.contain, alignment: Alignment.bottomLeft, errorWidget: (_, __, ___) => SizedBox(width: _bookWidth.w, height: _bookHeight.w), ), ), // 学科小图标(叠在书图上半部分,书图 110 宽 / 139 高容器) Positioned( left: 22.w, top: 14.w, child: CachedNetworkImage( imageUrl: subjectIcon, width: 56.w, height: 56.w, errorWidget: (_, _, _) => Icon( Icons.menu_book, size: 56.w, color: const Color(0xFF3D7DF5), ), ), ), // 上层:内容 Positioned.fill( child: Row( children: [ // 左侧书图区域的占位宽(内容从书图右边开始) SizedBox(width: 90.w), // 中间 + 右侧 Expanded( child: Padding( padding: EdgeInsets.symmetric(horizontal: 12.w), child: Row( children: [ Expanded(child: _buildMid()), _buildRight(), ], ), ), ), ], ), ), // 学科标签(覆盖在书图底部胶囊上) Positioned( left: -10.w, bottom: 26.w, width: _bookWidth.w, child: Center( child: Container( padding: EdgeInsets.symmetric( horizontal: 14.w, vertical: 6.w, ), decoration: BoxDecoration( color: const Color(0xFFE8F2FF), borderRadius: BorderRadius.circular(12.r), ), child: Text( item.baseSubjectName, style: TextStyle( fontSize: 16.spMin, fontWeight: FontWeight.w600, color: const Color(0xFF3D7DF5), height: 1.2, ), ), ), ), ), ], ), ), ); } Widget _buildMid() { return Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( item.taskName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 18.spMin, fontWeight: FontWeight.w600, color: const Color(0xFF1D2B47), ), ), SizedBox(height: 8.h), Text( _timeRange, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 14.spMin, fontWeight: FontWeight.w400, color: const Color(0xFF687FAD), ), ), ], ); } Widget _buildRight() { return Row( crossAxisAlignment: CrossAxisAlignment.baseline, textBaseline: TextBaseline.alphabetic, children: [ // 得分 _gradientScoreText('${item.userScore}', 32.spMin, FontWeight.w700), Text( ' / ${item.totalScore}', style: TextStyle( fontSize: 16.spMin, fontWeight: FontWeight.w400, color: const Color(0xFF687FAD), ), ), SizedBox(width: 10.w), Icon( Icons.arrow_forward_ios, size: 16.w, color: const Color(0xFF3D7DF5), ), ], ); } /// 渐变得分文字:linear-gradient(270deg, #2DC9FF -2.95%, #2B73F9 73.86%) Widget _gradientScoreText(String text, double fontSize, FontWeight weight) { return ShaderMask( shaderCallback: (rect) => ui.Gradient.linear( rect.topRight, rect.topLeft, const [Color(0xFF2DC9FF), Color(0xFF2B73F9)], const [-0.0295, 0.7386], TileMode.clamp, ), child: Text( text, style: TextStyle( fontSize: fontSize, fontWeight: weight, color: Colors.white, height: 1, ), ), ); } }