diff --git a/apps/weblibre/lib/core/design/app_colors.dart b/apps/weblibre/lib/core/design/app_colors.dart index 6a5cf08a..e8e98747 100644 --- a/apps/weblibre/lib/core/design/app_colors.dart +++ b/apps/weblibre/lib/core/design/app_colors.dart @@ -114,6 +114,35 @@ class AppColors extends ThemeExtension { 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 AppColors copyWith({ Color? seedColor, diff --git a/apps/weblibre/lib/features/geckoview/domain/providers/tab_state.g.dart b/apps/weblibre/lib/features/geckoview/domain/providers/tab_state.g.dart index 1563804c..a6232417 100644 --- a/apps/weblibre/lib/features/geckoview/domain/providers/tab_state.g.dart +++ b/apps/weblibre/lib/features/geckoview/domain/providers/tab_state.g.dart @@ -41,7 +41,7 @@ final class TabStatesProvider } } -String _$tabStatesHash() => r'd7efef406dd132a3ef1a67da06c794d18ccb484a'; +String _$tabStatesHash() => r'04b7f1ea704575e368a0f1186fc1c58f317620b1'; abstract class _$TabStates extends $Notifier> { Map build(); diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/tab.g.dart b/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/tab.g.dart index 6a2cb36f..1502bc8f 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/tab.g.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/tab.g.dart @@ -41,7 +41,7 @@ final class TabDataRepositoryProvider } } -String _$tabDataRepositoryHash() => r'38750c2e0d27b407b1038d324ad22eeb202da6eb'; +String _$tabDataRepositoryHash() => r'5192e4bb2a373bdef4474fb781eaacc64cc35609'; abstract class _$TabDataRepository extends $Notifier { void build(); diff --git a/apps/weblibre/lib/features/settings/presentation/screens/general_settings.dart b/apps/weblibre/lib/features/settings/presentation/screens/general_settings.dart index 504d5108..303a31f2 100644 --- a/apps/weblibre/lib/features/settings/presentation/screens/general_settings.dart +++ b/apps/weblibre/lib/features/settings/presentation/screens/general_settings.dart @@ -51,6 +51,13 @@ const List generalSettingsSections = [ keywords: ['light', 'dark', 'theme mode'], 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( title: 'User Interface Zoom', 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 { const _ThemeSection(); diff --git a/apps/weblibre/lib/features/user/data/models/general_settings.dart b/apps/weblibre/lib/features/user/data/models/general_settings.dart index e88c8998..7cba7581 100644 --- a/apps/weblibre/lib/features/user/data/models/general_settings.dart +++ b/apps/weblibre/lib/features/user/data/models/general_settings.dart @@ -145,6 +145,10 @@ class GeneralSettings with FastEquatable { /// accept and complete an inline search suggestion. Defaults to false. 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({ required this.themeMode, required this.uiScaleFactor, @@ -203,6 +207,7 @@ class GeneralSettings with FastEquatable { required this.enableLocalSearchIndex, required this.indexPrivateTabs, required this.acceptSuggestionOnSubmit, + required this.pureBlack, }); GeneralSettings.withDefaults({ @@ -263,6 +268,7 @@ class GeneralSettings with FastEquatable { bool? enableLocalSearchIndex, bool? indexPrivateTabs, bool? acceptSuggestionOnSubmit, + bool? pureBlack, }) : themeMode = themeMode ?? ThemeMode.dark, uiScaleFactor = uiScaleFactor ?? defaultUiScaleFactor, disableAnimations = disableAnimations ?? false, @@ -331,7 +337,8 @@ class GeneralSettings with FastEquatable { externalAppIntentPolicies = externalAppIntentPolicies ?? const {}, enableLocalSearchIndex = enableLocalSearchIndex ?? true, indexPrivateTabs = indexPrivateTabs ?? false, - acceptSuggestionOnSubmit = acceptSuggestionOnSubmit ?? false; + acceptSuggestionOnSubmit = acceptSuggestionOnSubmit ?? false, + pureBlack = pureBlack ?? false; factory GeneralSettings.fromJson(Map json) { // Migrate legacy `newTabPosition` setting to direction settings. @@ -442,5 +449,6 @@ class GeneralSettings with FastEquatable { enableLocalSearchIndex, indexPrivateTabs, acceptSuggestionOnSubmit, + pureBlack, ]; } diff --git a/apps/weblibre/lib/features/user/data/models/general_settings.g.dart b/apps/weblibre/lib/features/user/data/models/general_settings.g.dart index 78b050f3..4d1cb27c 100644 --- a/apps/weblibre/lib/features/user/data/models/general_settings.g.dart +++ b/apps/weblibre/lib/features/user/data/models/general_settings.g.dart @@ -143,6 +143,8 @@ abstract class _$GeneralSettingsCWProxy { GeneralSettings acceptSuggestionOnSubmit(bool acceptSuggestionOnSubmit); + GeneralSettings pureBlack(bool pureBlack); + /// 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)`. /// @@ -208,6 +210,7 @@ abstract class _$GeneralSettingsCWProxy { bool enableLocalSearchIndex, bool indexPrivateTabs, bool acceptSuggestionOnSubmit, + bool pureBlack, }); } @@ -461,6 +464,9 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy { GeneralSettings acceptSuggestionOnSubmit(bool acceptSuggestionOnSubmit) => call(acceptSuggestionOnSubmit: acceptSuggestionOnSubmit); + @override + GeneralSettings pureBlack(bool pureBlack) => call(pureBlack: pureBlack); + @override /// 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)`. @@ -528,6 +534,7 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy { Object? enableLocalSearchIndex = const $CopyWithPlaceholder(), Object? indexPrivateTabs = const $CopyWithPlaceholder(), Object? acceptSuggestionOnSubmit = const $CopyWithPlaceholder(), + Object? pureBlack = const $CopyWithPlaceholder(), }) { return GeneralSettings( themeMode: themeMode == const $CopyWithPlaceholder() || themeMode == null @@ -866,6 +873,10 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy { ? _value.acceptSuggestionOnSubmit // ignore: cast_nullable_to_non_nullable : 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?, indexPrivateTabs: json['indexPrivateTabs'] as bool?, acceptSuggestionOnSubmit: json['acceptSuggestionOnSubmit'] as bool?, + pureBlack: json['pureBlack'] as bool?, ); Map _$GeneralSettingsToJson( @@ -1067,6 +1079,7 @@ Map _$GeneralSettingsToJson( 'enableLocalSearchIndex': instance.enableLocalSearchIndex, 'indexPrivateTabs': instance.indexPrivateTabs, 'acceptSuggestionOnSubmit': instance.acceptSuggestionOnSubmit, + 'pureBlack': instance.pureBlack, }; const _$ThemeModeEnumMap = { diff --git a/apps/weblibre/lib/features/user/domain/repositories/general_settings.dart b/apps/weblibre/lib/features/user/domain/repositories/general_settings.dart index dc8dc7e7..765dd93f 100644 --- a/apps/weblibre/lib/features/user/domain/repositories/general_settings.dart +++ b/apps/weblibre/lib/features/user/domain/repositories/general_settings.dart @@ -265,6 +265,10 @@ class GeneralSettingsRepository extends _$GeneralSettingsRepository { DriftSqlType.bool, db.typeMapping, ), + 'pureBlack': settings['pureBlack']?.readAs( + DriftSqlType.bool, + db.typeMapping, + ), }); } diff --git a/apps/weblibre/lib/features/user/domain/repositories/general_settings.g.dart b/apps/weblibre/lib/features/user/domain/repositories/general_settings.g.dart index 42a5ff58..fd3d0aeb 100644 --- a/apps/weblibre/lib/features/user/domain/repositories/general_settings.g.dart +++ b/apps/weblibre/lib/features/user/domain/repositories/general_settings.g.dart @@ -35,7 +35,7 @@ final class GeneralSettingsRepositoryProvider } String _$generalSettingsRepositoryHash() => - r'1e7c7955669933a2dac5a279bcfbac54246672ca'; + r'5036124c086e35c0e4616a0fd3b2276adb66dd5f'; abstract class _$GeneralSettingsRepository extends $StreamNotifier { diff --git a/apps/weblibre/lib/main.dart b/apps/weblibre/lib/main.dart index fe21a333..03116493 100644 --- a/apps/weblibre/lib/main.dart +++ b/apps/weblibre/lib/main.dart @@ -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) { return scheme.surfaceContainerLowest == scheme.surface && scheme.surfaceContainerLow == scheme.surface && @@ -187,6 +209,9 @@ class _MainWidget extends HookConsumerWidget { (value) => value.showModalBarrier, ), ); + final pureBlack = ref.watch( + generalSettingsWithDefaultsProvider.select((value) => value.pureBlack), + ); useOnInitialization(() async { await CountryCodes.init(); @@ -361,6 +386,10 @@ class _MainWidget extends HookConsumerWidget { ); } + if (pureBlack) { + darkColorScheme = _applyPureBlackSurfaces(darkColorScheme); + } + return MainApp( key: rootKey, theme: ThemeData( @@ -389,7 +418,9 @@ class _MainWidget extends HookConsumerWidget { bottomSheetTheme: BottomSheetThemeData( modalBarrierColor: showModalBarrier ? null : Colors.transparent, ), - extensions: const >[AppColors.dark], + extensions: >[ + if (pureBlack) AppColors.darkOled else AppColors.dark, + ], ), themeMode: themeMode, uiScaleFactor: uiScaleFactor,