1. 新增报告计算进度圆环、科目卡片、完成弹窗组件 2. 重构report_computation_page,替换旧的自定义组件为新封装组件 3. 新增student_info_page的主题资源和UI重构,适配Flutter屏幕适配 4. 新增临时调试入口用于开发测试
287 lines
9.5 KiB
Dart
287 lines
9.5 KiB
Dart
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,
|
||
});
|
||
}
|