fix(startup): keep the platform launch screen behind the loading frame
Since 2.10.0 the app opens on a Flutter-owned startup frame, and that frame paints an opaque themed Scaffold before any preference is readable. Its themeMode defaults to system, so the theme comes from platform brightness -- and a TV has no system dark-mode toggle, so Fire TV and Shield report light. The result was a near-white #F7F7F8 sheet held for the whole gate, from prefs through Sentry to the database open, over an Android window the television resource qualifier had already painted black. Before 2.10.0 the gate ran ahead of runApp and no Flutter frame existed to cover it. Nothing in the loading frame is worth covering the launch screen for. Android composites Flutter in TransparencyMode.transparent over a window whose colour MainActivity already restored from plezy_prefs, so the loading Scaffold is transparent there and the launch screen carries the launch. Every other platform composites opaquely with nothing behind Flutter, so they keep painting their own background. The spinner and the failure screen still need a colour, and platform brightness is the wrong one for exactly the devices this bug is about, so the startup frames now adopt the persisted theme once it can be read. TV detection has to run before that read: the theme_mode default is TV-aware and isTVSync answers false until its singleton exists, which would resolve a fresh Android TV install to the light theme. Both singletons are memoised and awaited again by the gate. The read is best-effort -- an unreadable store is the gate's failure to report, not this path's -- and it also stops a startup failure from rendering as a full-screen white error page on a TV. darkThemeFor and materialThemeModeFor move onto ThemeProvider so the startup frames and the provider resolve OLED from one mapping rather than two. Verified on an Android TV emulator in television/notnight mode, clean install, cold start: peak frame luma 228 for 78 frames before, 0 frames above 120 after, and the same on a returning launch. close #1833
This commit is contained in:
+76
-5
@@ -166,10 +166,30 @@ void _bootstrapApp() {
|
||||
onCommitted: (dependencies) => _startNonessentialInitialization(dependencies.settings),
|
||||
lightTheme: monoTheme(dark: false),
|
||||
darkTheme: monoTheme(dark: true),
|
||||
resolveTheme: _resolveStartupTheme,
|
||||
// Android runs the Flutter surface in transparent mode over a window
|
||||
// whose background MainActivity already restored, so the loading frame
|
||||
// can leave the launch screen on display. Every other platform composites
|
||||
// opaquely and has nothing behind Flutter worth showing.
|
||||
transparentWhileLoading: Platform.isAndroid,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Resolves the theme the app will settle on, for the frames that precede it.
|
||||
///
|
||||
/// Both singletons are memoised and awaited again by the gate, so this costs
|
||||
/// one preference load and one platform-channel round trip, shared. TV
|
||||
/// detection has to come first: the `themeMode` default is TV-aware and
|
||||
/// [TvDetectionService.isTVSync] answers false until its singleton exists,
|
||||
/// which would make a fresh Android TV install resolve the light theme.
|
||||
Future<StartupThemeResolution> _resolveStartupTheme() async {
|
||||
final settings = await SettingsService.getInstance();
|
||||
await TvDetectionService.getInstance(forceTv: settings.read(SettingsService.forceTvMode));
|
||||
final mode = settings.read(SettingsService.themeMode);
|
||||
return (themeMode: ThemeProvider.materialThemeModeFor(mode), darkTheme: ThemeProvider.darkThemeFor(mode));
|
||||
}
|
||||
|
||||
/// Wraps [step] so a failure names the gate phase it came from.
|
||||
///
|
||||
/// `Future.wait` keeps only the first error and discards the rest, and the old
|
||||
@@ -562,6 +582,9 @@ Future<void> showRepairOutcomeDialog(
|
||||
);
|
||||
}
|
||||
|
||||
/// Theme the startup frames adopt once the persisted preference is readable.
|
||||
typedef StartupThemeResolution = ({material.ThemeMode themeMode, ThemeData darkTheme});
|
||||
|
||||
/// Mounts a Flutter-owned startup frame before invoking the asynchronous
|
||||
/// initialization gate. The generic seam keeps frame ordering, failure, and
|
||||
/// retry behavior testable without constructing platform services.
|
||||
@@ -578,6 +601,8 @@ class StartupBootstrap<T> extends StatefulWidget {
|
||||
this.lightTheme,
|
||||
this.darkTheme,
|
||||
this.themeMode = material.ThemeMode.system,
|
||||
this.resolveTheme,
|
||||
this.transparentWhileLoading = false,
|
||||
});
|
||||
|
||||
final Future<T> Function() initialize;
|
||||
@@ -599,6 +624,20 @@ class StartupBootstrap<T> extends StatefulWidget {
|
||||
final ThemeData? darkTheme;
|
||||
final material.ThemeMode themeMode;
|
||||
|
||||
/// Reads the persisted theme so the startup frames match the one the app
|
||||
/// settles on. Until it answers, [themeMode] resolves from platform
|
||||
/// brightness, which disagrees with the app's own default on every device
|
||||
/// that reports light while running the dark or OLED theme (#1833).
|
||||
final Future<StartupThemeResolution> Function()? resolveTheme;
|
||||
|
||||
/// Whether the platform window behind Flutter already paints the launch
|
||||
/// background, so the loading frame must not cover it.
|
||||
///
|
||||
/// True only on Android, where `TransparencyMode.transparent` lets the
|
||||
/// window decor show through and `MainActivity` has already restored the
|
||||
/// persisted launch colour.
|
||||
final bool transparentWhileLoading;
|
||||
|
||||
@override
|
||||
State<StartupBootstrap<T>> createState() => _StartupBootstrapState<T>();
|
||||
}
|
||||
@@ -620,15 +659,37 @@ class _StartupBootstrapState<T> extends State<StartupBootstrap<T>> {
|
||||
bool _restartRequired = false;
|
||||
int _generation = 0;
|
||||
|
||||
/// Persisted theme for the startup frames, null until [StartupBootstrap.resolveTheme] answers.
|
||||
StartupThemeResolution? _resolvedTheme;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
unawaited(_resolveTheme());
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
AndroidExitDiagnostics.markStartupPhase(AndroidStartupPhase.firstFrame);
|
||||
if (mounted) unawaited(_initialize());
|
||||
});
|
||||
}
|
||||
|
||||
/// Adopts the persisted theme for the startup frames.
|
||||
///
|
||||
/// Deliberately best-effort and unbounded: on Android the loading frame is
|
||||
/// transparent, so a slow answer costs spinner contrast rather than the
|
||||
/// launch, and a store this cannot read is the gate's failure to report —
|
||||
/// reporting it twice would race the diagnostic record.
|
||||
Future<void> _resolveTheme() async {
|
||||
final resolve = widget.resolveTheme;
|
||||
if (resolve == null) return;
|
||||
try {
|
||||
final resolved = await resolve();
|
||||
if (!mounted) return;
|
||||
setState(() => _resolvedTheme = resolved);
|
||||
} catch (error, stackTrace) {
|
||||
appLogger.d('Could not resolve the persisted startup theme', error: error, stackTrace: stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _initialize() async {
|
||||
if (_initializing) return;
|
||||
|
||||
@@ -717,14 +778,15 @@ class _StartupBootstrapState<T> extends State<StartupBootstrap<T>> {
|
||||
Widget build(BuildContext context) {
|
||||
if (_completed) return widget.buildApp(context, _value as T);
|
||||
|
||||
final resolved = _resolvedTheme;
|
||||
return TranslationProvider(
|
||||
child: Builder(
|
||||
builder: (context) => InputModeTracker(
|
||||
child: MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: widget.lightTheme,
|
||||
darkTheme: widget.darkTheme,
|
||||
themeMode: widget.themeMode,
|
||||
darkTheme: resolved?.darkTheme ?? widget.darkTheme,
|
||||
themeMode: resolved?.themeMode ?? widget.themeMode,
|
||||
home: Builder(builder: _buildBootstrapHome),
|
||||
),
|
||||
),
|
||||
@@ -734,10 +796,9 @@ class _StartupBootstrapState<T> extends State<StartupBootstrap<T>> {
|
||||
|
||||
Widget _buildBootstrapHome(BuildContext context) {
|
||||
final failure = _failure;
|
||||
if (failure != null) {
|
||||
return Scaffold(
|
||||
body: failure == null
|
||||
? const Center(child: CircularProgressIndicator(key: startupBootstrapProgressKey))
|
||||
: StartupFailureView(
|
||||
body: StartupFailureView(
|
||||
failure: failure,
|
||||
busy: _initializing || _repairing,
|
||||
restartRequired: _restartRequired,
|
||||
@@ -746,6 +807,16 @@ class _StartupBootstrapState<T> extends State<StartupBootstrap<T>> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Nothing here is worth covering the launch screen for. The platform
|
||||
// window already holds the user's launch colour, and this frame outlives
|
||||
// the whole gate, so painting a theme guessed from platform brightness is
|
||||
// what turned a black Android TV splash into a flashbang (#1833).
|
||||
return Scaffold(
|
||||
backgroundColor: widget.transparentWhileLoading ? Colors.transparent : null,
|
||||
body: const Center(child: CircularProgressIndicator(key: startupBootstrapProgressKey)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StartupDependencies {
|
||||
|
||||
@@ -52,26 +52,26 @@ class ThemeProvider extends ChangeNotifier with DisposableChangeNotifierMixin, W
|
||||
|
||||
settings.ThemeMode get themeMode => _themeMode;
|
||||
|
||||
ThemeData get lightTheme => monoTheme(dark: false);
|
||||
ThemeData get darkTheme {
|
||||
if (_themeMode == settings.ThemeMode.oled) {
|
||||
return monoTheme(dark: true, oled: true);
|
||||
}
|
||||
return monoTheme(dark: true);
|
||||
}
|
||||
/// Dark palette for [mode], honouring the OLED variant.
|
||||
///
|
||||
/// Static so the pre-provider startup frame can resolve the same theme this
|
||||
/// provider will settle on, instead of guessing from platform brightness
|
||||
/// and flashing when the two disagree (#1833).
|
||||
static ThemeData darkThemeFor(settings.ThemeMode mode) =>
|
||||
monoTheme(dark: true, oled: mode == settings.ThemeMode.oled);
|
||||
|
||||
ThemeMode get materialThemeMode {
|
||||
switch (_themeMode) {
|
||||
case settings.ThemeMode.light:
|
||||
return ThemeMode.light;
|
||||
case settings.ThemeMode.dark:
|
||||
return ThemeMode.dark;
|
||||
case settings.ThemeMode.oled:
|
||||
return ThemeMode.dark;
|
||||
case settings.ThemeMode.system:
|
||||
return ThemeMode.system;
|
||||
}
|
||||
}
|
||||
/// Material equivalent of the app's own [settings.ThemeMode].
|
||||
static ThemeMode materialThemeModeFor(settings.ThemeMode mode) => switch (mode) {
|
||||
settings.ThemeMode.light => ThemeMode.light,
|
||||
settings.ThemeMode.dark => ThemeMode.dark,
|
||||
settings.ThemeMode.oled => ThemeMode.dark,
|
||||
settings.ThemeMode.system => ThemeMode.system,
|
||||
};
|
||||
|
||||
ThemeData get lightTheme => monoTheme(dark: false);
|
||||
ThemeData get darkTheme => darkThemeFor(_themeMode);
|
||||
|
||||
ThemeMode get materialThemeMode => materialThemeModeFor(_themeMode);
|
||||
|
||||
bool get isDarkMode {
|
||||
switch (_themeMode) {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:drift/drift.dart' show ApplyInterceptor, QueryExecutor, QueryExecutorUser, QueryInterceptor;
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/database/app_database.dart';
|
||||
import 'package:plezy/database/download_operations.dart';
|
||||
@@ -11,9 +13,12 @@ import 'package:plezy/database/tvos_database_recovery_store.dart';
|
||||
import 'package:plezy/main.dart';
|
||||
import 'package:plezy/media/ids.dart';
|
||||
import 'package:plezy/models/download_models.dart';
|
||||
import 'package:plezy/providers/theme_provider.dart';
|
||||
import 'package:plezy/services/base_shared_preferences_service.dart';
|
||||
import 'package:plezy/services/prefs_recovery.dart';
|
||||
import 'package:plezy/services/settings_service.dart' as settings;
|
||||
import 'package:plezy/services/startup_diagnostics.dart';
|
||||
import 'package:plezy/theme/mono_theme.dart';
|
||||
import 'package:plezy/widgets/startup_failure_view.dart';
|
||||
|
||||
import 'test_helpers/download_fixtures.dart';
|
||||
@@ -75,6 +80,147 @@ void main() {
|
||||
await tester.pump();
|
||||
});
|
||||
|
||||
// The window behind Flutter already holds the launch colour: on Android
|
||||
// `MainActivity` restores the persisted one and the television resource
|
||||
// qualifier pins the default to black. Anything the loading frame paints
|
||||
// over it lasts the whole gate, so #1833 saw a near-white #F7F7F8 sheet in
|
||||
// place of a black TV splash.
|
||||
const windowKey = Key('window');
|
||||
const launchScreen = Color(0xFF123456);
|
||||
|
||||
Future<int> windowPixel(WidgetTester tester) async {
|
||||
final boundary = tester.renderObject<RenderRepaintBoundary>(find.byKey(windowKey));
|
||||
late ByteData bytes;
|
||||
await tester.runAsync(() async {
|
||||
final image = await boundary.toImage();
|
||||
bytes = (await image.toByteData())!;
|
||||
image.dispose();
|
||||
});
|
||||
// Top-left corner in rawRgba: outside the centred progress indicator.
|
||||
return Color.fromARGB(bytes.getUint8(3), bytes.getUint8(0), bytes.getUint8(1), bytes.getUint8(2)).toARGB32();
|
||||
}
|
||||
|
||||
Widget overLaunchScreen(Widget child) => RepaintBoundary(
|
||||
key: windowKey,
|
||||
child: ColoredBox(color: launchScreen, child: child),
|
||||
);
|
||||
|
||||
ThemeData bootstrapTheme(WidgetTester tester) => Theme.of(tester.element(find.byKey(startupBootstrapProgressKey)));
|
||||
|
||||
testWidgets('the loading frame leaves the platform launch screen visible', (tester) async {
|
||||
final completion = Completer<int>();
|
||||
|
||||
await tester.pumpWidget(
|
||||
overLaunchScreen(
|
||||
StartupBootstrap<int>(
|
||||
initialize: () => completion.future,
|
||||
buildApp: (_, value) => MaterialApp(home: Text('ready $value')),
|
||||
lightTheme: monoTheme(dark: false),
|
||||
darkTheme: monoTheme(dark: true),
|
||||
transparentWhileLoading: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.byKey(startupBootstrapProgressKey), findsOneWidget);
|
||||
expect(await windowPixel(tester), launchScreen.toARGB32());
|
||||
|
||||
completion.complete(1);
|
||||
await tester.pump();
|
||||
});
|
||||
|
||||
testWidgets('the loading frame paints a background where nothing is behind Flutter', (tester) async {
|
||||
final completion = Completer<int>();
|
||||
|
||||
await tester.pumpWidget(
|
||||
overLaunchScreen(
|
||||
StartupBootstrap<int>(
|
||||
initialize: () => completion.future,
|
||||
buildApp: (_, value) => MaterialApp(home: Text('ready $value')),
|
||||
lightTheme: monoTheme(dark: false),
|
||||
darkTheme: monoTheme(dark: true),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(await windowPixel(tester), monoTheme(dark: false).scaffoldBackgroundColor.toARGB32());
|
||||
|
||||
completion.complete(1);
|
||||
await tester.pump();
|
||||
});
|
||||
|
||||
testWidgets('the failure screen stays opaque over the launch screen', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
overLaunchScreen(
|
||||
StartupBootstrap<int>(
|
||||
initialize: () async => throw StateError('database unavailable'),
|
||||
buildApp: (_, value) => MaterialApp(home: Text('ready $value')),
|
||||
lightTheme: monoTheme(dark: false),
|
||||
darkTheme: monoTheme(dark: true),
|
||||
transparentWhileLoading: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byKey(startupBootstrapFailureKey), findsOneWidget);
|
||||
expect(await windowPixel(tester), monoTheme(dark: false).scaffoldBackgroundColor.toARGB32());
|
||||
});
|
||||
|
||||
testWidgets('adopts the persisted theme instead of platform brightness', (tester) async {
|
||||
// What a Fire TV or Shield reports: no system dark-mode toggle, so
|
||||
// ThemeMode.system resolves light while the app's own TV default is OLED.
|
||||
tester.platformDispatcher.platformBrightnessTestValue = Brightness.light;
|
||||
addTearDown(tester.platformDispatcher.clearPlatformBrightnessTestValue);
|
||||
|
||||
final resolved = Completer<StartupThemeResolution>();
|
||||
final completion = Completer<int>();
|
||||
|
||||
await tester.pumpWidget(
|
||||
StartupBootstrap<int>(
|
||||
initialize: () => completion.future,
|
||||
buildApp: (_, value) => MaterialApp(home: Text('ready $value')),
|
||||
lightTheme: monoTheme(dark: false),
|
||||
darkTheme: monoTheme(dark: true),
|
||||
resolveTheme: () => resolved.future,
|
||||
),
|
||||
);
|
||||
|
||||
expect(bootstrapTheme(tester).scaffoldBackgroundColor, monoTheme(dark: false).scaffoldBackgroundColor);
|
||||
|
||||
resolved.complete((
|
||||
themeMode: ThemeProvider.materialThemeModeFor(settings.ThemeMode.oled),
|
||||
darkTheme: ThemeProvider.darkThemeFor(settings.ThemeMode.oled),
|
||||
));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
|
||||
expect(bootstrapTheme(tester).brightness, Brightness.dark);
|
||||
expect(bootstrapTheme(tester).scaffoldBackgroundColor, const Color(0xFF000000));
|
||||
|
||||
completion.complete(1);
|
||||
await tester.pump();
|
||||
});
|
||||
|
||||
testWidgets('a theme preference that cannot be read does not block the gate', (tester) async {
|
||||
final completion = Completer<int>();
|
||||
|
||||
await tester.pumpWidget(
|
||||
StartupBootstrap<int>(
|
||||
initialize: () => completion.future,
|
||||
buildApp: (_, value) => MaterialApp(home: Text('ready $value')),
|
||||
resolveTheme: () async => throw const FormatException('unreadable'),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byKey(startupBootstrapProgressKey), findsOneWidget);
|
||||
|
||||
completion.complete(3);
|
||||
await tester.pump();
|
||||
expect(find.text('ready 3'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('replaces bootstrap UI with the initialized app on success', (tester) async {
|
||||
final completion = Completer<int>();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user