feat(report): 实现AI学习报告计算页及相关组件
1. 新增报告计算进度圆环、科目卡片、完成弹窗组件 2. 重构report_computation_page,替换旧的自定义组件为新封装组件 3. 新增student_info_page的主题资源和UI重构,适配Flutter屏幕适配 4. 新增临时调试入口用于开发测试
This commit is contained in:
@@ -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<ReportComputationPage> {
|
||||
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<GlobalKey> _itemKeys = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _loadAndStart());
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_precacheImages();
|
||||
_loadAndStart();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -43,6 +76,18 @@ class _ReportComputationPageState extends ConsumerState<ReportComputationPage> {
|
||||
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<void> _loadAndStart() async {
|
||||
try {
|
||||
final results = await Future.wait([
|
||||
@@ -55,20 +100,26 @@ class _ReportComputationPageState extends ConsumerState<ReportComputationPage> {
|
||||
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<ReportComputationPage> {
|
||||
|
||||
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<ReportComputationPage> {
|
||||
|
||||
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<ReportComputationPage> {
|
||||
_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<void> _onFinish() async {
|
||||
try {
|
||||
@@ -131,37 +197,64 @@ class _ReportComputationPageState extends ConsumerState<ReportComputationPage> {
|
||||
|
||||
@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<ReportComputationPage> {
|
||||
),
|
||||
),
|
||||
// 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<ReportComputationPage> {
|
||||
|
||||
// ==================== 内部数据类 ====================
|
||||
|
||||
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>(
|
||||
Color(0xFF1D2B47),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 进度圈
|
||||
SizedBox(
|
||||
width: 240,
|
||||
height: 240,
|
||||
child: CircularProgressIndicator(
|
||||
value: progress / 100,
|
||||
strokeWidth: 8,
|
||||
backgroundColor: Colors.transparent,
|
||||
valueColor: const AlwaysStoppedAnimation<Color>(
|
||||
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<Color>(
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user