Fix duplicate launch screen and native splash exit flashing

This commit is contained in:
Codex
2026-10-13 22:52:25 +08:00
parent 204e945b2e
commit 57eb2c0505
10 changed files with 128 additions and 79 deletions
@@ -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)
@@ -8,10 +8,10 @@
<item name="android:windowLightNavigationBar">false</item>
</style>
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@color/tpschedule_blue</item>
<item name="android:statusBarColor">@color/tpschedule_blue</item>
<item name="android:navigationBarColor">@color/tpschedule_blue</item>
<item name="android:windowLightStatusBar">false</item>
<item name="android:windowLightNavigationBar">false</item>
<item name="android:windowBackground">@color/tpschedule_surface</item>
<item name="android:statusBarColor">@color/tpschedule_surface</item>
<item name="android:navigationBarColor">@color/tpschedule_surface</item>
<item name="android:windowLightStatusBar">true</item>
<item name="android:windowLightNavigationBar">true</item>
</style>
</resources>
@@ -8,10 +8,10 @@
<item name="android:windowLightNavigationBar">false</item>
</style>
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@color/tpschedule_blue</item>
<item name="android:statusBarColor">@color/tpschedule_blue</item>
<item name="android:navigationBarColor">@color/tpschedule_blue</item>
<item name="android:windowLightStatusBar">false</item>
<item name="android:windowLightNavigationBar">false</item>
<item name="android:windowBackground">@color/tpschedule_surface</item>
<item name="android:statusBarColor">@color/tpschedule_surface</item>
<item name="android:navigationBarColor">@color/tpschedule_surface</item>
<item name="android:windowLightStatusBar">true</item>
<item name="android:windowLightNavigationBar">true</item>
</style>
</resources>
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="tpschedule_surface">#F3F7FF</color>
<color name="tpschedule_blue">#38B3FF</color>
</resources>
+21 -43
View File
@@ -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<void> ready;
final Widget child;
@override
Widget build(BuildContext context) => FutureBuilder<void>(
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<SystemUiOverlayStyle>(
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<void> launchApplication({
required Future<void> 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<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.dark.copyWith(
statusBarColor: Colors.transparent,
systemNavigationBarColor: const Color(0xFFF3F7FF),
systemNavigationBarIconBrightness: Brightness.dark,
),
child: child,
),
);
}
+6 -8
View File
@@ -34,14 +34,12 @@ Future<void> 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(),
),
);
}
+1 -1
View File
@@ -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:
+51 -14
View File
@@ -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<void>();
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<void>.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<void>();
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);
},
);
}