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); }, ); }