diff --git a/lib/main.dart b/lib/main.dart index 7c5d2044..72a5cd78 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 _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 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 extends StatefulWidget { this.lightTheme, this.darkTheme, this.themeMode = material.ThemeMode.system, + this.resolveTheme, + this.transparentWhileLoading = false, }); final Future Function() initialize; @@ -599,6 +624,20 @@ class StartupBootstrap 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 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> createState() => _StartupBootstrapState(); } @@ -620,15 +659,37 @@ class _StartupBootstrapState extends State> { 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 _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 _initialize() async { if (_initializing) return; @@ -717,14 +778,15 @@ class _StartupBootstrapState extends State> { 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,16 +796,25 @@ class _StartupBootstrapState extends State> { Widget _buildBootstrapHome(BuildContext context) { final failure = _failure; + if (failure != null) { + return Scaffold( + body: StartupFailureView( + failure: failure, + busy: _initializing || _repairing, + restartRequired: _restartRequired, + onRetry: () => unawaited(_initialize()), + onRepair: failure.repairable && widget.repair != null ? () => _repair(failure) : null, + ), + ); + } + + // 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( - body: failure == null - ? const Center(child: CircularProgressIndicator(key: startupBootstrapProgressKey)) - : StartupFailureView( - failure: failure, - busy: _initializing || _repairing, - restartRequired: _restartRequired, - onRetry: () => unawaited(_initialize()), - onRepair: failure.repairable && widget.repair != null ? () => _repair(failure) : null, - ), + backgroundColor: widget.transparentWhileLoading ? Colors.transparent : null, + body: const Center(child: CircularProgressIndicator(key: startupBootstrapProgressKey)), ); } } diff --git a/lib/providers/theme_provider.dart b/lib/providers/theme_provider.dart index 25cfacd6..19bef8b0 100644 --- a/lib/providers/theme_provider.dart +++ b/lib/providers/theme_provider.dart @@ -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) { diff --git a/test/startup_bootstrap_test.dart b/test/startup_bootstrap_test.dart index 3affe868..2f4f605f 100644 --- a/test/startup_bootstrap_test.dart +++ b/test/startup_bootstrap_test.dart @@ -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 windowPixel(WidgetTester tester) async { + final boundary = tester.renderObject(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(); + + await tester.pumpWidget( + overLaunchScreen( + StartupBootstrap( + 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(); + + await tester.pumpWidget( + overLaunchScreen( + StartupBootstrap( + 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( + 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(); + final completion = Completer(); + + await tester.pumpWidget( + StartupBootstrap( + 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(); + + await tester.pumpWidget( + StartupBootstrap( + 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();