diff --git a/README.md b/README.md
index 08d640b..8df317f 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,8 @@
# TPSchedule
-学生课表 APP,Flutter 实现,目前接入电子科技大学研究生系统。Android 0.5.0+8,Android 7.0 及以上。
+学生课表 APP,Flutter 实现,目前接入电子科技大学研究生系统。Android 0.5.1+9,Android 7.0 及以上。
-使用用户提供的原始 SVG 图标,蓝色渐变、黄色日历框及白色笔画保持一致;启动画面使用相同配色,初始化完成后直接进入课表,无额外等待。Android12+ 原生启动屏使用同色纯蓝背景及图标,Flutter 接续显示蓝色渐变。
+使用用户提供的原始 SVG 图标,蓝色渐变、黄色日历框及白色笔画保持一致;启动画面使用相同配色。0.5.1修复重复启动页闪烁:初始化期间保留原生启动屏,数据就绪后直接显示课表首帧,不再叠加Flutter品牌页或淡出动画。Android12+ 原生启动屏使用同色纯蓝背景及图标,旧版采用蓝色渐变。
三个图标标签页:课表、考试、个人设置。界面统一蓝色,三个页面标题对齐。透明蓝色液态玻璃风格悬浮 Dock 缩窄至264,选中滑块为灰色玻璃,图标为实心蓝色,静止边距为3。支持点击图标或拖动选中滑块切换;按压和拖动时放大、松手弹簧回弹,支持的渲染模式启用实时背景折射。页面切换采用短距离移动与淡入淡出,并保留每页滚动位置。页面本身不支持左右滑动切换。课表和考试页面向下滚动时 Dock 用360ms过渡滑出屏幕底部,向上滚动恢复;个人设置页面保持显示。系统“减少动画”开启时减少切页与回弹动画。
@@ -51,4 +51,4 @@ flutter test
flutter analyze
```
-仓库根目录运行 `./scripts/build-android.ps1` 构建。验证记录:[0.3.2蓝色图标修正](docs/rschedule-0.3.2-validation.md)、[0.3.1自动读取与玻璃动画修正](docs/rschedule-0.3.1-validation.md)、[0.3.0登录、考试和动画](docs/rschedule-0.3-validation.md)。Windows模拟器可仅在测试启动时使用软件渲染并关闭Impeller;交付APK保留手机默认渲染配置。历史验证:[0.2.1](docs/blue-glass-and-course-length.md)、[0.2.0](docs/riverside-revision-validation.md)、[首版](docs/android-mvp-validation.md)。
+仓库根目录运行 `./scripts/build-android.ps1` 构建。验证记录:[0.5.1启动闪烁修复](docs/tpschedule-0.5.1-validation.md)、[0.3.2蓝色图标修正](docs/rschedule-0.3.2-validation.md)、[0.3.1自动读取与玻璃动画修正](docs/rschedule-0.3.1-validation.md)、[0.3.0登录、考试和动画](docs/rschedule-0.3-validation.md)。Windows模拟器可仅在测试启动时使用软件渲染并关闭Impeller;交付APK保留手机默认渲染配置。历史验证:[0.2.1](docs/blue-glass-and-course-length.md)、[0.2.0](docs/riverside-revision-validation.md)、[首版](docs/android-mvp-validation.md)。
diff --git a/docs/tpschedule-0.5.1-validation.md b/docs/tpschedule-0.5.1-validation.md
new file mode 100644
index 0000000..3c4ebe0
--- /dev/null
+++ b/docs/tpschedule-0.5.1-validation.md
@@ -0,0 +1,25 @@
+# TPSchedule Android 0.5.1+9:启动闪烁修复
+
+日期:2026-10-13。
+
+0.5.0先显示Android原生启动屏,再通过FutureBuilder构建Flutter品牌页,最后AnimatedSwitcher淡出220ms。Future即使已完成,首次构建仍会进入等待分支;重复快速启动时,第二层品牌页容易表现为短暂闪屏。原实现的回归测试已复现这两个分支。
+
+0.5.1恢复此前0.4.0的正常启动方式:先完成controller.initialize,再一次性runApp。原生启动屏保持到首个真正的应用帧,不再额外构建Flutter品牌页或淡出。后台返回同一实例不重新初始化;初始化失败仍可进入原有恢复界面。
+
+连续三次冷启动后,录屏还复现后台返回时的无标志亮蓝色预览。已将NormalTheme窗口、状态栏和导航栏的底色改为与课表canvas一致的#F3F7FF,并使用深色系统图标;浅深资源均同步。仅改窗口底色仍不足以控制系统预览;日志确认还有Android SplashScreenView的退场动画。因此API31及以上使用官方setOnExitAnimationListener,在实际应用首帧完成后立即remove系统启动视图,去掉多余退场动画。LaunchTheme及Android12+系统启动屏保持原蓝色和标志,较旧安卓不调用新API。
+
+验证期间曾尝试首帧延后/释放方案,但软件渲染模拟器实测需要额外触碰才进入应用;普通widget测试会主动pump且替换帧调度,不能代表原生表现。最终放弃此方案,沿用正常runApp引导所安排的真实引擎帧,不依赖warm-up帧呈现或额外用户操作。
+
+自动测试覆盖等待真实初始化完成后启动、缓存数据首次绘制、后台返回、失败后的恢复界面。最终完整Flutter测试141项通过,使用--enable-impeller;静态分析无问题。
+
+参考:[Android启动屏衔接](https://docs.flutter.dev/platform-integration/android/splash-screen)、[Flutter首帧延后API](https://api.flutter.dev/flutter/widgets/WidgetsBinding/deferFirstFrame.html)。已核对Flutter本地renderer、scheduler及Android embedding源代码。
+
+应用ID仍为com.tpschedule.app,签名配置不变;图标、原生启动配色和通透Dock不改。0.5.0记录保留为历史验证。
+
+## 原生检查
+
+API36模拟器覆盖安装并进入保存的课表,无需触碰屏幕。最终发布包的冷启动和后台返回录屏位于dist/screenshots/tpschedule-startup-fixed.mp4。普通启动不再叠加Flutter品牌页;后台返回的系统预览由约0.3–0.4秒退场缩短为录屏中的约0.1秒,系统进入动画本身仍由Android控制,不能宣称每台手机完全没有任何系统预览帧。该检查使用软件渲染,具体手机效果还需实机确认。
+
+发布包和调试包均构建成功并通过原证书指纹检查;新旧证书及公钥完全一致。源码审查确认初始化失败后的恢复界面与较旧安卓API兼容性保留。
+
+发布APK SHA256:D8F4C15E9D3EF02AEF98DDB2BD032E2DF4B7A8166F79C34D524CCC7C08330D29。
diff --git a/mobile/android/app/src/main/kotlin/com/tpschedule/app/MainActivity.kt b/mobile/android/app/src/main/kotlin/com/tpschedule/app/MainActivity.kt
index 6a76a2f..0924c30 100644
--- a/mobile/android/app/src/main/kotlin/com/tpschedule/app/MainActivity.kt
+++ b/mobile/android/app/src/main/kotlin/com/tpschedule/app/MainActivity.kt
@@ -1,10 +1,20 @@
package com.tpschedule.app
+import android.os.Build
+import android.os.Bundle
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
class MainActivity : FlutterActivity() {
private var calendar: CalendarBridge? = null
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ // Once Flutter has drawn, remove the system preview immediately.
+ // Its default blue exit fade can flash over an already ready app.
+ splashScreen.setOnExitAnimationListener { view -> view.remove() }
+ }
+ }
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
calendar = CalendarBridge(this, flutterEngine.dartExecutor.binaryMessenger)
diff --git a/mobile/android/app/src/main/res/values-night/styles.xml b/mobile/android/app/src/main/res/values-night/styles.xml
index 745eea2..efc5a8b 100644
--- a/mobile/android/app/src/main/res/values-night/styles.xml
+++ b/mobile/android/app/src/main/res/values-night/styles.xml
@@ -8,10 +8,10 @@
- false
diff --git a/mobile/android/app/src/main/res/values/styles.xml b/mobile/android/app/src/main/res/values/styles.xml
index 745eea2..efc5a8b 100644
--- a/mobile/android/app/src/main/res/values/styles.xml
+++ b/mobile/android/app/src/main/res/values/styles.xml
@@ -8,10 +8,10 @@
- false
diff --git a/mobile/android/app/src/main/res/values/tpschedule_colors.xml b/mobile/android/app/src/main/res/values/tpschedule_colors.xml
index 8d838ed..c6f6f88 100644
--- a/mobile/android/app/src/main/res/values/tpschedule_colors.xml
+++ b/mobile/android/app/src/main/res/values/tpschedule_colors.xml
@@ -1,4 +1,5 @@
+ #F3F7FF
#38B3FF
diff --git a/mobile/lib/app/tp_startup.dart b/mobile/lib/app/tp_startup.dart
index 3f62f27..21af5b8 100644
--- a/mobile/lib/app/tp_startup.dart
+++ b/mobile/lib/app/tp_startup.dart
@@ -1,48 +1,26 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
-import 'tp_launch_page.dart';
-
-/// Displays the brand during real initialization, without a minimum wait.
-class TpStartup extends StatelessWidget {
- const TpStartup({super.key, required this.ready, required this.child});
- final Future ready;
- final Widget child;
-
- @override
- Widget build(BuildContext context) => FutureBuilder(
- future: ready,
- builder: (context, snapshot) {
- final complete = snapshot.connectionState == ConnectionState.done;
- final reduceMotion =
- MediaQuery.maybeOf(context)?.disableAnimations ??
- WidgetsBinding
- .instance
- .platformDispatcher
- .accessibilityFeatures
- .disableAnimations;
- return AnimatedSwitcher(
- duration: reduceMotion
- ? Duration.zero
- : const Duration(milliseconds: 220),
- switchInCurve: Curves.easeOut,
- switchOutCurve: Curves.easeIn,
- child: complete
- ? AnnotatedRegion(
- key: const ValueKey('tp-ready'),
- value: SystemUiOverlayStyle.dark.copyWith(
- statusBarColor: Colors.transparent,
- systemNavigationBarColor: const Color(0xFFF3F7FF),
- systemNavigationBarIconBrightness: Brightness.dark,
- ),
- child: child,
- )
- : const MaterialApp(
- key: ValueKey('tp-launch'),
- debugShowCheckedModeBanner: false,
- home: TpLaunchPage(),
- ),
- );
- },
+/// The native launch screen remains until runApp submits the initialized app.
+/// Use normal bootstrap instead of a second splash or deferred warm-up frames.
+Future launchApplication({
+ required Future ready,
+ required 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.
+ }
+ runApp(
+ AnnotatedRegion(
+ value: SystemUiOverlayStyle.dark.copyWith(
+ statusBarColor: Colors.transparent,
+ systemNavigationBarColor: const Color(0xFFF3F7FF),
+ systemNavigationBarIconBrightness: Brightness.dark,
+ ),
+ child: child,
+ ),
);
}
diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart
index 325a87c..90fba03 100644
--- a/mobile/lib/main.dart
+++ b/mobile/lib/main.dart
@@ -34,14 +34,12 @@ Future main() async {
AndroidCalendarService(MethodCalendarGateway()),
DateTime.now,
);
- runApp(
- TpStartup(
- ready: controller.initialize(),
- child: buildApplication(
- controller,
- notificationSelection: selection,
- school: SchoolSession(),
- ),
+ await launchApplication(
+ ready: controller.initialize(),
+ child: buildApplication(
+ controller,
+ notificationSelection: selection,
+ school: SchoolSession(),
),
);
}
diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml
index a01fccf..5428b72 100644
--- a/mobile/pubspec.yaml
+++ b/mobile/pubspec.yaml
@@ -1,7 +1,7 @@
name: hepan_everyday
description: TPSchedule 学生课表
publish_to: none
-version: 0.5.0+8
+version: 0.5.1+9
environment:
sdk: ^3.13.5
dependencies:
diff --git a/mobile/test/widgets/tp_startup_test.dart b/mobile/test/widgets/tp_startup_test.dart
index e434571..b1da47c 100644
--- a/mobile/test/widgets/tp_startup_test.dart
+++ b/mobile/test/widgets/tp_startup_test.dart
@@ -5,25 +5,62 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:hepan_everyday/app/tp_startup.dart';
void main() {
+ testWidgets('waiting initialization does not replace the native launch', (
+ tester,
+ ) async {
+ await tester.pumpWidget(const SizedBox());
+ final ready = Completer();
+ var startupFinished = false;
+ final startup = launchApplication(
+ ready: ready.future,
+ child: const MaterialApp(home: Text('已准备好课表')),
+ ).then((_) => startupFinished = true);
+ await tester.pump();
+ expect(startupFinished, isFalse);
+ expect(tester.binding.sendFramesToEngine, isTrue);
+ expect(find.text('TPSchedule'), findsNothing);
+ expect(find.text('已准备好课表'), findsNothing);
+ ready.complete();
+ await startup;
+ await tester.pump();
+ expect(find.text('已准备好课表'), findsOneWidget);
+ expect(find.text('TPSchedule'), findsNothing);
+ expect(tester.binding.sendFramesToEngine, isTrue);
+ });
+
+ testWidgets('cached startup and resume never insert an extra launch frame', (
+ tester,
+ ) async {
+ final ready = Future.value();
+ await ready;
+ await launchApplication(
+ ready: ready,
+ child: const MaterialApp(home: Text('保存的课表')),
+ );
+ await tester.pump();
+ expect(find.text('保存的课表'), findsOneWidget);
+ expect(find.text('TPSchedule'), findsNothing);
+ tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
+ tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
+ await tester.pump();
+ expect(find.text('保存的课表'), findsOneWidget);
+ expect(find.text('TPSchedule'), findsNothing);
+ });
+
testWidgets(
- 'startup shows brand while initializing and immediately proceeds when ready',
+ 'initialization errors still show the recovery UI on first frame',
(tester) async {
final ready = Completer();
- await tester.pumpWidget(
- TpStartup(
- ready: ready.future,
- child: const MaterialApp(home: Scaffold(body: Text('已准备好课表'))),
- ),
+ final startup = launchApplication(
+ ready: ready.future,
+ child: const MaterialApp(home: Text('恢复保存文件')),
);
+ ready.completeError(StateError('storage failed'));
+ await startup;
await tester.pump();
- expect(find.text('TPSchedule'), findsOneWidget);
- expect(find.text('已准备好课表'), findsNothing);
- ready.complete();
- await tester.pump();
- await tester.pump();
- expect(find.text('已准备好课表'), findsOneWidget);
- await tester.pumpAndSettle();
- expect(find.text('TPSchedule'), findsNothing);
+ expect(find.text('恢复保存文件'), findsOneWidget);
+ expect(tester.binding.sendFramesToEngine, isTrue);
+ expect(tester.takeException(), isNull);
},
);
}