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

175 lines
9.2 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 '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/ 里
///
/// 〇、环境 / 品牌参数(--dart-define 编译期注入,详见 core/config/app_config.dart):
/// 日常开发(默认正式环境 + 爱学蝶变,零参数):
/// flutter run
/// 连测试环境调试:
/// flutter run --dart-define=ENV=test
/// 打楚才正式包:
/// flutter build apk --release --dart-define=ENV=prod --dart-define=BRAND=chucai
/// 打豫志测试包:
/// flutter build apk --release --dart-define=ENV=test --dart-define=BRAND=yuzhi
/// BRAND 可选:aixue / chucai / yule / yuzhi / qinxiao / wansi / shucai
/// (品牌表见 core/config/app_brand.dart;不传默认 aixue=爱学蝶变)
///
/// 一、启动顺序(每步都是插件初始化,必须 runApp 前 await):
/// 1. WidgetsFlutterBinding.ensureInitialized():插件通道必须在 runApp 前就绪;
/// 2. await DioClient.restoreToken():冷启动把磁盘上的 Token 回填内存,
/// 这样即便没进登录页,后续请求也自动带 Authorization;
/// 3. await PrivacyStorage.readAccepted():恢复启动隐私协议同意状态;
/// 未同意时,App 首屏会先被 StartupGate + 路由守卫拦住;
/// 4. await AppDatabase.instance.init():初始化本地业务数据库(Isar,
/// 题库/错题/播放记录集合;web 调试模式自动跳过,见 app_database.dart);
/// 5. runApp:ProviderScope 包根(Riverpod 全局状态容器),进入 UI。
///
/// 二、完整路由运行流程(从 main() 到页面渲染):
///
/// main()
/// │
/// ├─ 1. WidgetsFlutterBinding.ensureInitialized()
/// ├─ 2. GoRouter.optionURLReflectsImperativeAPIs = true
/// ├─ 3. await DioClient.restoreToken() ← 恢复登录态
/// ├─ 4. privacyAcceptedNotifier.value = ... ← 恢复隐私同意状态
/// ├─ 5. await AppDatabase.instance.init() ← 初始化 Isar
/// │
/// └─ 6. runApp(ProviderScope(child: TabApp()))
/// │
/// └─ MaterialApp.router(routerConfig: appRouter)
/// │
/// ├─ builder: PrivacyStartupGate ← 视觉第一拦
/// │ ├─ 未同意隐私 → 全屏盖 PrivacyConsentPage
/// │ └─ 已同意隐私 → 放行 child(路由树)
/// │
/// └─ appRouter (GoRouter)
/// │
/// ├─ redirect: _guardLogin ← 每次导航都过一遍
/// │ │
/// │ │ 判断逻辑(按顺序):
/// │ │ ① 未同意隐私 且不在隐私页 → 重定向 /privacy-consent
/// │ │ ② 已同意隐私 且在隐私页 → 已登录去 /,未登录去 /login
/// │ │ ③ 未登录 且不在登录页 → 重定向 /login
/// │ │ ④ 已登录 且在登录页 → 重定向 /(首页)
/// │ │ ⑤ 以上都不满足 → return null(放行)
/// │ │
/// │ └─ 触发时机:
/// │ • 每次 context.push/go 导航时
/// │ • authTick 变化时(登录/登出 → 自动 refresh)
/// │ • privacyAcceptedNotifier 变化时
/// │
/// └─ routes 路由表:
/// │
/// ├─ ShellRoute (MainTabPage) ← 底部 4 tab 壳
/// │ ├─ / → TaskPage (任务)
/// │ ├─ /recite → RecitePage (背诵)
/// │ ├─ /class → ClassPage (班级)
/// │ └─ /my → MyPage (我的)
/// │ │
/// │ └─ MainTabPage.initState
/// │ └─ addPostFrameCallback
/// │ └─ _checkVersion() ← 版本检查(仅冷启动一次)
/// │
/// ├─ /privacy-consent → PrivacyConsentPage
/// ├─ /user-agreement → LegalDocumentPage
/// ├─ /privacy-policy → LegalDocumentPage
/// ├─ /login → LoginPage
/// └─ /myPoint → MyPointPage
///
/// 三、用户正常流程:
///
/// 首次安装:
/// 启动 → PrivacyStartupGate 拦住 → 隐私协议页 → 同意
/// → redirect 重定向 /login → 登录成功 → authTick 变化
/// → redirect 重定向 / → ShellRoute → MainTabPage → 版本检查
///
/// 老用户(已登录):
/// 启动 → 已同意隐私 + 有 Token → redirect 直接放行
/// → / → ShellRoute → MainTabPage → 版本检查
///
/// 四、关键文件对照:
/// main.dart = 启动脚本(本文件)
/// core/app.dart = 根组件(ScreenUtil + MaterialApp + PrivacyStartupGate)
/// core/router/app_router.dart = 路由表 + 登录守卫(对标 vue-router/index.ts)
/// core/router/main_tab_page.dart = 底部 tab 壳 + 版本检查触发点
/// core/router/router_keys.dart = 全局 NavigatorKey
///
/// 想单独跑某个学习 Demo 时,临时改这里,例如:
/// runApp(const ProviderScope(child: MaterialApp(home: DioDemoPage())));
/// 跑完记得改回 runApp(const ProviderScope(child: TabApp()));
Future<void> main() async {
// 需要在启动 App 之前读取本地存储、调用原生插件,必须写在 runApp 之前!
// 作用: 初始化 Flutter 和原生的绑定桥梁
WidgetsFlutterBinding.ensureInitialized();
// flutter_inappwebview 平台实现注册(Chrome Web / Windows desktop 必须显式触发)
// 否则会报 InAppWebViewPlatform.instance != null 断言错误
InAppWebViewPlatform.instance;
// go_router 17.x 大坑:命令式导航(context.push/go)默认不写回浏览器
// 地址栏(optionURLReflectsImperativeAPIs 默认 false)。必须在使用
// appRouter(runApp)之前全局开启,否则 web 调试 URL 不随页面变化。
GoRouter.optionURLReflectsImperativeAPIs = true;
// 冷启动恢复登录态(Token 从安全存储回填内存)
await DioClient.instance.restoreToken();
// 冷启动恢复"是否已同意启动隐私协议"状态。
// 这个值会同时驱动:
// 1. app.dart 里的 StartupGate(视觉上先拦住);
// 2. app_router.dart 里的 redirect(路由层再兜底一次)。
privacyAcceptedNotifier.value = await PrivacyStorage.instance.readAccepted();
// 初始化本地业务数据库(Isar 题库/错题/播放记录;web 调试自动跳过)
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,
),
);
}
}