diff --git a/README.md b/README.md
index ebf6ebb..0205d46 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# TPSchedule
-学生课表 APP,Flutter 实现,目前接入电子科技大学研究生系统。Android 0.6.0+15,Android 7.0 及以上。
+学生课表 APP,Flutter 实现,目前接入电子科技大学研究生系统。Android 0.6.1+16,Android 7.0 及以上。
使用用户提供的原始 SVG 图标,蓝色渐变、黄色日历框及白色笔画保持一致;启动画面使用相同配色。0.5.1修复重复启动页闪烁:初始化期间保留原生启动屏,数据就绪后直接显示课表首帧,不再叠加Flutter品牌页或淡出动画。Android12+ 原生启动屏使用同色纯蓝背景及图标,旧版采用蓝色渐变。
@@ -20,6 +20,8 @@
0.6.0将Dock光晕改为白光,换周增加过渡,修复通知查询失败阻断提醒,并增加通知测试与诊断。我的页面新增反馈与共建。服务器仍需配置部署,详见[0.6.0验证记录](docs/tpschedule-0.6.0-validation.md)与[中文1Panel部署说明](server/feedback/部署说明.md)。
+0.6.1优化启动:先绘制与原生开屏衔接的品牌帧,读取本地课表后以300ms过渡进入首页,通知与日历随后同步;学校浏览器首次访问时才初始化,后台返回不重播开屏。基础和旧版Android开屏资源统一蓝底与图标尺寸。详见[0.6.1启动验证记录](docs/tpschedule-0.6.1-validation.md)。
+
0.5.4源码和安装包已冻结用于登记,位于dist/backups/TPSchedule-0.5.4-registration-20261014,Git标签tpschedule-0.5.4-registration。软著材料登记版本按用户要求为V1.0,使用冻结源码,未混入0.5.5功能,另存于output/pdf/TPSchedule-软著登记材料-V1.0。
## 安装与学校登录
diff --git a/docs/tpschedule-0.6.1-validation.md b/docs/tpschedule-0.6.1-validation.md
new file mode 100644
index 0000000..6b805e2
--- /dev/null
+++ b/docs/tpschedule-0.6.1-validation.md
@@ -0,0 +1,27 @@
+# TPSchedule 0.6.1+16:启动衔接与过渡
+
+2026-10-14。用户报告偶发开屏后黑屏2–3秒,希望增加进入首页的过渡。
+
+## 调查与改动
+
+旧入口在runApp之前等待完整controller.initialize,包括本地存储、通知及日历。延迟初始化回归在旧实现确认等待期间没有Flutter界面。另一个回归确认SchoolSession构造时就创建WebView,即使只查看本地课表也启动浏览器引擎。这两项均已先观察失败,再修复。
+
+- 先正常runApp绘制轻量品牌帧,随后初始化时区并读取本地课表。没有使用deferFirstFrame/allowFirstFrame,也没有增加固定停留时间。
+- 启动时使用initialize(reconcileServices:false),本地数据就绪后准备首页;通知与日历在首页场景提交后独立同步。默认initialize行为保留,其他业务调用不变。
+- SchoolSession的WebViewController改为首次学校访问时创建并复用;导航限制、HTTPS升级、身份验证、读取、学期选择、退出清理逻辑保留。
+- 首页先在不透明品牌覆盖层下布局和提交场景,再用300ms淡出品牌,首页轻微上移6逻辑像素。完成前禁止点击、焦点与首页无障碍操作;后台返回不重播该过渡,减少动画时直接显示。
+- 首个Flutter品牌帧与原生启动屏衔接:统一#38B3FF底色,原图标居中,前景画布188.8dp。基础及drawable-v21启动资源都已同步。应用图标及原SVG不改。
+- 引导阶段的插件异常可重试;保存文件读取失败仍进入原有恢复界面。没有用超时清空课表或重建保存文件。
+
+不透明覆盖层确认首页完成布局并提交场景,不保证隐藏内容已经全部由GPU渲染;手机最终效果仍受设备渲染影响。
+
+## 验证
+
+- 197项Flutter测试通过,静态分析无问题。
+- 覆盖持续等待仍显示品牌、逐步过渡/期间防误触、失败重试、减少动画、页面销毁后异步回调、后台返回不重播、数据先于慢提醒服务就绪、坏文件恢复、学校浏览器延迟创建和重用及导航策略。
+- 独立代码审阅启动调度、状态保留、通知点击及学校登录逻辑。审阅指出Android24–30还使用drawable-v21覆盖文件,已同步修复;复查未发现其他需修改问题。
+- 发布/调试APK构建并核对原证书。API36模拟器覆盖安装保留原课表,冷启动无需触碰自动进入首页;确认首页完成后返回桌面并再打开,检查过渡没有重播。
+- 实际录屏在dist/screenshots/tpschedule-061-startup-warm.mp4,采样帧在tpschedule-061-startup-frames.png。工具输出在本地.tools/tpschedule-061-*,不随源码分发。模拟器采用软件兼容渲染,不能代替vivo X200 Pro真机复测,也不能将系统首屏计时解释为首页全部渲染耗时。
+- 包名com.tpschedule.app、原签名、公私钥、0.5.4登记冻结及V1.0材料保持。反馈服务及雷池部署步骤不变。
+
+参考:[Flutter原生启动屏衔接](https://docs.flutter.dev/platform-integration/android/splash-screen)。
diff --git a/mobile/android/app/src/main/res/drawable-v21/launch_background.xml b/mobile/android/app/src/main/res/drawable-v21/launch_background.xml
index 6bd4fdb..79bdbbf 100644
--- a/mobile/android/app/src/main/res/drawable-v21/launch_background.xml
+++ b/mobile/android/app/src/main/res/drawable-v21/launch_background.xml
@@ -1,5 +1,5 @@
-
-
+
+
diff --git a/mobile/android/app/src/main/res/drawable/launch_background.xml b/mobile/android/app/src/main/res/drawable/launch_background.xml
index 6bd4fdb..79bdbbf 100644
--- a/mobile/android/app/src/main/res/drawable/launch_background.xml
+++ b/mobile/android/app/src/main/res/drawable/launch_background.xml
@@ -1,5 +1,5 @@
-
-
+
+
diff --git a/mobile/lib/app/app_controller.dart b/mobile/lib/app/app_controller.dart
index d17938f..d47ac89 100644
--- a/mobile/lib/app/app_controller.dart
+++ b/mobile/lib/app/app_controller.dart
@@ -57,7 +57,7 @@ class AppController extends ChangeNotifier {
lastError = null;
}
- Future initialize() => _run(() async {
+ Future initialize({bool reconcileServices = true}) => _run(() async {
try {
snapshot = await store.load() ?? AppSnapshot();
storageReady = true;
@@ -65,7 +65,7 @@ class AppController extends ChangeNotifier {
storageReady = false;
throw const FormatException('保存文件读取失败,可恢复最近备份');
}
- await _reconcile();
+ if (reconcileServices) await _reconcile();
});
Future restoreBackup() => _run(() async {
snapshot = await store.restoreBackup();
diff --git a/mobile/lib/app/app_version.dart b/mobile/lib/app/app_version.dart
index c238c39..f73a03a 100644
--- a/mobile/lib/app/app_version.dart
+++ b/mobile/lib/app/app_version.dart
@@ -1,2 +1,2 @@
-const appVersion = '0.6.0';
-const appBuildNumber = 15;
+const appVersion = '0.6.1';
+const appBuildNumber = 16;
diff --git a/mobile/lib/app/tp_launch_page.dart b/mobile/lib/app/tp_launch_page.dart
index a06c236..b855b89 100644
--- a/mobile/lib/app/tp_launch_page.dart
+++ b/mobile/lib/app/tp_launch_page.dart
@@ -3,43 +3,50 @@ import 'package:flutter/services.dart';
import '../widgets/tp_logo.dart';
-/// The first Flutter frame while saved data and reminder services initialize.
+/// Matching native artwork while saved data initializes.
class TpLaunchPage extends StatelessWidget {
- const TpLaunchPage({super.key});
+ const TpLaunchPage({super.key, this.nativeContinuity = false});
+ final bool nativeContinuity;
@override
- Widget build(BuildContext context) => AnnotatedRegion(
- value: const SystemUiOverlayStyle(
- statusBarColor: Colors.transparent,
- statusBarIconBrightness: Brightness.light,
- statusBarBrightness: Brightness.dark,
- systemNavigationBarColor: Color(0xFF38B3FF),
- systemNavigationBarIconBrightness: Brightness.light,
- ),
- child: Scaffold(
- backgroundColor: const Color(0xFF38B3FF),
- body: DecoratedBox(
- decoration: const BoxDecoration(gradient: tpBrandGradient),
- child: SizedBox.expand(
- child: Center(
- child: Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- const TpLogo(size: 120, background: false),
- const SizedBox(height: 20),
- Text(
- 'TPSchedule',
- style: Theme.of(context).textTheme.headlineSmall?.copyWith(
- color: Colors.white,
- fontWeight: FontWeight.w600,
- letterSpacing: 0.4,
+ Widget build(BuildContext context) => nativeContinuity
+ ? const ColoredBox(
+ color: Color(0xFF38B3FF),
+ child: Center(child: TpLogo(size: 188.8, background: false)),
+ )
+ : AnnotatedRegion(
+ value: const SystemUiOverlayStyle(
+ statusBarColor: Colors.transparent,
+ statusBarIconBrightness: Brightness.light,
+ statusBarBrightness: Brightness.dark,
+ systemNavigationBarColor: Color(0xFF38B3FF),
+ systemNavigationBarIconBrightness: Brightness.light,
+ ),
+ child: Scaffold(
+ backgroundColor: const Color(0xFF38B3FF),
+ body: DecoratedBox(
+ decoration: const BoxDecoration(gradient: tpBrandGradient),
+ child: SizedBox.expand(
+ child: Center(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const TpLogo(size: 120, background: false),
+ const SizedBox(height: 20),
+ Text(
+ 'TPSchedule',
+ style: Theme.of(context).textTheme.headlineSmall
+ ?.copyWith(
+ color: Colors.white,
+ fontWeight: FontWeight.w600,
+ letterSpacing: 0.4,
+ ),
+ ),
+ ],
),
),
- ],
+ ),
),
),
- ),
- ),
- ),
- );
+ );
}
diff --git a/mobile/lib/app/tp_startup.dart b/mobile/lib/app/tp_startup.dart
index 21af5b8..ae073f9 100644
--- a/mobile/lib/app/tp_startup.dart
+++ b/mobile/lib/app/tp_startup.dart
@@ -1,26 +1,187 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
-/// The native launch screen remains until runApp submits the initialized app.
-/// Use normal bootstrap instead of a second splash or deferred warm-up frames.
+import 'app_theme.dart';
+import 'tp_launch_page.dart';
+
+/// Paint a light first frame before asking plugins to read saved data.
Future launchApplication({
- required Future ready,
- required Widget child,
+ Future? ready,
+ Widget? child,
+ Future Function()? bootstrap,
+ VoidCallback? onHomeReady,
}) async {
+ assert(bootstrap != null || (ready != null && child != null));
+ // Attach error handling immediately, even if a caller already started work.
+ final legacy = bootstrap == null ? _legacyReady(ready!, child!) : null;
+ runApp(
+ TpStartup(bootstrap: bootstrap ?? () => legacy!, onHomeReady: onHomeReady),
+ );
+}
+
+Future _legacyReady(Future ready, Widget child) async {
try {
await ready;
} catch (_) {
- // AppController exposes initialization failures in its recovery UI.
- // Preserve access to that UI instead of leaving the native launch visible.
+ // AppController exposes failed storage reads through the recovery screen.
}
- runApp(
- AnnotatedRegion(
- value: SystemUiOverlayStyle.dark.copyWith(
- statusBarColor: Colors.transparent,
- systemNavigationBarColor: const Color(0xFFF3F7FF),
- systemNavigationBarIconBrightness: Brightness.dark,
+ return child;
+}
+
+class TpStartup extends StatefulWidget {
+ const TpStartup({super.key, required this.bootstrap, this.onHomeReady});
+ final Future Function() bootstrap;
+ final VoidCallback? onHomeReady;
+ @override
+ State createState() => _TpStartupState();
+}
+
+class _TpStartupState extends State
+ with SingleTickerProviderStateMixin {
+ late final _fade =
+ AnimationController(
+ vsync: this,
+ duration: const Duration(milliseconds: 300),
+ )..addStatusListener((status) {
+ if (status == AnimationStatus.completed && mounted) {
+ setState(() => _revealed = true);
+ }
+ });
+ Widget? _home;
+ bool _failed = false, _revealed = false;
+
+ @override
+ void initState() {
+ super.initState();
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (mounted) _bootstrap();
+ });
+ }
+
+ void _bootstrap() {
+ setState(() => _failed = false);
+ Future.sync(widget.bootstrap).then(
+ (home) {
+ if (!mounted) return;
+ setState(() => _home = home);
+ // Paint home behind an opaque brand frame before fading it out.
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (!mounted) return;
+ final reduced =
+ MediaQuery.maybeOf(context)?.disableAnimations ??
+ WidgetsBinding
+ .instance
+ .platformDispatcher
+ .accessibilityFeatures
+ .disableAnimations;
+ if (reduced) {
+ _fade.value = 1;
+ } else {
+ _fade.forward();
+ }
+ widget.onHomeReady?.call();
+ });
+ },
+ onError: (Object error, StackTrace stack) {
+ if (mounted) setState(() => _failed = true);
+ },
+ );
+ }
+
+ @override
+ void dispose() {
+ _fade.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ Widget content = Directionality(
+ textDirection: TextDirection.ltr,
+ child: Theme(
+ data: hepanTheme(),
+ child: AnimatedBuilder(
+ animation: _fade,
+ builder: (context, _) {
+ final progress = Curves.easeOutCubic.transform(_fade.value);
+ return AnnotatedRegion(
+ value:
+ (progress < 0.5
+ ? SystemUiOverlayStyle.light
+ : SystemUiOverlayStyle.dark)
+ .copyWith(
+ statusBarColor: Colors.transparent,
+ systemNavigationBarColor: Color.lerp(
+ const Color(0xFF38B3FF),
+ canvas,
+ progress,
+ ),
+ ),
+ child: ColoredBox(
+ color: const Color(0xFF38B3FF),
+ child: Stack(
+ fit: StackFit.expand,
+ children: [
+ if (_home != null)
+ ExcludeSemantics(
+ excluding: !_revealed,
+ child: ExcludeFocus(
+ excluding: !_revealed,
+ child: IgnorePointer(
+ ignoring: !_revealed,
+ child: Transform.translate(
+ offset: Offset(0, 6 * (1 - progress)),
+ child: _home,
+ ),
+ ),
+ ),
+ ),
+ if (!_revealed)
+ IgnorePointer(
+ ignoring: !_failed,
+ child: Opacity(
+ key: const Key('startup-brand-overlay'),
+ opacity: 1 - progress,
+ child: _failed
+ ? _recovery()
+ : const TpLaunchPage(nativeContinuity: true),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ },
+ ),
+ ),
+ );
+ if (MediaQuery.maybeOf(context) == null) {
+ content = MediaQuery.fromView(view: View.of(context), child: content);
+ }
+ return content;
+ }
+
+ Widget _recovery() => Material(
+ color: const Color(0xFF38B3FF),
+ child: Center(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const Text(
+ '启动未完成,请重试',
+ style: TextStyle(color: Colors.white, fontSize: 18),
+ ),
+ const SizedBox(height: 20),
+ FilledButton(
+ style: FilledButton.styleFrom(
+ backgroundColor: Colors.white,
+ foregroundColor: river,
+ ),
+ onPressed: _bootstrap,
+ child: const Text('重新打开'),
+ ),
+ ],
),
- child: child,
),
);
}
diff --git a/mobile/lib/features/school/school_session.dart b/mobile/lib/features/school/school_session.dart
index f319d79..63c959a 100644
--- a/mobile/lib/features/school/school_session.dart
+++ b/mobile/lib/features/school/school_session.dart
@@ -21,78 +21,79 @@ enum SchoolConnectionState {
}
class SchoolSession extends ChangeNotifier {
- SchoolSession() {
- web = WebViewController()
- ..setJavaScriptMode(JavaScriptMode.unrestricted)
- ..setNavigationDelegate(
- NavigationDelegate(
- onNavigationRequest: (request) async {
- final uri = Uri.tryParse(request.url);
- final upgrade = uri == null || !request.isMainFrame
- ? null
- : SchoolNavigationPolicy.secureIdentityRedirect(uri);
- if (upgrade != null) {
- final generation = _pageGeneration;
- try {
- await web.loadRequest(upgrade);
- } catch (_) {
- if (generation == _pageGeneration) {
- _failure('学校身份认证暂时无法打开,请重试。');
- }
- }
- return NavigationDecision.prevent;
- }
- if (uri == null ||
- !SchoolNavigationPolicy.canNavigate(
- uri,
- isMainFrame: request.isMainFrame,
- )) {
- if (kDebugMode && request.isMainFrame) {
- debugPrint(
- 'Blocked school navigation origin: ${uri?.scheme}://${uri?.host}',
- );
- }
- if (request.isMainFrame) _failure('学校跳转到了其他地址,请返回学校认证入口重试。');
- return NavigationDecision.prevent;
- }
- return NavigationDecision.navigate;
- },
- onPageStarted: (_) {
- _poll?.cancel();
- _manualRead = false;
- _pageGeneration++;
- _reader.invalidate();
- pendingDraft = null;
- pendingExams = null;
- if (!hasOpened || _routeClosed) return;
- _loading = true;
- notifyListeners();
- },
- onPageFinished: _loaded,
- onWebResourceError: (error) async {
- if (error.isForMainFrame != true || error.url == null) return;
+ SchoolSession();
+ // Creating Android WebView starts its browser engine. Do it on school access,
+ // not while the student is opening an offline timetable.
+ late final WebViewController web = _createWeb();
+ WebViewController _createWeb() => WebViewController()
+ ..setJavaScriptMode(JavaScriptMode.unrestricted)
+ ..setNavigationDelegate(
+ NavigationDelegate(
+ onNavigationRequest: (request) async {
+ final uri = Uri.tryParse(request.url);
+ final upgrade = uri == null || !request.isMainFrame
+ ? null
+ : SchoolNavigationPolicy.secureIdentityRedirect(uri);
+ if (upgrade != null) {
final generation = _pageGeneration;
- final current = await web.currentUrl();
- if (hasOpened &&
- generation == _pageGeneration &&
- error.url == current) {
- _failure('学校页面暂时未能加载,请检查网络后重试。');
+ try {
+ await web.loadRequest(upgrade);
+ } catch (_) {
+ if (generation == _pageGeneration) {
+ _failure('学校身份认证暂时无法打开,请重试。');
+ }
}
- },
- onHttpError: (error) async {
- final generation = _pageGeneration;
- final current = Uri.tryParse(await web.currentUrl() ?? '');
- if (hasOpened &&
- generation == _pageGeneration &&
- error.request != null &&
- error.request!.uri == current) {
- _failure('学校请求未完成,请稍后重试。');
+ return NavigationDecision.prevent;
+ }
+ if (uri == null ||
+ !SchoolNavigationPolicy.canNavigate(
+ uri,
+ isMainFrame: request.isMainFrame,
+ )) {
+ if (kDebugMode && request.isMainFrame) {
+ debugPrint(
+ 'Blocked school navigation origin: ${uri?.scheme}://${uri?.host}',
+ );
}
- },
- ),
- );
- }
- late final WebViewController web;
+ if (request.isMainFrame) _failure('学校跳转到了其他地址,请返回学校认证入口重试。');
+ return NavigationDecision.prevent;
+ }
+ return NavigationDecision.navigate;
+ },
+ onPageStarted: (_) {
+ _poll?.cancel();
+ _manualRead = false;
+ _pageGeneration++;
+ _reader.invalidate();
+ pendingDraft = null;
+ pendingExams = null;
+ if (!hasOpened || _routeClosed) return;
+ _loading = true;
+ notifyListeners();
+ },
+ onPageFinished: _loaded,
+ onWebResourceError: (error) async {
+ if (error.isForMainFrame != true || error.url == null) return;
+ final generation = _pageGeneration;
+ final current = await web.currentUrl();
+ if (hasOpened &&
+ generation == _pageGeneration &&
+ error.url == current) {
+ _failure('学校页面暂时未能加载,请检查网络后重试。');
+ }
+ },
+ onHttpError: (error) async {
+ final generation = _pageGeneration;
+ final current = Uri.tryParse(await web.currentUrl() ?? '');
+ if (hasOpened &&
+ generation == _pageGeneration &&
+ error.request != null &&
+ error.request!.uri == current) {
+ _failure('学校请求未完成,请稍后重试。');
+ }
+ },
+ ),
+ );
final _reader = SchoolReader(SchoolPageAdapter());
final _flow = SchoolLoginFlow();
Timer? _timeout;
diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart
index 90fba03..1ac8209 100644
--- a/mobile/lib/main.dart
+++ b/mobile/lib/main.dart
@@ -1,3 +1,5 @@
+import 'dart:async';
+
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import 'package:timezone/data/latest.dart' as tzdata;
@@ -25,21 +27,31 @@ Widget buildApplication(
);
Future main() async {
WidgetsFlutterBinding.ensureInitialized();
- tzdata.initializeTimeZones();
- final selection = ValueNotifier(null);
- final gateway = PluginReminderGateway(onTap: (key) => selection.value = key);
- final controller = AppController(
- FileSnapshotStore(await getApplicationSupportDirectory()),
- LocalNotificationService(gateway),
- AndroidCalendarService(MethodCalendarGateway()),
- DateTime.now,
- );
+ AppController? controller;
await launchApplication(
- ready: controller.initialize(),
- child: buildApplication(
- controller,
- notificationSelection: selection,
- school: SchoolSession(),
- ),
+ bootstrap: () async {
+ tzdata.initializeTimeZones();
+ final selection = ValueNotifier(null);
+ final gateway = PluginReminderGateway(
+ onTap: (key) => selection.value = key,
+ );
+ final value = AppController(
+ FileSnapshotStore(await getApplicationSupportDirectory()),
+ LocalNotificationService(gateway),
+ AndroidCalendarService(MethodCalendarGateway()),
+ DateTime.now,
+ );
+ await value.initialize(reconcileServices: false);
+ controller = value;
+ return buildApplication(
+ value,
+ notificationSelection: selection,
+ school: SchoolSession(),
+ );
+ },
+ onHomeReady: () {
+ final value = controller;
+ if (value != null) unawaited(value.refreshServices());
+ },
);
}
diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml
index a836a94..ea67128 100644
--- a/mobile/pubspec.yaml
+++ b/mobile/pubspec.yaml
@@ -1,7 +1,7 @@
name: hepan_everyday
description: TPSchedule 学生课表
publish_to: none
-version: 0.6.0+15
+version: 0.6.1+16
environment:
sdk: ^3.13.5
dependencies:
diff --git a/mobile/test/app/startup_data_test.dart b/mobile/test/app/startup_data_test.dart
new file mode 100644
index 0000000..001ce0e
--- /dev/null
+++ b/mobile/test/app/startup_data_test.dart
@@ -0,0 +1,74 @@
+import 'dart:async';
+
+import 'package:flutter_test/flutter_test.dart';
+import 'package:hepan_everyday/app/app_controller.dart';
+import 'package:hepan_everyday/domain/app_settings.dart';
+import 'package:hepan_everyday/domain/app_snapshot.dart';
+import 'package:hepan_everyday/domain/course_occurrence.dart';
+import 'package:hepan_everyday/services/notification_service.dart';
+
+import '../support/fakes.dart';
+
+class WaitingNotifications extends FakeNotifications {
+ final pending = Completer();
+ @override
+ Future reconcile(
+ List events,
+ AppSettings settings,
+ DateTime now,
+ ) {
+ reconciliations++;
+ return pending.future;
+ }
+}
+
+void main() {
+ test(
+ 'data-only startup finishes before slow native reminder work starts',
+ () async {
+ final notifications = WaitingNotifications();
+ final calendar = FakeCalendar();
+ final snapshot = AppSnapshot();
+ final controller = AppController(
+ MemoryStore(snapshot),
+ notifications,
+ calendar,
+ DateTime.now,
+ );
+ await controller.initialize(reconcileServices: false);
+ expect(controller.snapshot, same(snapshot));
+ expect(notifications.reconciliations, 0);
+ expect(calendar.synchronizations, 0);
+ final background = controller.refreshServices();
+ await Future.delayed(Duration.zero);
+ expect(notifications.reconciliations, 1);
+ notifications.pending.complete(const ReminderResult(ReminderStatus.off));
+ await background;
+ controller.dispose();
+ },
+ );
+
+ test(
+ 'failed saved-data read still exposes recovery without starting services',
+ () async {
+ final store = BrokenStore();
+ final notifications = FakeNotifications();
+ final controller = AppController(
+ store,
+ notifications,
+ FakeCalendar(),
+ DateTime.now,
+ );
+ await controller.initialize(reconcileServices: false);
+ expect(controller.storageReady, isFalse);
+ expect(controller.lastError, contains('保存文件读取失败'));
+ expect(notifications.reconciliations, 0);
+ controller.dispose();
+ },
+ );
+}
+
+class BrokenStore extends MemoryStore {
+ @override
+ Future load() => Future.error(const FormatException('invalid'));
+}
diff --git a/mobile/test/school/lazy_school_session_test.dart b/mobile/test/school/lazy_school_session_test.dart
new file mode 100644
index 0000000..2c2d343
--- /dev/null
+++ b/mobile/test/school/lazy_school_session_test.dart
@@ -0,0 +1,58 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:webview_flutter_platform_interface/webview_flutter_platform_interface.dart';
+import 'package:hepan_everyday/features/school/school_session.dart';
+import 'package:hepan_everyday/domain/school_term.dart';
+
+import '../support/fake_school_web.dart';
+
+class CountingSchoolPlatform extends FakeSchoolPlatform {
+ int creations = 0;
+ @override
+ PlatformWebViewController createPlatformWebViewController(
+ PlatformWebViewControllerCreationParams params,
+ ) {
+ creations++;
+ return super.createPlatformWebViewController(params);
+ }
+}
+
+void main() {
+ TestWidgetsFlutterBinding.ensureInitialized();
+ test('local timetable startup does not construct the school browser', () {
+ final platform = CountingSchoolPlatform();
+ WebViewPlatform.instance = platform;
+ final session = SchoolSession();
+ try {
+ expect(platform.creations, 0);
+ expect(session.connectionState, SchoolConnectionState.signedOut);
+ expect(session.hasOpened, isFalse);
+ } finally {
+ session.dispose();
+ }
+ });
+ test(
+ 'opening school creates one reusable browser with navigation policy',
+ () async {
+ final platform = CountingSchoolPlatform();
+ WebViewPlatform.instance = platform;
+ final session = SchoolSession();
+ try {
+ await session.openTimetable(term: SchoolTerm(73));
+ expect(platform.creations, 1);
+ final controller = session.web;
+ expect(session.web, same(controller));
+ expect(platform.creations, 1);
+ expect(platform.web.loads, isNotEmpty);
+ final decision = await platform.delegate.navigation(
+ NavigationRequest(
+ url: 'https://unrelated.example/',
+ isMainFrame: true,
+ ),
+ );
+ expect(decision, NavigationDecision.prevent);
+ } finally {
+ session.dispose();
+ }
+ },
+ );
+}
diff --git a/mobile/test/school/term_selection_test.dart b/mobile/test/school/term_selection_test.dart
index 7b12a21..e95e5ce 100644
--- a/mobile/test/school/term_selection_test.dart
+++ b/mobile/test/school/term_selection_test.dart
@@ -66,9 +66,12 @@ void main() {
WebViewPlatform.instance = platform;
final session = SchoolSession();
addTearDown(session.dispose);
+ // A previously authenticated session has already opened its browser.
+ final connectedBrowser = session.web;
session.connectionState = SchoolConnectionState.connected;
await session.openTimetable(term: SchoolTerm(72), chooseTerm: true);
expect(session.needsTermSelection, isTrue);
+ expect(session.web, same(connectedBrowser));
expect(platform.web.loads, isEmpty);
expect(session.selectedTerm, SchoolTerm(72));
expect(session.pendingDraft, isNull);
diff --git a/mobile/test/widgets/startup_slow_test.dart b/mobile/test/widgets/startup_slow_test.dart
new file mode 100644
index 0000000..233e6fe
--- /dev/null
+++ b/mobile/test/widgets/startup_slow_test.dart
@@ -0,0 +1,31 @@
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:hepan_everyday/app/tp_startup.dart';
+import 'package:hepan_everyday/app/tp_launch_page.dart';
+
+void main() {
+ testWidgets(
+ 'slow initialization paints branded frames instead of waiting without a Flutter UI',
+ (tester) async {
+ final ready = Completer();
+ final startup = launchApplication(
+ ready: ready.future,
+ child: const MaterialApp(home: Text('已读取课表')),
+ );
+ try {
+ await tester.pump();
+ await tester.pump(const Duration(seconds: 3));
+ expect(find.byType(TpLaunchPage), findsOneWidget);
+ expect(find.text('已读取课表'), findsNothing);
+ expect(tester.binding.sendFramesToEngine, isTrue);
+ } finally {
+ ready.complete();
+ await startup;
+ await tester.pumpAndSettle();
+ await tester.pumpWidget(const SizedBox());
+ }
+ },
+ );
+}
diff --git a/mobile/test/widgets/startup_transition_test.dart b/mobile/test/widgets/startup_transition_test.dart
new file mode 100644
index 0000000..f587ce8
--- /dev/null
+++ b/mobile/test/widgets/startup_transition_test.dart
@@ -0,0 +1,119 @@
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:hepan_everyday/app/tp_startup.dart';
+import 'package:hepan_everyday/app/tp_launch_page.dart';
+
+void main() {
+ testWidgets(
+ 'home paints before gradual reveal and cannot be tapped until complete',
+ (tester) async {
+ final ready = Completer();
+ var serviceStarts = 0, taps = 0, boots = 0;
+ await tester.pumpWidget(
+ TpStartup(
+ bootstrap: () {
+ boots++;
+ expect(find.byType(TpLaunchPage), findsOneWidget);
+ return ready.future;
+ },
+ onHomeReady: () {
+ serviceStarts++;
+ expect(find.text('课程详情'), findsOneWidget);
+ },
+ ),
+ );
+ ready.complete(
+ MaterialApp(
+ home: Center(
+ child: TextButton(
+ onPressed: () => taps++,
+ child: const Text('课程详情'),
+ ),
+ ),
+ ),
+ );
+ await tester.pump();
+ await tester.pump();
+ expect(serviceStarts, 1);
+ await tester.pump(const Duration(milliseconds: 100));
+ final overlay = tester.widget(
+ find.byKey(const Key('startup-brand-overlay')),
+ );
+ expect(overlay.opacity, inExclusiveRange(0, 1));
+ await tester.tap(find.text('课程详情'), warnIfMissed: false);
+ expect(taps, 0);
+ await tester.pumpAndSettle();
+ expect(find.byType(TpLaunchPage), findsNothing);
+ await tester.tap(find.text('课程详情'));
+ expect(taps, 1);
+ tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
+ tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
+ await tester.pumpAndSettle();
+ expect(boots, 1);
+ expect(serviceStarts, 1);
+ expect(find.byType(TpLaunchPage), findsNothing);
+ },
+ );
+
+ testWidgets(
+ 'bootstrap failure presents a working retry instead of a blank screen',
+ (tester) async {
+ var attempts = 0;
+ await tester.pumpWidget(
+ TpStartup(
+ bootstrap: () async {
+ if (++attempts == 1) throw StateError('plugin unavailable');
+ return const MaterialApp(home: Text('已恢复启动'));
+ },
+ ),
+ );
+ await tester.pumpAndSettle();
+ expect(find.text('启动未完成,请重试'), findsOneWidget);
+ expect(tester.takeException(), isNull);
+ await tester.tap(find.text('重新打开'));
+ await tester.pumpAndSettle();
+ expect(find.text('已恢复启动'), findsOneWidget);
+ expect(attempts, 2);
+ expect(tester.takeException(), isNull);
+ },
+ );
+
+ testWidgets('reduced motion reveals home without a timed fade', (
+ tester,
+ ) async {
+ await tester.pumpWidget(
+ MediaQuery(
+ data: const MediaQueryData(disableAnimations: true),
+ child: TpStartup(
+ bootstrap: () async => const MaterialApp(home: Text('首页')),
+ ),
+ ),
+ );
+ await tester.pump();
+ await tester.pump();
+ expect(find.text('首页'), findsOneWidget);
+ expect(find.byType(TpLaunchPage), findsNothing);
+ });
+
+ testWidgets(
+ 'late bootstrap completion after disposal does not start services',
+ (tester) async {
+ final ready = Completer();
+ var serviceStarts = 0;
+ await tester.pumpWidget(
+ TpStartup(
+ bootstrap: () => ready.future,
+ onHomeReady: () => serviceStarts++,
+ ),
+ );
+ await tester.pumpWidget(const SizedBox());
+ ready.complete(const MaterialApp(home: Text('旧首页')));
+ await tester.pumpAndSettle();
+ expect(serviceStarts, 0);
+ expect(find.text('旧首页'), findsNothing);
+ expect(tester.takeException(), isNull);
+ },
+ );
+}
diff --git a/mobile/test/widgets/tp_startup_test.dart b/mobile/test/widgets/tp_startup_test.dart
index b1da47c..1f8e574 100644
--- a/mobile/test/widgets/tp_startup_test.dart
+++ b/mobile/test/widgets/tp_startup_test.dart
@@ -3,9 +3,10 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hepan_everyday/app/tp_startup.dart';
+import 'package:hepan_everyday/app/tp_launch_page.dart';
void main() {
- testWidgets('waiting initialization does not replace the native launch', (
+ testWidgets('waiting initialization keeps painted brand frames', (
tester,
) async {
await tester.pumpWidget(const SizedBox());
@@ -16,13 +17,14 @@ void main() {
child: const MaterialApp(home: Text('已准备好课表')),
).then((_) => startupFinished = true);
await tester.pump();
- expect(startupFinished, isFalse);
+ expect(startupFinished, isTrue);
+ expect(find.byType(TpLaunchPage), findsOneWidget);
expect(tester.binding.sendFramesToEngine, isTrue);
expect(find.text('TPSchedule'), findsNothing);
expect(find.text('已准备好课表'), findsNothing);
ready.complete();
await startup;
- await tester.pump();
+ await tester.pumpAndSettle();
expect(find.text('已准备好课表'), findsOneWidget);
expect(find.text('TPSchedule'), findsNothing);
expect(tester.binding.sendFramesToEngine, isTrue);
@@ -37,7 +39,7 @@ void main() {
ready: ready,
child: const MaterialApp(home: Text('保存的课表')),
);
- await tester.pump();
+ await tester.pumpAndSettle();
expect(find.text('保存的课表'), findsOneWidget);
expect(find.text('TPSchedule'), findsNothing);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
@@ -57,7 +59,7 @@ void main() {
);
ready.completeError(StateError('storage failed'));
await startup;
- await tester.pump();
+ await tester.pumpAndSettle();
expect(find.text('恢复保存文件'), findsOneWidget);
expect(tester.binding.sendFramesToEngine, isTrue);
expect(tester.takeException(), isNull);