Files
崔宝新 0fefc786bb feat(report): 实现AI学习报告计算页及相关组件
1. 新增报告计算进度圆环、科目卡片、完成弹窗组件
2. 重构report_computation_page,替换旧的自定义组件为新封装组件
3. 新增student_info_page的主题资源和UI重构,适配Flutter屏幕适配
4. 新增临时调试入口用于开发测试
2026-09-10 11:57:18 +08:00

287 lines
9.5 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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计算中"动画 → 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});
@override
ConsumerState<ReportComputationPage> createState() =>
_ReportComputationPageState();
}
class _ReportComputationPageState extends ConsumerState<ReportComputationPage> {
List<_SubjectData> _subjectList = [];
double _ringProgress = 0;
bool _showFinishedPopup = false;
Timer? _animTimer;
/// 每个科目卡片的 key,用于动画切换时自动滚动到可见区域
/// (对标 Vue scroll-into-view / scrollToItemIfNeeded)
final List<GlobalKey> _itemKeys = [];
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_precacheImages();
_loadAndStart();
});
}
@override
void dispose() {
_animTimer?.cancel();
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([
ReportApi.instance.getCurrentReport(),
ReportApi.instance.getCurrentReportKnow(),
]);
final report = results[0] as UserReportType;
final knowList = results[1] as List<UserReportKnowType>;
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 = list;
_itemKeys
..clear()
..addAll(List.generate(list.length, (_) => GlobalKey()));
});
_startSubjects(0);
} catch (e) {
talker.error('reportComputation 加载失败', e);
}
}
void _startSubjects(int index) {
if (index >= _subjectList.length) {
setState(() => _ringProgress = 100);
// Vue:最后一个科目完成后延迟 500ms 弹弹窗
Timer(const Duration(milliseconds: 500), () {
if (mounted) setState(() => _showFinishedPopup = true);
});
return;
}
setState(() {
_subjectList[index].status = ComputationSubjectStatus.inProgress;
_subjectList[index].progress = 0;
});
_scrollToItem(index);
final totalTimeMs = _subjectList[index].time * 1000;
final startTime = DateTime.now().millisecondsSinceEpoch;
_animTimer = Timer.periodic(const Duration(milliseconds: 30), (timer) {
final elapsed = DateTime.now().millisecondsSinceEpoch - startTime;
final progress = (elapsed / totalTimeMs * 100).clamp(0, 100).toInt();
setState(() {
_subjectList[index].progress = progress;
_ringProgress = (index * 100 + progress) / _subjectList.length;
});
if (elapsed >= totalTimeMs) {
timer.cancel();
_animTimer = null;
setState(() {
_subjectList[index].progress = 100;
_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 {
await UserApi.changeShowReport();
talker.debug('changeShowReport 成功');
await ref.read(currentUserProvider.notifier).refresh();
} catch (e) {
talker.error('changeShowReport 失败', e);
}
if (!mounted) return;
context.go('/');
}
@override
Widget build(BuildContext context) {
final isPad = MediaQuery.sizeOf(context).width >= AppAdapt.tabletBreakpoint;
final bottomPadding = MediaQuery.paddingOf(context).bottom;
return Scaffold(
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: [
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),
),
SizedBox(height: 7.5.h),
// 圆环进度
ComputationProgressRing(progress: _ringProgress),
// 科目列表
Expanded(
child: ListView.separated(
padding: EdgeInsets.fromLTRB(
14.w,
10.h,
14.w,
20.h + bottomPadding,
),
itemCount: _subjectList.length,
separatorBuilder: (_, _) => SizedBox(height: 7.5.h),
itemBuilder: (_, 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,
);
},
),
),
],
),
),
// finishedPopup 弹窗
if (_showFinishedPopup) ComputationFinishedPopup(onStart: _onFinish),
],
),
);
}
}
// ==================== 内部数据类 ====================
class _SubjectData {
final SubjectReportType subjectReport;
int progress = 0;
ComputationSubjectStatus status = ComputationSubjectStatus.notStarted;
final int time;
final int knowCount;
_SubjectData({
required this.subjectReport,
required this.time,
required this.knowCount,
});
}