diff --git a/lib/features/report/report_computation_page.dart b/lib/features/report/report_computation_page.dart index 818b2dd..55a271a 100644 --- a/lib/features/report/report_computation_page.dart +++ b/lib/features/report/report_computation_page.dart @@ -1,21 +1,48 @@ import 'dart:async'; +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:go_router/go_router.dart'; import 'package:new_learn/core/api/auth/api.dart'; import 'package:new_learn/core/api/report/api.dart'; import 'package:new_learn/core/api/report/model.dart'; import 'package:new_learn/core/logger/app_talker.dart'; import 'package:new_learn/core/providers/user_provider.dart'; +import 'package:new_learn/core/utils/img_url.dart'; +import 'package:new_learn/core/utils/screen_adapt_template.dart'; +import 'package:new_learn/features/report/widgets/computation_finished_popup.dart'; +import 'package:new_learn/features/report/widgets/computation_progress_ring.dart'; +import 'package:new_learn/features/report/widgets/computation_subject_item.dart'; /// ============================================================ /// 【ReportComputationPage】AI 加载动画页 —— 对标 Vue reportComputation.vue /// /// 入门测 H5 壳子看完所有科目报告 → navigateTo reportComputation → -/// 圆环进度 + 每个科目依次"AI计算中"动画 → finishedPopup 弹窗 → +/// 圆环进度 + 每个科目依次"AI计算中"动画 → 500ms 后 finishedPopup 弹窗 → /// 调 ChangeShowReport → refresh userProvider → switchTab 首页 +/// +/// Vue 端用 tPageImg/tPageColor 从 themes/default.json 抽主题; +/// Flutter 端未做主题抽离,本页资源路径/颜色集中在 _ComputationTheme。 /// ============================================================ + +/// 本页主题资源(对标 default.json → pages/reportPackage/reportComputation) +class _ComputationTheme { + // ---- images ---- + static const String bg = + '/StaticResource/2026-06-11/bg-report21781144905849.png'; + static const String bgPad = + '/StaticResource/2026-06-23/bg-report_pad1782196580176.png'; + static const String bubble = + '/StaticResource/2026-06-11/Group33(2)1781142719442.png'; + + // ---- colors ---- + static const Color pageBg = Color(0xFF275FD9); // background +} + +String _img(String path) => ImgUrl.build(path); + class ReportComputationPage extends ConsumerStatefulWidget { const ReportComputationPage({super.key}); @@ -26,15 +53,21 @@ class ReportComputationPage extends ConsumerStatefulWidget { class _ReportComputationPageState extends ConsumerState { List<_SubjectData> _subjectList = []; - int _ringProgress = 0; + double _ringProgress = 0; bool _showFinishedPopup = false; - int _currentIndex = -1; Timer? _animTimer; + /// 每个科目卡片的 key,用于动画切换时自动滚动到可见区域 + /// (对标 Vue scroll-into-view / scrollToItemIfNeeded) + final List _itemKeys = []; + @override void initState() { super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) => _loadAndStart()); + WidgetsBinding.instance.addPostFrameCallback((_) { + _precacheImages(); + _loadAndStart(); + }); } @override @@ -43,6 +76,18 @@ class _ReportComputationPageState extends ConsumerState { super.dispose(); } + /// 预加载弹窗/背景图,避免首次显示闪烁(对标 Vue onMounted getImageInfo) + void _precacheImages() { + final isPad = MediaQuery.sizeOf(context).width >= AppAdapt.tabletBreakpoint; + for (final path in [ + isPad ? _ComputationTheme.bgPad : _ComputationTheme.bg, + ComputationFinishedPopup.topLightPadImg, + ComputationFinishedPopup.contentBgImg, + ]) { + precacheImage(CachedNetworkImageProvider(_img(path)), context); + } + } + Future _loadAndStart() async { try { final results = await Future.wait([ @@ -55,20 +100,26 @@ class _ReportComputationPageState extends ConsumerState { if (report.subjectReports.isEmpty) return; final itemTime = (15 / report.subjectReports.length).floor(); + final list = report.subjectReports.map((item) { + final knowItem = knowList.isNotEmpty + ? knowList.firstWhere( + (k) => k.subjectName == item.subjectName, + orElse: () => const UserReportKnowType(), + ) + : const UserReportKnowType(); + return _SubjectData( + subjectReport: item, + knowCount: knowItem.knowCount, + time: itemTime, + ); + }).toList(); + + if (!mounted) return; setState(() { - _subjectList = report.subjectReports.map((item) { - final knowItem = knowList.isNotEmpty - ? knowList.firstWhere( - (k) => k.subjectName == item.subjectName, - orElse: () => const UserReportKnowType(), - ) - : const UserReportKnowType(); - return _SubjectData( - subjectReport: item, - knowCount: knowItem.knowCount, - time: itemTime, - ); - }).toList(); + _subjectList = list; + _itemKeys + ..clear() + ..addAll(List.generate(list.length, (_) => GlobalKey())); }); _startSubjects(0); } catch (e) { @@ -78,18 +129,19 @@ class _ReportComputationPageState extends ConsumerState { void _startSubjects(int index) { if (index >= _subjectList.length) { - setState(() { - _ringProgress = 100; - _showFinishedPopup = true; + setState(() => _ringProgress = 100); + // Vue:最后一个科目完成后延迟 500ms 弹弹窗 + Timer(const Duration(milliseconds: 500), () { + if (mounted) setState(() => _showFinishedPopup = true); }); return; } setState(() { - _currentIndex = index; - _subjectList[index].status = _SubjectStatus.inProgress; + _subjectList[index].status = ComputationSubjectStatus.inProgress; _subjectList[index].progress = 0; }); + _scrollToItem(index); final totalTimeMs = _subjectList[index].time * 1000; final startTime = DateTime.now().millisecondsSinceEpoch; @@ -100,8 +152,7 @@ class _ReportComputationPageState extends ConsumerState { setState(() { _subjectList[index].progress = progress; - _ringProgress = - ((index * 100) + progress) ~/ _subjectList.length; + _ringProgress = (index * 100 + progress) / _subjectList.length; }); if (elapsed >= totalTimeMs) { @@ -109,13 +160,28 @@ class _ReportComputationPageState extends ConsumerState { _animTimer = null; setState(() { _subjectList[index].progress = 100; - _subjectList[index].status = _SubjectStatus.finished; + _subjectList[index].status = ComputationSubjectStatus.finished; }); _startSubjects(index + 1); } }); } + /// 当前科目卡片不完全可见时,滚动到可见区域 + void _scrollToItem(int index) { + WidgetsBinding.instance.addPostFrameCallback((_) { + final ctx = _itemKeys[index].currentContext; + if (ctx != null) { + Scrollable.ensureVisible( + ctx, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + alignment: 0.2, + ); + } + }); + } + /// finishedPopup 的"立即开启学习蝶变"按钮 Future _onFinish() async { try { @@ -131,37 +197,64 @@ class _ReportComputationPageState extends ConsumerState { @override Widget build(BuildContext context) { + final isPad = MediaQuery.sizeOf(context).width >= AppAdapt.tabletBreakpoint; + final bottomPadding = MediaQuery.paddingOf(context).bottom; + return Scaffold( - backgroundColor: const Color(0xFF0F1A2E), + backgroundColor: _ComputationTheme.pageBg, body: Stack( children: [ + // ===== 背景图:宽铺满、居顶、高度自适应(Vue mode=widthFix)===== + Positioned( + top: 0, + left: 0, + right: 0, + child: CachedNetworkImage( + imageUrl: _img( + isPad ? _ComputationTheme.bgPad : _ComputationTheme.bg, + ), + fit: BoxFit.fitWidth, + errorWidget: (_, _, _) => const SizedBox.shrink(), + ), + ), SafeArea( + bottom: false, child: Column( children: [ - const SizedBox(height: 16), - const Text( - 'AI智能生成学习计划', - style: TextStyle( - color: Colors.white, - fontSize: 15, - fontWeight: FontWeight.w600, - ), + SizedBox(height: 8.h), + // AI核心算力气泡(454x98rpx) + CachedNetworkImage( + imageUrl: _img(_ComputationTheme.bubble), + width: 227.w, + height: 49.h, + fit: BoxFit.fill, + errorWidget: (_, _, _) => + SizedBox(width: 227.w, height: 49.h), ), - const SizedBox(height: 24), + SizedBox(height: 7.5.h), // 圆环进度 - _ProgressRing(progress: _ringProgress), - const SizedBox(height: 20), + ComputationProgressRing(progress: _ringProgress), // 科目列表 Expanded( child: ListView.separated( - padding: const EdgeInsets.symmetric( - horizontal: 14, - vertical: 10, + padding: EdgeInsets.fromLTRB( + 14.w, + 10.h, + 14.w, + 20.h + bottomPadding, ), itemCount: _subjectList.length, - separatorBuilder: (_, __) => const SizedBox(height: 8), + separatorBuilder: (_, _) => SizedBox(height: 7.5.h), itemBuilder: (_, index) { - return _SubjectItem(data: _subjectList[index]); + final data = _subjectList[index]; + return ComputationSubjectItem( + key: _itemKeys[index], + subjectName: data.subjectReport.subjectName, + iconUrl: data.subjectReport.icon, + knowCount: data.knowCount, + progress: data.progress, + status: data.status, + ); }, ), ), @@ -169,55 +262,7 @@ class _ReportComputationPageState extends ConsumerState { ), ), // finishedPopup 弹窗 - if (_showFinishedPopup) - Positioned.fill( - child: Container( - color: Colors.black54, - child: Center( - child: Container( - width: 320, - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.check_circle, - size: 48, color: Color(0xFF10C79C)), - const SizedBox(height: 12), - const Text( - '报告生成完成', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: Color(0xFF1D2B47), - ), - ), - const SizedBox(height: 20), - SizedBox( - width: double.infinity, - height: 50, - child: ElevatedButton( - onPressed: _onFinish, - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF3D7DF5), - foregroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(25), - ), - elevation: 0, - ), - child: const Text('立即开启学习蝶变'), - ), - ), - ], - ), - ), - ), - ), - ), + if (_showFinishedPopup) ComputationFinishedPopup(onStart: _onFinish), ], ), ); @@ -226,215 +271,16 @@ class _ReportComputationPageState extends ConsumerState { // ==================== 内部数据类 ==================== -enum _SubjectStatus { notStarted, inProgress, finished } - class _SubjectData { final SubjectReportType subjectReport; - int progress; - _SubjectStatus status; - int time; - int knowCount; + int progress = 0; + ComputationSubjectStatus status = ComputationSubjectStatus.notStarted; + final int time; + final int knowCount; _SubjectData({ required this.subjectReport, required this.time, required this.knowCount, - this.progress = 0, - this.status = _SubjectStatus.notStarted, }); } - -// ==================== 圆环进度组件 ==================== - -class _ProgressRing extends StatelessWidget { - const _ProgressRing({required this.progress}); - final int progress; - - @override - Widget build(BuildContext context) { - return SizedBox( - width: 262, - height: 262, - child: Stack( - alignment: Alignment.center, - children: [ - // 背景圈 - SizedBox( - width: 240, - height: 240, - child: CircularProgressIndicator( - value: 1.0, - strokeWidth: 8, - backgroundColor: Colors.white.withValues(alpha: 0.1), - valueColor: const AlwaysStoppedAnimation( - Color(0xFF1D2B47), - ), - ), - ), - // 进度圈 - SizedBox( - width: 240, - height: 240, - child: CircularProgressIndicator( - value: progress / 100, - strokeWidth: 8, - backgroundColor: Colors.transparent, - valueColor: const AlwaysStoppedAnimation( - Color(0xFF3D7DF5), - ), - ), - ), - // 中心文字 - Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - '$progress%', - style: const TextStyle( - color: Colors.white, - fontSize: 40, - fontWeight: FontWeight.w700, - ), - ), - const Text( - 'AI加载中', - style: TextStyle( - color: Color(0xFF687FAD), - fontSize: 14, - fontWeight: FontWeight.w700, - ), - ), - ], - ), - ], - ), - ); - } -} - -// ==================== 科目卡片组件 ==================== - -class _SubjectItem extends StatelessWidget { - const _SubjectItem({required this.data}); - final _SubjectData data; - - @override - Widget build(BuildContext context) { - final isFinished = data.status == _SubjectStatus.finished; - final isInProgress = data.status == _SubjectStatus.inProgress; - - return Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: isFinished - ? const [Color(0xFFE3FCF4), Color(0xFFF7FCFA), Colors.white] - : const [Color(0xFFE3F7FC), Color(0xFFF7FBFC), Colors.white], - ), - boxShadow: const [ - BoxShadow( - color: Colors.black12, - blurRadius: 6, - offset: Offset(0, 2), - ), - ], - ), - child: Row( - children: [ - // 状态徽章 - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: isFinished - ? const Color(0xFF19CE92) - : isInProgress - ? const Color(0xFF3D7DF5) - : const Color(0xFFC0C4CC), - borderRadius: BorderRadius.circular(10), - ), - child: Text( - '${data.progress}%', - style: const TextStyle( - color: Colors.white, - fontSize: 14, - fontWeight: FontWeight.w600, - ), - ), - ), - const SizedBox(width: 12), - // 科目图标 - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: data.subjectReport.icon.isNotEmpty - ? Image.network( - data.subjectReport.icon, - width: 26, - height: 26, - fit: BoxFit.cover, - errorBuilder: (_, __, ___) => const Icon( - Icons.menu_book, - size: 26, - color: Color(0xFF3D7DF5), - ), - ) - : const Icon( - Icons.menu_book, - size: 26, - color: Color(0xFF3D7DF5), - ), - ), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - data.subjectReport.subjectName, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: Color(0xFF1D2B47), - ), - ), - const SizedBox(height: 6), - // 科目内部进度条 - ClipRRect( - borderRadius: BorderRadius.circular(4), - child: LinearProgressIndicator( - value: data.progress / 100, - backgroundColor: const Color(0xFFE1EAFA), - valueColor: AlwaysStoppedAnimation( - isFinished - ? const Color(0xFF19CE92) - : const Color(0xFF3D7DF5), - ), - minHeight: 4, - ), - ), - ], - ), - ), - const SizedBox(width: 8), - // 知识点数量 - Container( - padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3), - decoration: BoxDecoration( - color: const Color(0xFFE1EAFA), - borderRadius: BorderRadius.circular(4), - ), - child: Text( - '${data.knowCount}', - style: const TextStyle( - fontSize: 12, - color: Color(0xFF3D7DF5), - ), - ), - ), - ], - ), - ); - } -} diff --git a/lib/features/report/student_info_page.dart b/lib/features/report/student_info_page.dart index 28d7d6b..a62f94a 100644 --- a/lib/features/report/student_info_page.dart +++ b/lib/features/report/student_info_page.dart @@ -1,17 +1,19 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import 'package:new_learn/core/api/auth/api.dart'; import 'package:new_learn/core/api/report/api.dart'; import 'package:new_learn/core/api/report/model.dart'; -import 'package:new_learn/core/config/app_config.dart'; import 'package:new_learn/core/logger/app_talker.dart'; -import 'package:new_learn/core/network/dio_client.dart'; import 'package:new_learn/core/providers/user_provider.dart'; +import 'package:new_learn/core/utils/img_url.dart'; /// ============================================================ /// 【StudentInfoPage】诊断报告欢迎介绍页 —— 对标 Vue studentInfo.vue /// +/// Vue 端用 tPageImg / tPageCss / tPageColor 从 themes/default.json 抽主题; +/// Flutter 端未做主题抽离,所以直接硬编码 default.json 里这一页的资源路径、 +/// 颜色值、渐变配置。集中在本文件顶部 _StudentTheme 常量,便于后续抽离。 +/// /// 路由参数(GoRouteState.extra): /// context.go('/studentInfo', extra: {'type': 'placementTest'}); /// - type: placementTest(入门测)| exitTest(出门测) @@ -19,6 +21,87 @@ import 'package:new_learn/core/providers/user_provider.dart'; /// 完整链路:登录成功/全局拦截 → 此页 → H5壳子 → reportComputation(入门测) /// 或 switchTab 首页(出门测) /// ============================================================ + +/// 本页用到的所有主题资源(对标 Vue themes/default.json → pages/reportPackage/studentInfo) +class _StudentTheme { + // ---- 图片(default.json images)---- + // Flutter 端用 network 加载,需拼 CDN 域名;这里存相对路径, + // 使用时调 ImgUrl.build(path) + static const String bg = + '/StaticResource/2026-06-10/bg-report1781093056538.png'; + static const String bubble = + '/StaticResource/2026-06-12/report_kuang1781230963682.png'; + static const String welcome = + '/StaticResource/2026-06-10/Group3331781094167604.png'; + static const String analytics = + '/StaticResource/2026-06-10/analytics1781081543912.png'; + static const String logo = '/StaticResource/2026-06-10/logo1781083733383.png'; + static const String aiAnalysisTitle = + '/StaticResource/2026-06-10/ai-analysis-title1781087439752.png'; + static const String analysisTitle = + '/StaticResource/2026-06-22/analysis-title1782110217839.png'; + static const String levelA = + '/StaticResource/2026-06-23/levelA1782178351618.png'; + static const String levelB = + '/StaticResource/2026-06-23/levelB1782178351618.png'; + static const String levelC = + '/StaticResource/2026-06-23/levelC1782178351618.png'; + static const String verify = + '/StaticResource/2026-06-10/verify1781081993368.png'; + static const String starIcon = + '/StaticResource/2026-06-10/star1781089096898.png'; + + // ---- 颜色(default.json colors)---- + static const Color pageBg = Color(0xFFE5F6FE); // background + static const Color highlight = Color(0xFF3D7DF5); + + // ---- 渐变(default.json css 里的 linear-gradient/rgba 转 Flutter)---- + // item-bg: linear-gradient(180deg, #F5FBFF → #E5F4FF) + static const LinearGradient itemBg = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFFF5FBFF), Color(0xFFE5F4FF)], + ); + + // item-border-color: linear-gradient(180deg, #FFFFFF → #C9E9FF(50%) → #BAE2FF) + static const LinearGradient itemBorder = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFFFFFFFF), Color(0xFFC9E9FF), Color(0xFFBAE2FF)], + ); + + // analysis-view-bg: linear-gradient(180deg, #DAF3FF → #D4ECFF(52%) → #E5FAFB) + static const LinearGradient analysisViewBg = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFFDAF3FF), Color(0xFFD4ECFF), Color(0xFFE5FAFB)], + ); + + // text-line: 底部渐变条(大字号等级的下划线) + // linear-gradient(90deg, rgba(61,125,245,0) → rgba(29,156,255,0.2) → rgba(138,236,253,0.5)) + static const LinearGradient textLine = LinearGradient( + colors: [Color(0x003D7DF5), Color(0x331D9CFF), Color(0x808AECFD)], + ); + + // button-text-bg: linear-gradient(0deg, #FFFFFF → #E0F2FF) + static const LinearGradient buttonTextBg = LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [Color(0xFFFFFFFF), Color(0xFFE0F2FF)], + ); + + // start-button-bg 径向渐变 + #3d7df5(简化:Flutter 径向渐变不太好还原多层 radial, + // 这里用线性渐变近似,视觉上接近即可) + static const LinearGradient startButtonBg = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF5B9BFF), Color(0xFF3D7DF5)], + ); +} + +/// 图片 URL 拼接工具(对标 Vue $img('/StaticResource/xxx'),复用项目 ImgUrl.build) +String _img(String path) => ImgUrl.build(path); + class StudentInfoPage extends ConsumerStatefulWidget { const StudentInfoPage({super.key}); @@ -35,7 +118,8 @@ class _StudentInfoPageState extends ConsumerState { void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { - final extra = GoRouterState.of(context).extra as Map? ?? {}; + final extra = + GoRouterState.of(context).extra as Map? ?? {}; _reportType = (extra['type'] as String?) ?? 'placementTest'; _loadReport(); }); @@ -45,18 +129,9 @@ class _StudentInfoPageState extends ConsumerState { setState(() => _loading = true); try { final isExit = _reportType == 'exitTest'; - final apiPath = isExit - ? '/api/v1/UserReport/GetCurrentAfterReport' - : '/api/v1/UserReport/GetCurrentReport'; - talker.debug('studentInfo 调 $apiPath'); final report = isExit ? await ReportApi.instance.getCurrentAfterReport() : await ReportApi.instance.getCurrentReport(); - talker.debug( - 'studentInfo 响应: userName=${report.userName} ' - 'subjectReports.length=${report.subjectReports.length} ' - 'raw=${report.subjectReports.toString()}', - ); if (mounted) { setState(() { _report = report; @@ -69,11 +144,8 @@ class _StudentInfoPageState extends ConsumerState { } } - /// 按钮点击:subjectReports 为空 → 降级置 false 回首页;否则进 H5 壳子 Future _onStart() async { if (_report.subjectReports.isEmpty) { - // 降级:只改本地标记,不调后端 - talker.debug('studentInfo: subjectReports 为空,降级本地置 false'); if (_reportType == 'exitTest') { await ref.read(currentUserProvider.notifier).markShowAfterReportFalse(); } else { @@ -82,10 +154,7 @@ class _StudentInfoPageState extends ConsumerState { if (mounted) context.go('/'); return; } - // 进 H5 壳子 - context.push('/reportWebView', extra: { - 'type': _reportType, - }); + context.push('/reportWebView', extra: {'type': _reportType}); } @override @@ -96,170 +165,385 @@ class _StudentInfoPageState extends ConsumerState { : (user?.userName ?? ''); return Scaffold( - backgroundColor: const Color(0xFFF5F8FF), - appBar: AppBar( - backgroundColor: Colors.transparent, - elevation: 0, - leading: IconButton( - icon: const Icon(Icons.arrow_back_ios_new, color: Color(0xFF1D2B47)), - onPressed: () => context.canPop() ? context.pop() : context.go('/'), - ), - ), + backgroundColor: _StudentTheme.pageBg, body: _loading ? const Center(child: CircularProgressIndicator()) - : SafeArea( - child: Column( + : SizedBox.expand( + child: Stack( children: [ - // 顶部问候 - Padding( - padding: const EdgeInsets.symmetric(horizontal: 28), - child: Row( - children: [ - const Text( - 'Hi . ', - style: TextStyle( - fontSize: 28, - fontWeight: FontWeight.w900, - color: Colors.white, - ), - ), - Text( - userName, - style: const TextStyle( - fontSize: 28, - fontWeight: FontWeight.w900, - color: Colors.white, - ), - ), - ], + // ===== 背景层(Vue bg-wrapper)铺满 ===== + Positioned.fill( + child: Image.network( + _img(_StudentTheme.bg), + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => const SizedBox.shrink(), ), ), - // 科目卡片列表 - Expanded( + // ===== 内容层(滚动,底部留出按钮高度)===== + SafeArea( child: SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(28, 120, 28, 20), + padding: const EdgeInsets.only(bottom: 140), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const Padding( - padding: EdgeInsets.only(bottom: 12), - child: Text( - '诊断科目分析', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: Color(0xFF1D2B47), - ), - ), - ), - Container( - width: double.infinity, - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - ), - child: _report.subjectReports.isEmpty - ? const Center( - child: Text( - '暂无数据', - style: TextStyle(color: Color(0xFF687FAD)), - ), - ) - : Wrap( - spacing: 10, - runSpacing: 10, - children: _report.subjectReports - .map((s) => _SubjectCard( - subject: s, - )) - .toList(), - ), - ), + _buildHeader(userName), + const SizedBox(height: 130), + // _buildAnalysisLevel(), + // const SizedBox(height: 15), + _buildSubjectCard(), + const SizedBox(height: 14), + _buildLogo(), ], ), ), ), - // 底部按钮 - Padding( - padding: const EdgeInsets.fromLTRB(29, 20, 29, 20), - child: SizedBox( - width: double.infinity, - height: 50, - child: ElevatedButton( - onPressed: _onStart, - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF3D7DF5), - foregroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(25), - ), - elevation: 0, - ), - child: const Text( - '开启智能诊断报告', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), - ), - ), + // ===== 底部按钮(fixed,无边距)===== + Positioned( + left: 0, + right: 0, + bottom: 0, + child: SafeArea(top: false, child: _buildBottomButton()), ), ], ), ), ); } -} -class _SubjectCard extends StatelessWidget { - const _SubjectCard({required this.subject}); - final SubjectReportType subject; + // ===== 顶部问候区 + welcome 插画 ===== + // Vue: bubble-wrap(背景图气泡 + userName)+ welcome-wrap(欢迎插画) + Widget _buildHeader(String userName) { + return Padding( + padding: const EdgeInsets.only(left: 14, right: 14, top: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // bubble 气泡(用 DecoratedBox 做背景图) + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + // borderRadius: BorderRadius.circular(12), + image: DecorationImage( + image: NetworkImage(_img(_StudentTheme.bubble)), + fit: BoxFit.fill, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Hi . ', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w900, + color: Colors.white, + ), + ), + Text( + userName, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w900, + color: Colors.white, + ), + ), + ], + ), + ), + const SizedBox(height: 10), + // welcome 插画(占位,没图时跳过) + Image.network( + _img(_StudentTheme.welcome), + width: 165, + fit: BoxFit.fitWidth, + errorBuilder: (_, __, ___) => const SizedBox.shrink(), + ), + ], + ), + ); + } - @override - Widget build(BuildContext context) { - return Container( - width: 72, - height: 88, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - gradient: const LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Color(0xFFF5F8FF), Color(0xFFE6EEFF)], + // ===== 当前档次等级卡片(Vue analysis-view,被注释掉的)===== + Widget _buildAnalysisLevel() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 14), + child: Container( + width: double.infinity, + // analysis-view-bg 渐变外层 + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + gradient: _StudentTheme.analysisViewBg, + boxShadow: const [ + BoxShadow( + color: Color(0x1A3D7DF5), + blurRadius: 15, + offset: Offset(0, 5), + ), + ], + ), + padding: const EdgeInsets.fromLTRB(16, 18, 16, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // analysis-title 图 + Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Image.network( + _img(_StudentTheme.analysisTitle), + height: 18, + fit: BoxFit.fitHeight, + errorBuilder: (_, __, ___) => const SizedBox.shrink(), + ), + ], + ), + const SizedBox(height: 12), + // 内层白色卡片:大字号 level + 文字 + Container( + width: double.infinity, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.fromLTRB(16, 12, 16, 18), + child: Stack( + clipBehavior: Clip.none, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 大字号等级 + text-line 下划线 + Stack( + alignment: Alignment.bottomLeft, + children: [ + Text( + _report.userLevel.isNotEmpty + ? _report.userLevel + : '--', + style: const TextStyle( + fontSize: 30, + fontWeight: FontWeight.w600, + color: Color(0xFF1D2B47), + height: 1.0, + ), + ), + // 底部渐变条 + Positioned( + left: 0, + right: 0, + bottom: -2, + child: Container( + height: 4, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(2), + gradient: _StudentTheme.textLine, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + // ai-analysis-title 图("AI 智能诊断评定") + Image.network( + _img(_StudentTheme.aiAnalysisTitle), + height: 14, + fit: BoxFit.fitHeight, + errorBuilder: (_, __, ___) => const SizedBox.shrink(), + ), + ], + ), + // 右上角 level${level} 图标 + Positioned( + right: -18, + top: -44, + child: _LevelIcon(level: _report.userLevel), + ), + ], + ), + ), + ], ), ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: subject.icon.isNotEmpty - ? Image.network( - subject.icon, - width: 40, - height: 40, - fit: BoxFit.cover, - errorBuilder: (_, __, ___) => const Icon( - Icons.menu_book, - size: 40, - color: Color(0xFF3D7DF5), + ); + } + + // ===== 诊断科目分析卡片 ===== + Widget _buildSubjectCard() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 14), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // header: analytics 图标 + title + 数据已校准 + Row( + children: [ + Image.network( + _img(_StudentTheme.analytics), + width: 18, + height: 18, + errorBuilder: (_, __, ___) => const SizedBox.shrink(), + ), + const SizedBox(width: 6), + const Text( + '诊断科目分析', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Color(0xFF1D2B47), + ), + ), + const Spacer(), + Image.network( + _img(_StudentTheme.verify), + width: 16, + height: 16, + errorBuilder: (_, __, ___) => const SizedBox.shrink(), + ), + const SizedBox(width: 3), + const Text( + '数据已校准', + style: TextStyle(fontSize: 11, color: Color(0xFF00CC96)), + ), + ], + ), + const SizedBox(height: 16), + // subject list + _report.subjectReports.isEmpty + ? const Center( + child: Text( + '暂无数据', + style: TextStyle(color: Color(0xFF687FAD)), ), ) - : const Icon( - Icons.menu_book, - size: 40, - color: Color(0xFF3D7DF5), + : Wrap( + spacing: 10, + runSpacing: 10, + children: _report.subjectReports + .map((s) => _SubjectCard(subject: s)) + .toList(), ), + ], + ), + ), + ); + } + + // ===== logo ===== + Widget _buildLogo() { + return Center( + child: Image.network( + _img(_StudentTheme.logo), + width: 65, // 130rpx ≈ 65dp + fit: BoxFit.fitWidth, + errorBuilder: (_, __, ___) => const SizedBox.shrink(), + ), + ); + } + + // ===== 底部按钮区(fixed 底部,无边距,圆角顶对齐屏幕)===== + Widget _buildBottomButton() { + return Container( + width: double.infinity, + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + RichText( + text: TextSpan( + style: const TextStyle( + fontSize: 11, + height: 1.6, + color: Color(0xFF495A7A), + ), + children: [ + WidgetSpan( + alignment: PlaceholderAlignment.top, + child: Padding( + padding: const EdgeInsets.only(right: 6), + child: Image.network( + _img(_StudentTheme.aiAnalysisTitle), + width: 95, + fit: BoxFit.fitWidth, + errorBuilder: (_, __, ___) => const SizedBox.shrink(), + ), + ), + ), + const TextSpan(text: '本报告基于'), + const TextSpan( + text: '15年高考真题精研', + style: TextStyle(color: Color(0xFF3D7DF5)), + ), + const TextSpan(text: '、'), + const TextSpan( + text: '50万道名校模拟题库', + style: TextStyle(color: Color(0xFF3D7DF5)), + ), + const TextSpan(text: '及'), + const TextSpan( + text: '3,682个', + style: TextStyle(color: Color(0xFF3D7DF5)), + ), + const TextSpan(text: '底层知识点拓扑结构,通过爱学智算引擎进行深度解析。'), + ], + ), ), - const SizedBox(height: 8), - Text( - subject.subjectName, - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: Color(0xFF1D2B47), + const SizedBox(height: 10), + // start-button + SizedBox( + width: double.infinity, + height: 44, + child: ElevatedButton( + onPressed: _onStart, + style: ElevatedButton.styleFrom( + padding: EdgeInsets.zero, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(22), + ), + elevation: 0, + backgroundColor: Colors.transparent, + ), + child: Ink( + decoration: BoxDecoration( + gradient: _StudentTheme.startButtonBg, + borderRadius: BorderRadius.circular(22), + border: Border.all(color: const Color(0x4DFFFFFF), width: 2), + ), + child: Container( + alignment: Alignment.center, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Image.network( + _img(_StudentTheme.starIcon), + width: 24, + height: 24, + errorBuilder: (_, __, ___) => const SizedBox.shrink(), + ), + const SizedBox(width: 6), + const Text( + '开启智能诊断报告', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), ), ), ], @@ -267,3 +551,104 @@ class _SubjectCard extends StatelessWidget { ); } } + +/// 科目卡片 —— item-bg 渐变 + item-border-color 渐变边框 +class _SubjectCard extends StatelessWidget { + const _SubjectCard({required this.subject}); + final SubjectReportType subject; + + @override + Widget build(BuildContext context) { + return Container( + width: 72, // 144rpx + height: 88, // 176rpx + // 渐变边框用双层 Container 实现 + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + gradient: _StudentTheme.itemBorder, + ), + padding: const EdgeInsets.all(1), // 边框厚度 + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(11), + gradient: _StudentTheme.itemBg, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: subject.icon.isNotEmpty + ? Image.network( + subject.icon, + width: 40, + height: 40, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => const Icon( + Icons.menu_book, + size: 40, + color: Color(0xFF3D7DF5), + ), + ) + : const Icon( + Icons.menu_book, + size: 40, + color: Color(0xFF3D7DF5), + ), + ), + const SizedBox(height: 8), + Text( + subject.subjectName, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: Color(0xFF1D2B47), + ), + ), + ], + ), + ), + ); + } +} + +/// 右上角等级图标:levelA/B/C 图片,加载失败时用占位 +class _LevelIcon extends StatelessWidget { + const _LevelIcon({required this.level}); + final String level; + + @override + Widget build(BuildContext context) { + // 大写化匹配 default.json 的 key + final key = level.toUpperCase(); + final iconPath = switch (key) { + 'A' => _StudentTheme.levelA, + 'B' => _StudentTheme.levelB, + 'C' => _StudentTheme.levelC, + _ => _StudentTheme.levelB, // 兜底 + }; + return Image.network( + _img(iconPath), + width: 110, + height: 110, + fit: BoxFit.contain, + errorBuilder: (_, __, ___) => Container( + width: 110, + height: 110, + alignment: Alignment.center, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(22), + gradient: _StudentTheme.startButtonBg, + ), + child: Text( + key, + style: const TextStyle( + fontSize: 48, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ), + ); + } +} diff --git a/lib/features/report/widgets/computation_finished_popup.dart b/lib/features/report/widgets/computation_finished_popup.dart new file mode 100644 index 0000000..2021a36 --- /dev/null +++ b/lib/features/report/widgets/computation_finished_popup.dart @@ -0,0 +1,171 @@ +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/utils/img_url.dart'; +import 'package:new_learn/core/utils/screen_adapt_template.dart'; + +/// ============================================================ +/// 【ComputationFinishedPopup】报告生成完成弹窗 +/// —— 对标 Vue finishedPopup.vue +/// +/// 黑色 60% 遮罩 + 顶部 62% 屏高的 top-light 光效图 + +/// 640x570rpx content-bg 内容图,底部「立即开启学习蝶变」渐变按钮, +/// 入场 0.5s 淡入上移(popup-fade-up)。 +/// ============================================================ +class ComputationFinishedPopup extends StatefulWidget { + const ComputationFinishedPopup({super.key, required this.onStart}); + + final VoidCallback onStart; + + static const String topLightImg = + '/StaticResource/2026-06-11/top-light1781173654372.png'; + static const String topLightPadImg = + '/StaticResource/2026-06-24/top-light_pad1782270110001.png'; + static const String contentBgImg = + '/StaticResource/2026-06-11/content-bg1781173794633.png'; + static const String starImg = + '/StaticResource/2026-06-10/star1781089096898.png'; + + @override + State createState() => + _ComputationFinishedPopupState(); +} + +class _ComputationFinishedPopupState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 500), + )..forward(); + + late final Animation _opacity = CurvedAnimation( + parent: _controller, + curve: Curves.easeOut, + ); + late final Animation _offset = + Tween(begin: const Offset(0, 0.08), end: Offset.zero).animate( + CurvedAnimation(parent: _controller, curve: Curves.easeOut), + ); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final screenHeight = MediaQuery.sizeOf(context).height; + final isPad = + MediaQuery.sizeOf(context).width >= AppAdapt.tabletBreakpoint; + + return Stack( + children: [ + // popup-mask + const Positioned.fill(child: ColoredBox(color: Color(0x99000000))), + // top-light:宽铺满,高 62%,居顶 + Positioned( + top: 0, + left: 0, + right: 0, + height: screenHeight * 0.62, + child: CachedNetworkImage( + imageUrl: ImgUrl.build( + isPad + ? ComputationFinishedPopup.topLightPadImg + : ComputationFinishedPopup.topLightImg, + ), + fit: BoxFit.fill, + errorWidget: (_, _, _) => const SizedBox.shrink(), + ), + ), + // popup-content:640x570rpx + Center( + child: FadeTransition( + opacity: _opacity, + child: SlideTransition( + position: _offset, + child: SizedBox( + width: 320.w, + height: 310.h, + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill( + child: CachedNetworkImage( + imageUrl: + ImgUrl.build(ComputationFinishedPopup.contentBgImg), + fit: BoxFit.fill, + errorWidget: (_, _, _) => DecoratedBox( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8.r), + ), + ), + ), + ), + // gogo-button:90% 宽,距底 50rpx + Positioned( + left: 16.w, + right: 16.w, + bottom: 25.h, + child: _buildStartButton(), + ), + ], + ), + ), + ), + ), + ), + ], + ); + } + + Widget _buildStartButton() { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: widget.onStart, + child: Container( + height: 50.h, // 100rpx + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(999), + // start-button-bg 多层 radial-gradient 用线性渐变近似 + gradient: const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF6AA2FF), Color(0xFF3D7DF5)], + ), + border: Border.all(color: const Color(0x4DFFFFFF), width: 1.w), + boxShadow: const [ + BoxShadow( + color: Color(0x3D3D7DF5), + blurRadius: 20, + offset: Offset(0, 10), + ), + ], + ), + alignment: Alignment.center, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + CachedNetworkImage( + imageUrl: ImgUrl.build(ComputationFinishedPopup.starImg), + width: 24.w, + height: 24.w, + errorWidget: (_, _, _) => SizedBox(width: 24.w, height: 24.w), + ), + SizedBox(width: 6.w), + Text( + '立即开启学习蝶变', + style: TextStyle( + fontSize: 16.spMin, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/report/widgets/computation_progress_ring.dart b/lib/features/report/widgets/computation_progress_ring.dart new file mode 100644 index 0000000..0137646 --- /dev/null +++ b/lib/features/report/widgets/computation_progress_ring.dart @@ -0,0 +1,235 @@ +import 'dart:math' as math; +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/utils/img_url.dart'; + +/// ============================================================ +/// 【ComputationProgressRing】AI 计算圆环 —— 对标 Vue ProgressRing.vue +/// +/// 由外到内: +/// 1. 虚线刻度图(700rpx,6s 顺时针慢转) +/// 2. 外圈光环球图(524rpx,1.5s 反向快转) +/// 3. 外层渐变光圈 ellipse-wai(452rpx) +/// 4. 渐变进度弧(370rpx,线宽 20rpx,#8BFFF5 → #006CFF 纵向渐变) +/// 5. 白色内圆 ellipse-nei(300rpx)+ 渐变百分比文字 + 副标题 +/// +/// Vue 用 canvas 画弧;Flutter 用 CustomPaint,渐变方向与 Vue +/// createLinearGradient(center,0 → center,size) 保持一致(纵向)。 +/// ============================================================ +class ComputationProgressRing extends StatefulWidget { + const ComputationProgressRing({super.key, required this.progress}); + + /// 进度值 0-100 + final double progress; + + /// 外圈光环球图(Vue props.outerImg 默认值) + static const String outerImg = + '/StaticResource/2026-06-11/outer1781143064766.png'; + + /// 虚线刻度图(Vue props.dashedLineImg 默认值) + static const String dashedImg = + '/StaticResource/2026-06-11/dashedLine1781143064766.png'; + + @override + State createState() => + _ComputationProgressRingState(); +} + +class _ComputationProgressRingState extends State + with TickerProviderStateMixin { + /// 外圈:Vue rotate-cw 0 → -360deg / 1.5s + late final AnimationController _outerCtrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1500), + )..repeat(); + + /// 虚线:Vue rotate-ccw 0 → 360deg / 6s + late final AnimationController _dashedCtrl = AnimationController( + vsync: this, + duration: const Duration(seconds: 6), + )..repeat(); + + @override + void dispose() { + _outerCtrl.dispose(); + _dashedCtrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final size = 262.w; // 524rpx + return SizedBox( + width: size, + height: size, + child: Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + // 虚线刻度(700rpx,超出 524rpx 容器,允许溢出绘制) + RepaintBoundary( + child: RotationTransition( + turns: _dashedCtrl, + child: _ringImage(ComputationProgressRing.dashedImg, 350.w), + ), + ), + // 外圈光球 + RepaintBoundary( + child: RotationTransition( + turns: ReverseAnimation(_outerCtrl), + child: _ringImage(ComputationProgressRing.outerImg, 262.w), + ), + ), + // ellipse-wai:87.42deg 紫色渐变 + 内发光(内阴影用外发光近似) + Container( + width: 226.w, + height: 226.w, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: const LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + stops: [0.1133, 1.0], + colors: [Color(0x6B5B5DFF), Color(0x0000FFF2)], + ), + border: Border.all(color: const Color(0x33FFFFFF), width: 1.w), + boxShadow: const [ + BoxShadow(color: Color(0x33FFFFFF), blurRadius: 25), + ], + ), + ), + // 进度弧(canvas) + SizedBox( + width: 185.w, + height: 185.w, + child: CustomPaint(painter: _RingArcPainter(widget.progress)), + ), + // ellipse-nei:内圆 + Container( + width: 150.w, + height: 150.w, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: const LinearGradient( + begin: Alignment(-0.6, -0.8), + end: Alignment(0.87, 0.49), + stops: [0.42, 0.82], + colors: [Color(0xFFB1DFFF), Color(0xFFFFFFFF)], + ), + border: Border.all(color: const Color(0x80FFFFFF)), + boxShadow: const [ + BoxShadow( + color: Color(0x0D1D2B47), + blurRadius: 28, + offset: Offset(0, 14), + ), + BoxShadow(color: Color(0x80FFFFFF), blurRadius: 26), + ], + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + _gradientText( + widget.progress.floor().toString(), + 40.spMin, + height: 1.0, + ), + Padding( + padding: EdgeInsets.only(left: 2.w, bottom: 6.h), + child: _gradientText('%', 14.5.spMin), + ), + ], + ), + SizedBox(height: 2.h), + Text( + 'AI加载中', + style: TextStyle( + fontSize: 14.5.spMin, + fontWeight: FontWeight.bold, + color: const Color(0xFF0F9AFF), + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _ringImage(String path, double size) { + return CachedNetworkImage( + imageUrl: ImgUrl.build(path), + width: size, + height: size, + fit: BoxFit.contain, + errorWidget: (_, _, _) => SizedBox(width: size, height: size), + ); + } + + /// 渐变文字:linear-gradient(180deg, #006cff → #529bff) + Widget _gradientText(String text, double fontSize, {double? height}) { + return ShaderMask( + shaderCallback: (rect) => ui.Gradient.linear( + rect.topCenter, + rect.bottomCenter, + const [Color(0xFF006CFF), Color(0xFF529BFF)], + ), + child: Text( + text, + style: TextStyle( + fontSize: fontSize, + height: height, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ); + } +} + +/// 进度弧画笔(对标 Vue canvas arc + 纵向线性渐变 + round 线帽) +class _RingArcPainter extends CustomPainter { + _RingArcPainter(this.progress); + + final double progress; + + @override + void paint(Canvas canvas, Size size) { + final p = progress.clamp(0.0, 100.0); + if (p <= 0) return; + + final stroke = 10.w; // 20rpx + final center = Offset(size.width / 2, size.height / 2); + final radius = (size.width - stroke) / 2; + + final paint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = stroke + ..strokeCap = StrokeCap.round + ..shader = ui.Gradient.linear( + Offset(center.dx, center.dy - radius), + Offset(center.dx, center.dy + radius), + const [Color(0xFF8BFFF5), Color(0xFF006CFF)], + ); + + canvas.drawArc( + Rect.fromCircle(center: center, radius: radius), + -math.pi / 2, // 从 12 点方向开始 + 2 * math.pi * p / 100, + false, + paint, + ); + } + + @override + bool shouldRepaint(covariant _RingArcPainter oldDelegate) => + oldDelegate.progress != progress; +} diff --git a/lib/features/report/widgets/computation_subject_item.dart b/lib/features/report/widgets/computation_subject_item.dart new file mode 100644 index 0000000..3d906ec --- /dev/null +++ b/lib/features/report/widgets/computation_subject_item.dart @@ -0,0 +1,267 @@ +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/utils/img_url.dart'; + +/// 科目计算状态(对标 Vue SubjectStatus) +enum ComputationSubjectStatus { notStarted, inProgress, finished } + +/// ============================================================ +/// 【ComputationSubjectItem】AI 计算科目卡片 +/// —— 对标 reportComputation.vue 的 .subject-item +/// +/// 右上角标(subscript):未开始/进行中蓝色闪电底,已完成绿色对勾底; +/// 标题行:科目名 + 科目图标 + ai-plan「AI学习计划+」图; +/// 知识点行 + 底部渐变进度条(进行中蓝 / 已完成绿)。 +/// ============================================================ +class ComputationSubjectItem extends StatelessWidget { + const ComputationSubjectItem({ + super.key, + required this.subjectName, + required this.iconUrl, + required this.knowCount, + required this.progress, + required this.status, + }); + + final String subjectName; + final String iconUrl; + final int knowCount; + + /// 0-100 + final int progress; + final ComputationSubjectStatus status; + + // ---- 图片资源(default.json ai-plan + Vue 内联 $img 角标图)---- + static const String _aiPlanImg = + '/StaticResource/2026-06-11/aiPlan1781157211012.png'; + static const String _subscriptBgBlue = + '/StaticResource/2026-06-11/subscript-bg11781162055363.png'; + static const String _subscriptBgGreen = + '/StaticResource/2026-06-11/subscript-bg21781162055358.png'; + static const String _iconFlash = + '/StaticResource/2026-06-11/subscript-cion1-1781162401643.png'; + static const String _iconCheck = + '/StaticResource/2026-06-11/subscript-cion2-1781162401644.png'; + + bool get _isFinished => status == ComputationSubjectStatus.finished; + + @override + Widget build(BuildContext context) { + return Opacity( + // Vue: 未开始 opacity 0.8 + opacity: status == ComputationSubjectStatus.notStarted ? 0.8 : 1, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16.r), // 16rpx + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: const [0, 0.3, 1], + colors: _isFinished + ? const [Color(0xFFE3FCF4), Color(0xFFF7FCFA), Colors.white] + : const [Color(0xFFE3F7FC), Color(0xFFF7FBFC), Colors.white], + ), + ), + child: Stack( + clipBehavior: Clip.none, + children: [ + Padding( + padding: EdgeInsets.fromLTRB(14.w, 14.w, 14.w, 20.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildTitle(), + SizedBox(height: 16.h), // 知识点行 margin 32rpx 0 + _buildKnowledge(), + SizedBox(height: 16.h), + _buildProgressBar(), + ], + ), + ), + // 右上角标 + Positioned(top: 0, right: 0, child: _buildSubscript()), + ], + ), + ), + ); + } + + // ===== 标题行:科目名 + 科目图标 + AI学习计划图 ===== + Widget _buildTitle() { + return Row( + children: [ + Text( + subjectName, + style: TextStyle( + fontSize: 18.spMin, // 36rpx + fontWeight: FontWeight.w600, + color: const Color(0xFF1D2B47), + ), + ), + SizedBox(width: 6.w), + if (iconUrl.isNotEmpty) + CachedNetworkImage( + imageUrl: ImgUrl.build(iconUrl), + width: 26.w, // 52rpx,widthFix + fit: BoxFit.fitWidth, + errorWidget: (_, _, _) => Icon( + Icons.menu_book, + size: 26.w, + color: const Color(0xFF3D7DF5), + ), + ) + else + Icon(Icons.menu_book, size: 26.w, color: const Color(0xFF3D7DF5)), + SizedBox(width: 6.w), + CachedNetworkImage( + imageUrl: ImgUrl.build(_aiPlanImg), + width: 77.w, // 154rpx,widthFix + fit: BoxFit.fitWidth, + errorWidget: (_, _, _) => const SizedBox.shrink(), + ), + ], + ); + } + + // ===== 知识点行(Vue 预计时间已注释,只保留知识点)===== + Widget _buildKnowledge() { + return Container( + padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 6.h), + decoration: BoxDecoration( + color: const Color(0xFFF3F8FE), + borderRadius: BorderRadius.circular(4.r), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '知识点', + style: TextStyle( + fontSize: 12.spMin, + color: const Color(0xFF495A7A), + ), + ), + SizedBox(width: 6.w), + Container( + padding: EdgeInsets.symmetric(horizontal: 3.5.w), + decoration: BoxDecoration( + color: const Color(0xFFE1EAFA), + borderRadius: BorderRadius.circular(4.r), + ), + child: Text( + '$knowCount', + style: TextStyle( + fontSize: 12.spMin, + color: const Color(0xFF495A7A), + ), + ), + ), + ], + ), + ); + } + + // ===== 底部进度条:蓝渐变 / 绿渐变 ===== + Widget _buildProgressBar() { + return SizedBox( + height: 6.h, // 12rpx + child: LayoutBuilder( + builder: (_, constraints) { + final fillWidth = + constraints.maxWidth * (progress.clamp(0, 100) / 100); + return Stack( + children: [ + Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + color: const Color(0xFFE9EDF5), + borderRadius: BorderRadius.circular(3.h), + ), + ), + ), + Positioned( + left: 0, + top: 0, + bottom: 0, + child: Container( + width: fillWidth, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(3.h), + gradient: LinearGradient( + colors: _isFinished + ? const [Color(0xFFB4EADA), Color(0xFF19CE92)] + : const [ + Color(0xFF8AD1FD), + Color(0xFF3C84FF), + Color(0xFF3D7DF5), + ], + stops: _isFinished ? null : const [0, 0.64, 1], + ), + ), + ), + ), + ], + ); + }, + ), + ); + } + + // ===== 右上角标:162x66rpx 背景图 + 状态图标 + 百分比 ===== + Widget _buildSubscript() { + final bgImg = _isFinished ? _subscriptBgGreen : _subscriptBgBlue; + return SizedBox( + width: 81.w, + height: 33.h, + child: Stack( + children: [ + Positioned.fill( + child: CachedNetworkImage( + imageUrl: ImgUrl.build(bgImg), + fit: BoxFit.fill, + errorWidget: (_, _, _) => DecoratedBox( + decoration: BoxDecoration( + color: _isFinished + ? const Color(0xFF19CE92) + : const Color(0xFF3D7DF5), + borderRadius: BorderRadius.only( + topRight: Radius.circular(8.r), + ), + ), + ), + ), + ), + Padding( + padding: EdgeInsets.only(bottom: 5.h), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (status != ComputationSubjectStatus.notStarted) + Padding( + padding: EdgeInsets.only(right: 4.w), + child: CachedNetworkImage( + imageUrl: ImgUrl.build( + _isFinished ? _iconCheck : _iconFlash, + ), + width: 18.w, + height: 18.w, + errorWidget: (_, _, _) => + SizedBox(width: 18.w, height: 18.w), + ), + ), + Text( + '$progress%', + style: TextStyle( + fontSize: 16.spMin, // 32rpx + color: Colors.white, + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index d36cab9..66458f9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,12 +1,16 @@ import 'package:flutter/material.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:go_router/go_router.dart'; +// ignore: unused_import —— 调试结束切回 TabApp 时使用 import 'package:new_learn/core/app.dart'; import 'package:new_learn/core/network/dio_client.dart'; import 'package:new_learn/core/providers/privacy_provider.dart'; +import 'package:new_learn/core/router/app_router.dart'; import 'package:new_learn/core/storage/database/app_database.dart'; import 'package:new_learn/core/storage/privacy_storage.dart'; +import 'package:new_learn/core/utils/screen_adapt_template.dart'; /// App 启动入口 —— 真正的业务代码都在 app.dart / pages/ 里 /// @@ -127,4 +131,44 @@ Future main() async { await AppDatabase.instance.init(); // ProviderScope:Riverpod 所有 provider 的容器,必须包在根 runApp(const ProviderScope(child: TabApp())); + // 临时调试用,跑完改回。 + // 注意两点: + // 1. 页面组件用了 .w/.h/.spMin,必须包 ScreenUtilInit(与 TabApp 一致), + // 否则 ScreenUtil 的 late 字段未初始化会抛 LateInitializationError; + // 2. 必须用真实 appRouter(MaterialApp.router),不能用裸 MaterialApp(home:), + // 否则页面里 context.go('/') 找不到 GoRouter,点"立即开启学习蝶变"不会跳首页。 + // runApp(const ProviderScope(child: _DebugApp())); +} + +/// 临时调试壳:ScreenUtilInit + 真实路由表,启动后直接进 /reportComputation +class _DebugApp extends StatefulWidget { + const _DebugApp(); + + @override + State<_DebugApp> createState() => _DebugAppState(); +} + +class _DebugAppState extends State<_DebugApp> { + @override + void initState() { + super.initState(); + // 首帧后跳到调试页(默认初始位置是 '/',直接进会先闪一帧首页) + WidgetsBinding.instance.addPostFrameCallback((_) { + appRouter.go('/reportComputation'); + }); + } + + @override + Widget build(BuildContext context) { + return ScreenUtilInit( + designSize: AppAdapt.designSizeOf(context), + minTextAdapt: true, + splitScreenMode: true, + fontSizeResolver: AppAdapt.fontSizeResolver, + builder: (context, _) => MaterialApp.router( + debugShowCheckedModeBanner: false, + routerConfig: appRouter, + ), + ); + } }