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:
edde746
2026-08-08 08:29:37 +02:00
parent 24a041977b
commit 3364b3c22c
3 changed files with 247 additions and 30 deletions
+146
View File
@@ -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>();