add oled theme support

This commit is contained in:
Fabian Freund
2026-06-06 07:33:32 +02:00
parent c5bed35048
commit 7614f5ea9f
9 changed files with 134 additions and 5 deletions
@@ -114,6 +114,35 @@ class AppColors extends ThemeExtension<AppColors> {
brandLink: Color(0xFFFBDC6B), brandLink: Color(0xFFFBDC6B),
); );
/// Pure-black ("OLED") variant of [dark]. Identical to [dark] except the
/// decorative aura tones collapse toward black so the home/new-tab backdrop
/// reads as black instead of charcoal. Semantic colors (private, isolated,
/// tor, brand, warning) are kept so they stay legible. The aura orbs are
/// heavily blurred, so the faint tints below still render as a subtle brand
/// glow on black rather than flat dead black.
static const darkOled = AppColors._(
seedColor: Color(0xFF167C80),
privateTabPurple: Color(0xFF8000D7),
privateTabBackground: Color(0xFF25003E),
privateTabForeground: Color(0xFFFFFFFF),
privateSelectionOverlay: Color(0x648000D7),
isolatedTabTeal: Color(0xFF00897B),
isolatedTabBackground: Color(0xFF003D36),
isolatedTabForeground: Color(0xFFFFFFFF),
isolatedSelectionOverlay: Color(0x6400897B),
torPurple: Color(0xFF7D4698),
torActiveGreen: Color(0xFF68B030),
torBackgroundGrey: Color(0xFF333A41),
warningAmber: Color(0xFFFFA000),
// Aura: collapsed toward black, keeping only a whisper of brand tint.
auraPurple: Color(0xFF0E0A1A),
auraGold: Color(0xFF17130A),
auraShadow: Color(0xFF050505),
auraShadowHighlight: Color(0xFF0C0C0E),
auraTint: Color(0xFF000000),
brandLink: Color(0xFFFBDC6B),
);
@override @override
AppColors copyWith({ AppColors copyWith({
Color? seedColor, Color? seedColor,
@@ -41,7 +41,7 @@ final class TabStatesProvider
} }
} }
String _$tabStatesHash() => r'd7efef406dd132a3ef1a67da06c794d18ccb484a'; String _$tabStatesHash() => r'04b7f1ea704575e368a0f1186fc1c58f317620b1';
abstract class _$TabStates extends $Notifier<Map<String, TabState>> { abstract class _$TabStates extends $Notifier<Map<String, TabState>> {
Map<String, TabState> build(); Map<String, TabState> build();
@@ -41,7 +41,7 @@ final class TabDataRepositoryProvider
} }
} }
String _$tabDataRepositoryHash() => r'38750c2e0d27b407b1038d324ad22eeb202da6eb'; String _$tabDataRepositoryHash() => r'5192e4bb2a373bdef4474fb781eaacc64cc35609';
abstract class _$TabDataRepository extends $Notifier<void> { abstract class _$TabDataRepository extends $Notifier<void> {
void build(); void build();
@@ -51,6 +51,13 @@ const List<SettingsSectionDefinition> generalSettingsSections = [
keywords: ['light', 'dark', 'theme mode'], keywords: ['light', 'dark', 'theme mode'],
child: _ThemeSection(), child: _ThemeSection(),
), ),
SettingsEntryDefinition(
title: 'Pure Black (OLED)',
subtitle: 'Use true-black surfaces in dark mode to save power on OLED '
'screens',
keywords: ['oled', 'amoled', 'high contrast', 'black', 'dark'],
child: _PureBlackTile(),
),
SettingsEntryDefinition( SettingsEntryDefinition(
title: 'User Interface Zoom', title: 'User Interface Zoom',
subtitle: 'Make the user interface smaller or larger', subtitle: 'Make the user interface smaller or larger',
@@ -275,6 +282,43 @@ class _ShowModalBarrierTile extends HookConsumerWidget {
} }
} }
class _PureBlackTile extends HookConsumerWidget {
const _PureBlackTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final pureBlack = ref.watch(
generalSettingsWithDefaultsProvider.select((s) => s.pureBlack),
);
final themeMode = ref.watch(
generalSettingsWithDefaultsProvider.select((s) => s.themeMode),
);
// OLED surfaces only apply to dark mode; disable the toggle when the app
// is locked to light mode so the setting can't appear to have no effect.
final enabled = themeMode != ThemeMode.light;
return SwitchListTile.adaptive(
title: const Text('Pure Black (OLED)'),
subtitle: const Text(
'Use true-black surfaces in dark mode to save power on OLED screens',
),
secondary: const Icon(Icons.contrast),
value: pureBlack,
onChanged: enabled
? (value) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.pureBlack(value),
);
}
: null,
);
}
}
class _ThemeSection extends HookConsumerWidget { class _ThemeSection extends HookConsumerWidget {
const _ThemeSection(); const _ThemeSection();
@@ -145,6 +145,10 @@ class GeneralSettings with FastEquatable {
/// accept and complete an inline search suggestion. Defaults to false. /// accept and complete an inline search suggestion. Defaults to false.
final bool acceptSuggestionOnSubmit; final bool acceptSuggestionOnSubmit;
/// Whether dark mode should use pure-black ("OLED"/high-contrast) surfaces.
/// Only takes effect when the effective brightness is dark. Defaults to false.
final bool pureBlack;
GeneralSettings({ GeneralSettings({
required this.themeMode, required this.themeMode,
required this.uiScaleFactor, required this.uiScaleFactor,
@@ -203,6 +207,7 @@ class GeneralSettings with FastEquatable {
required this.enableLocalSearchIndex, required this.enableLocalSearchIndex,
required this.indexPrivateTabs, required this.indexPrivateTabs,
required this.acceptSuggestionOnSubmit, required this.acceptSuggestionOnSubmit,
required this.pureBlack,
}); });
GeneralSettings.withDefaults({ GeneralSettings.withDefaults({
@@ -263,6 +268,7 @@ class GeneralSettings with FastEquatable {
bool? enableLocalSearchIndex, bool? enableLocalSearchIndex,
bool? indexPrivateTabs, bool? indexPrivateTabs,
bool? acceptSuggestionOnSubmit, bool? acceptSuggestionOnSubmit,
bool? pureBlack,
}) : themeMode = themeMode ?? ThemeMode.dark, }) : themeMode = themeMode ?? ThemeMode.dark,
uiScaleFactor = uiScaleFactor ?? defaultUiScaleFactor, uiScaleFactor = uiScaleFactor ?? defaultUiScaleFactor,
disableAnimations = disableAnimations ?? false, disableAnimations = disableAnimations ?? false,
@@ -331,7 +337,8 @@ class GeneralSettings with FastEquatable {
externalAppIntentPolicies = externalAppIntentPolicies ?? const {}, externalAppIntentPolicies = externalAppIntentPolicies ?? const {},
enableLocalSearchIndex = enableLocalSearchIndex ?? true, enableLocalSearchIndex = enableLocalSearchIndex ?? true,
indexPrivateTabs = indexPrivateTabs ?? false, indexPrivateTabs = indexPrivateTabs ?? false,
acceptSuggestionOnSubmit = acceptSuggestionOnSubmit ?? false; acceptSuggestionOnSubmit = acceptSuggestionOnSubmit ?? false,
pureBlack = pureBlack ?? false;
factory GeneralSettings.fromJson(Map<String, dynamic> json) { factory GeneralSettings.fromJson(Map<String, dynamic> json) {
// Migrate legacy `newTabPosition` setting to direction settings. // Migrate legacy `newTabPosition` setting to direction settings.
@@ -442,5 +449,6 @@ class GeneralSettings with FastEquatable {
enableLocalSearchIndex, enableLocalSearchIndex,
indexPrivateTabs, indexPrivateTabs,
acceptSuggestionOnSubmit, acceptSuggestionOnSubmit,
pureBlack,
]; ];
} }
@@ -143,6 +143,8 @@ abstract class _$GeneralSettingsCWProxy {
GeneralSettings acceptSuggestionOnSubmit(bool acceptSuggestionOnSubmit); GeneralSettings acceptSuggestionOnSubmit(bool acceptSuggestionOnSubmit);
GeneralSettings pureBlack(bool pureBlack);
/// Creates a new instance with the provided field values. /// Creates a new instance with the provided field values.
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `GeneralSettings(...).copyWith.fieldName(value)`. /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `GeneralSettings(...).copyWith.fieldName(value)`.
/// ///
@@ -208,6 +210,7 @@ abstract class _$GeneralSettingsCWProxy {
bool enableLocalSearchIndex, bool enableLocalSearchIndex,
bool indexPrivateTabs, bool indexPrivateTabs,
bool acceptSuggestionOnSubmit, bool acceptSuggestionOnSubmit,
bool pureBlack,
}); });
} }
@@ -461,6 +464,9 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
GeneralSettings acceptSuggestionOnSubmit(bool acceptSuggestionOnSubmit) => GeneralSettings acceptSuggestionOnSubmit(bool acceptSuggestionOnSubmit) =>
call(acceptSuggestionOnSubmit: acceptSuggestionOnSubmit); call(acceptSuggestionOnSubmit: acceptSuggestionOnSubmit);
@override
GeneralSettings pureBlack(bool pureBlack) => call(pureBlack: pureBlack);
@override @override
/// Creates a new instance with the provided field values. /// Creates a new instance with the provided field values.
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `GeneralSettings(...).copyWith.fieldName(value)`. /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `GeneralSettings(...).copyWith.fieldName(value)`.
@@ -528,6 +534,7 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
Object? enableLocalSearchIndex = const $CopyWithPlaceholder(), Object? enableLocalSearchIndex = const $CopyWithPlaceholder(),
Object? indexPrivateTabs = const $CopyWithPlaceholder(), Object? indexPrivateTabs = const $CopyWithPlaceholder(),
Object? acceptSuggestionOnSubmit = const $CopyWithPlaceholder(), Object? acceptSuggestionOnSubmit = const $CopyWithPlaceholder(),
Object? pureBlack = const $CopyWithPlaceholder(),
}) { }) {
return GeneralSettings( return GeneralSettings(
themeMode: themeMode == const $CopyWithPlaceholder() || themeMode == null themeMode: themeMode == const $CopyWithPlaceholder() || themeMode == null
@@ -866,6 +873,10 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
? _value.acceptSuggestionOnSubmit ? _value.acceptSuggestionOnSubmit
// ignore: cast_nullable_to_non_nullable // ignore: cast_nullable_to_non_nullable
: acceptSuggestionOnSubmit as bool, : acceptSuggestionOnSubmit as bool,
pureBlack: pureBlack == const $CopyWithPlaceholder() || pureBlack == null
? _value.pureBlack
// ignore: cast_nullable_to_non_nullable
: pureBlack as bool,
); );
} }
} }
@@ -992,6 +1003,7 @@ GeneralSettings _$GeneralSettingsFromJson(
enableLocalSearchIndex: json['enableLocalSearchIndex'] as bool?, enableLocalSearchIndex: json['enableLocalSearchIndex'] as bool?,
indexPrivateTabs: json['indexPrivateTabs'] as bool?, indexPrivateTabs: json['indexPrivateTabs'] as bool?,
acceptSuggestionOnSubmit: json['acceptSuggestionOnSubmit'] as bool?, acceptSuggestionOnSubmit: json['acceptSuggestionOnSubmit'] as bool?,
pureBlack: json['pureBlack'] as bool?,
); );
Map<String, dynamic> _$GeneralSettingsToJson( Map<String, dynamic> _$GeneralSettingsToJson(
@@ -1067,6 +1079,7 @@ Map<String, dynamic> _$GeneralSettingsToJson(
'enableLocalSearchIndex': instance.enableLocalSearchIndex, 'enableLocalSearchIndex': instance.enableLocalSearchIndex,
'indexPrivateTabs': instance.indexPrivateTabs, 'indexPrivateTabs': instance.indexPrivateTabs,
'acceptSuggestionOnSubmit': instance.acceptSuggestionOnSubmit, 'acceptSuggestionOnSubmit': instance.acceptSuggestionOnSubmit,
'pureBlack': instance.pureBlack,
}; };
const _$ThemeModeEnumMap = { const _$ThemeModeEnumMap = {
@@ -265,6 +265,10 @@ class GeneralSettingsRepository extends _$GeneralSettingsRepository {
DriftSqlType.bool, DriftSqlType.bool,
db.typeMapping, db.typeMapping,
), ),
'pureBlack': settings['pureBlack']?.readAs(
DriftSqlType.bool,
db.typeMapping,
),
}); });
} }
@@ -35,7 +35,7 @@ final class GeneralSettingsRepositoryProvider
} }
String _$generalSettingsRepositoryHash() => String _$generalSettingsRepositoryHash() =>
r'1e7c7955669933a2dac5a279bcfbac54246672ca'; r'5036124c086e35c0e4616a0fd3b2276adb66dd5f';
abstract class _$GeneralSettingsRepository abstract class _$GeneralSettingsRepository
extends $StreamNotifier<GeneralSettings> { extends $StreamNotifier<GeneralSettings> {
+32 -1
View File
@@ -84,6 +84,28 @@ ColorScheme _fixSurfaceContainerColors(
} }
} }
/// Rewrites a dark [ColorScheme] to use pure-black ("OLED"/high-contrast)
/// surfaces.
///
/// Only the *base* tones (the scaffold/page background and the lowest
/// containers) become true black for the power saving. The elevated container
/// tones keep meaningful grey steps so cards, sheets and menus stay visibly
/// separated from the black background — Material elevation shadows are
/// invisible on black, so the surface-tint step is the only separation cue and
/// it must stay perceptible. Steps below ~`#12` are imperceptible near black, so
/// the elevated tones climb in larger increments than the default dark scheme.
ColorScheme _applyPureBlackSurfaces(ColorScheme scheme) {
return scheme.copyWith(
surface: const Color(0xFF000000),
surfaceDim: const Color(0xFF000000),
surfaceContainerLowest: const Color(0xFF000000),
surfaceContainerLow: const Color(0xFF121212),
surfaceContainer: const Color(0xFF1B1B1B),
surfaceContainerHigh: const Color(0xFF242424),
surfaceContainerHighest: const Color(0xFF2E2E2E),
);
}
bool _hasBrokenSurfaceContainerColors(ColorScheme scheme) { bool _hasBrokenSurfaceContainerColors(ColorScheme scheme) {
return scheme.surfaceContainerLowest == scheme.surface && return scheme.surfaceContainerLowest == scheme.surface &&
scheme.surfaceContainerLow == scheme.surface && scheme.surfaceContainerLow == scheme.surface &&
@@ -187,6 +209,9 @@ class _MainWidget extends HookConsumerWidget {
(value) => value.showModalBarrier, (value) => value.showModalBarrier,
), ),
); );
final pureBlack = ref.watch(
generalSettingsWithDefaultsProvider.select((value) => value.pureBlack),
);
useOnInitialization(() async { useOnInitialization(() async {
await CountryCodes.init(); await CountryCodes.init();
@@ -361,6 +386,10 @@ class _MainWidget extends HookConsumerWidget {
); );
} }
if (pureBlack) {
darkColorScheme = _applyPureBlackSurfaces(darkColorScheme);
}
return MainApp( return MainApp(
key: rootKey, key: rootKey,
theme: ThemeData( theme: ThemeData(
@@ -389,7 +418,9 @@ class _MainWidget extends HookConsumerWidget {
bottomSheetTheme: BottomSheetThemeData( bottomSheetTheme: BottomSheetThemeData(
modalBarrierColor: showModalBarrier ? null : Colors.transparent, modalBarrierColor: showModalBarrier ? null : Colors.transparent,
), ),
extensions: const <ThemeExtension<dynamic>>[AppColors.dark], extensions: <ThemeExtension<dynamic>>[
if (pureBlack) AppColors.darkOled else AppColors.dark,
],
), ),
themeMode: themeMode, themeMode: themeMode,
uiScaleFactor: uiScaleFactor, uiScaleFactor: uiScaleFactor,