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
129 lines
4.6 KiB
Dart
129 lines
4.6 KiB
Dart
import 'dart:async' show unawaited;
|
|
import 'dart:io' show Platform;
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:material_symbols_icons/symbols.dart';
|
|
import '../mixins/disposable_change_notifier_mixin.dart';
|
|
import '../services/settings_binding_owner.dart';
|
|
import '../services/settings_service.dart' as settings;
|
|
import '../theme/mono_theme.dart';
|
|
|
|
class ThemeProvider extends ChangeNotifier with DisposableChangeNotifierMixin, WidgetsBindingObserver {
|
|
late final SettingsBindingOwner _settingsBinding;
|
|
settings.ThemeMode _themeMode = settings.ThemeMode.system;
|
|
late Brightness _systemBrightness;
|
|
|
|
ThemeProvider() {
|
|
_systemBrightness = WidgetsBinding.instance.platformDispatcher.platformBrightness;
|
|
// Seed synchronously when settings are already loaded (main() initializes
|
|
// them before runApp) so the first frame paints the persisted theme; the
|
|
// async path below lands a microtask too late for the first build.
|
|
final loaded = settings.SettingsService.instanceOrNull;
|
|
if (loaded != null) _themeMode = loaded.read(settings.SettingsService.themeMode);
|
|
_settingsBinding = SettingsBindingOwner(
|
|
prefs: const [settings.SettingsService.themeMode],
|
|
onRefresh: (service) => _syncThemeMode(service.read(settings.SettingsService.themeMode)),
|
|
);
|
|
unawaited(_settingsBinding.bind());
|
|
WidgetsBinding.instance.addObserver(this);
|
|
}
|
|
|
|
@override
|
|
void didChangePlatformBrightness() {
|
|
_systemBrightness = WidgetsBinding.instance.platformDispatcher.platformBrightness;
|
|
if (_themeMode == settings.ThemeMode.system) {
|
|
safeNotifyListeners();
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_settingsBinding.dispose();
|
|
WidgetsBinding.instance.removeObserver(this);
|
|
super.dispose();
|
|
}
|
|
|
|
void _syncThemeMode(settings.ThemeMode mode, {bool forceNotify = false}) {
|
|
final changed = _themeMode != mode;
|
|
_themeMode = mode;
|
|
_updateSplashTheme(mode);
|
|
if (changed || forceNotify) safeNotifyListeners();
|
|
}
|
|
|
|
settings.ThemeMode get themeMode => _themeMode;
|
|
|
|
/// 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);
|
|
|
|
/// 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) {
|
|
case settings.ThemeMode.light:
|
|
return false;
|
|
case settings.ThemeMode.dark:
|
|
return true;
|
|
case settings.ThemeMode.oled:
|
|
return true;
|
|
case settings.ThemeMode.system:
|
|
return _systemBrightness == Brightness.dark;
|
|
}
|
|
}
|
|
|
|
static const _themeChannel = MethodChannel('com.plezy/theme');
|
|
|
|
@visibleForTesting
|
|
Future<void> setThemeMode(settings.ThemeMode mode) async {
|
|
if (_themeMode == mode) return;
|
|
final service = _settingsBinding.settings ?? await settings.SettingsService.getInstance();
|
|
await service.write(settings.SettingsService.themeMode, mode);
|
|
if (_settingsBinding.settings == null) _syncThemeMode(mode);
|
|
}
|
|
|
|
Future<void> reload() async {
|
|
await _settingsBinding.bind();
|
|
final service = _settingsBinding.settings;
|
|
if (service != null) _syncThemeMode(service.read(settings.SettingsService.themeMode), forceNotify: true);
|
|
}
|
|
|
|
void _updateSplashTheme(settings.ThemeMode mode) {
|
|
if (!Platform.isAndroid) return;
|
|
final name = switch (mode) {
|
|
settings.ThemeMode.dark => 'dark',
|
|
settings.ThemeMode.oled => 'oled',
|
|
settings.ThemeMode.light => 'light',
|
|
settings.ThemeMode.system => 'system',
|
|
};
|
|
_themeChannel.invokeMethod('setSplashTheme', {'mode': name});
|
|
}
|
|
|
|
IconData get themeModeIcon {
|
|
switch (_themeMode) {
|
|
case settings.ThemeMode.light:
|
|
return Symbols.light_mode_rounded;
|
|
case settings.ThemeMode.dark:
|
|
return Symbols.dark_mode_rounded;
|
|
case settings.ThemeMode.oled:
|
|
return Symbols.contrast_rounded;
|
|
case settings.ThemeMode.system:
|
|
return Symbols.brightness_auto_rounded;
|
|
}
|
|
}
|
|
}
|