diff --git a/lib/config/setting_keys.dart b/lib/config/setting_keys.dart index 62ff4f4..2071e8a 100644 --- a/lib/config/setting_keys.dart +++ b/lib/config/setting_keys.dart @@ -59,6 +59,11 @@ enum AppSettings { ), // colorSchemeSeed stored as ARGB int colorSchemeSeedInt('chat.fluffy.color_scheme_seed', 0xFF00D084), + + /// When false the chosen seed color is used verbatim as the theme primary + /// color. When true the Material 3 tonal palette is used instead, which + /// softens it. + softenThemeColor('chat.fluffy.soften_theme_color', false), emojiSuggestionLocale('emoji_suggestion_locale', ''), enableSoftLogout('chat.fluffy.enable_soft_logout', true), enableMatrixNativeOIDC('chat.fluffy.enable_matrix_native_oidc', true), diff --git a/lib/config/tatu_palette.dart b/lib/config/tatu_palette.dart index 4d10f09..ad17988 100644 --- a/lib/config/tatu_palette.dart +++ b/lib/config/tatu_palette.dart @@ -97,7 +97,14 @@ abstract class TatuPalette { /// When a [seed] is provided (e.g. a color chosen by the user or a system /// dynamic color), its tonal values are used for the primary family while /// the brand surfaces, accent and error colors are preserved. - static ColorScheme scheme(Brightness brightness, [Color? seed]) { + /// + /// Unless [softenPrimary] is set, the seed is used verbatim as the primary + /// color, because the Material 3 tonal palette noticeably dulls it down. + static ColorScheme scheme( + Brightness brightness, [ + Color? seed, + bool softenPrimary = false, + ]) { final isDark = brightness == Brightness.dark; final p = isDark ? _dark : _light; final base = ColorScheme.fromSeed( @@ -106,12 +113,17 @@ abstract class TatuPalette { dynamicSchemeVariant: DynamicSchemeVariant.rainbow, ); final useBrandPrimary = seed == null; + // Only force the exact color when the caller did not ask for the soft + // Material 3 palette. `null` here means "keep the value from [base]". + final exactPrimary = !useBrandPrimary && !softenPrimary ? seed : null; return base.copyWith( - primary: useBrandPrimary ? p.primary : null, - onPrimary: useBrandPrimary ? p.onPrimary : null, + primary: useBrandPrimary ? p.primary : exactPrimary, + onPrimary: useBrandPrimary + ? p.onPrimary + : (exactPrimary == null ? null : _contrastOn(exactPrimary)), primaryContainer: useBrandPrimary ? p.primaryContainer : null, onPrimaryContainer: useBrandPrimary ? p.onPrimaryContainer : null, - surfaceTint: useBrandPrimary ? p.primary : null, + surfaceTint: useBrandPrimary ? p.primary : exactPrimary, secondary: p.secondary, onSecondary: p.onSecondary, secondaryContainer: p.secondaryContainer, @@ -141,6 +153,12 @@ abstract class TatuPalette { surfaceBright: p.surfaceBright, ); } + + /// Picks the readable foreground for an arbitrary background color. + static Color _contrastOn(Color background) => + ThemeData.estimateBrightnessForColor(background) == Brightness.dark + ? Colors.white + : Colors.black; } class _Scheme { @@ -209,4 +227,4 @@ class _Scheme { final Color surfaceHighest; final Color surfaceDim; final Color surfaceBright; -} \ No newline at end of file +} diff --git a/lib/config/themes.dart b/lib/config/themes.dart index 839f0d9..e9b2f64 100644 --- a/lib/config/themes.dart +++ b/lib/config/themes.dart @@ -47,8 +47,9 @@ abstract class FluffyThemes { BuildContext context, Brightness brightness, [ Color? seed, + bool softenPrimary = false, ]) { - final colorScheme = TatuPalette.scheme(brightness, seed); + final colorScheme = TatuPalette.scheme(brightness, seed, softenPrimary); final isColumnMode = FluffyThemes.isColumnMode(context); final dividerColor = brightness == Brightness.dark ? colorScheme.surfaceContainerHighest diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index f8324c6..8555c2e 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -3029,5 +3029,15 @@ "@uninstallAppConfirm": { "type": "String", "placeholders": {} + }, + "softenThemeColor": "Soften theme color", + "@softenThemeColor": { + "type": "String", + "placeholders": {} + }, + "softenThemeColorSubtitle": "Use a soft Material 3 palette derived from the selected color instead of the exact color.", + "@softenThemeColorSubtitle": { + "type": "String", + "placeholders": {} } } diff --git a/lib/l10n/intl_pt.arb b/lib/l10n/intl_pt.arb index 994fcc5..564302e 100644 --- a/lib/l10n/intl_pt.arb +++ b/lib/l10n/intl_pt.arb @@ -2043,5 +2043,7 @@ "type": "String", "placeholders": {} }, - "areYouSureDeletePost": "Eliminar" + "areYouSureDeletePost": "Eliminar", + "softenThemeColor": "Suavizar cor do tema", + "softenThemeColorSubtitle": "Usa uma paleta suave do Material 3 derivada da cor escolhida em vez da cor exata." } diff --git a/lib/pages/settings_style/settings_style.dart b/lib/pages/settings_style/settings_style.dart index d8bd001..176a9c9 100644 --- a/lib/pages/settings_style/settings_style.dart +++ b/lib/pages/settings_style/settings_style.dart @@ -130,6 +130,13 @@ class SettingsStyleController extends State { ThemeMode get currentTheme => ThemeController.of(context).themeMode; Color? get currentColor => ThemeController.of(context).primaryColor; + bool get softenThemeColor => ThemeController.of(context).softenPrimaryColor; + + Future setSoftenThemeColor(bool soften) async { + await ThemeController.of(context).setSoftenPrimaryColor(soften); + setState(() {}); + } + void switchTheme(ThemeMode? newTheme) { if (newTheme == null) return; switch (newTheme) { diff --git a/lib/pages/settings_style/settings_style_view.dart b/lib/pages/settings_style/settings_style_view.dart index 97a6062..90240c7 100644 --- a/lib/pages/settings_style/settings_style_view.dart +++ b/lib/pages/settings_style/settings_style_view.dart @@ -136,6 +136,13 @@ class SettingsStyleView extends StatelessWidget { ); }, ), + SwitchListTile( + secondary: const Icon(Icons.palette_outlined), + title: Text(L10n.of(context).softenThemeColor), + subtitle: Text(L10n.of(context).softenThemeColorSubtitle), + value: controller.softenThemeColor, + onChanged: controller.setSoftenThemeColor, + ), Divider(color: theme.dividerColor), ListTile( title: Text( diff --git a/lib/utils/account_config.dart b/lib/utils/account_config.dart index 78e3846..c20786c 100644 --- a/lib/utils/account_config.dart +++ b/lib/utils/account_config.dart @@ -28,6 +28,8 @@ extension ApplicationAccountConfigExtension on Client { config.wallpaperOpacity ?? currentConfig.wallpaperOpacity, wallpaperBlur: config.wallpaperBlur ?? currentConfig.wallpaperBlur, primaryColor: config.primaryColor ?? currentConfig.primaryColor, + softenThemeColor: + config.softenThemeColor ?? currentConfig.softenThemeColor, ).toJson(), ); } @@ -40,6 +42,20 @@ extension ApplicationAccountConfigExtension on Client { wallpaperOpacity: currentConfig.wallpaperOpacity, wallpaperBlur: currentConfig.wallpaperBlur, primaryColor: primaryColor, + softenThemeColor: currentConfig.softenThemeColor, + ), + ); + } + + Future setApplicationAccountSoftenThemeColor(bool soften) { + final currentConfig = applicationAccountConfig; + return setApplicationAccountConfig( + ApplicationAccountConfig( + wallpaperUrl: currentConfig.wallpaperUrl, + wallpaperOpacity: currentConfig.wallpaperOpacity, + wallpaperBlur: currentConfig.wallpaperBlur, + primaryColor: currentConfig.primaryColor, + softenThemeColor: soften, ), ); } @@ -50,12 +66,14 @@ class ApplicationAccountConfig { final double? wallpaperOpacity; final double? wallpaperBlur; final int? primaryColor; + final bool? softenThemeColor; const ApplicationAccountConfig({ this.wallpaperUrl, this.wallpaperOpacity, this.wallpaperBlur, this.primaryColor, + this.softenThemeColor, }); static double _sanitizedOpacity(double? opacity) { @@ -74,6 +92,7 @@ class ApplicationAccountConfig { ), wallpaperBlur: json.tryGet('wallpaper_blur'), primaryColor: json.tryGet('primary_color'), + softenThemeColor: json.tryGet('soften_theme_color'), ); Map toJson() => { @@ -81,5 +100,6 @@ class ApplicationAccountConfig { 'wallpaper_opacity': wallpaperOpacity, 'wallpaper_blur': wallpaperBlur, 'primary_color': primaryColor, + 'soften_theme_color': softenThemeColor, }; } diff --git a/lib/widgets/tatu_chat_app.dart b/lib/widgets/tatu_chat_app.dart index 3d7384f..0c93dde 100644 --- a/lib/widgets/tatu_chat_app.dart +++ b/lib/widgets/tatu_chat_app.dart @@ -73,11 +73,13 @@ class TatuChatApp extends StatelessWidget { context, Brightness.light, primaryColor, + themeController.softenPrimaryColor, ), darkTheme: FluffyThemes.buildTheme( context, Brightness.dark, primaryColor, + themeController.softenPrimaryColor, ), scrollBehavior: CustomScrollBehavior(), localizationsDelegates: [ diff --git a/lib/widgets/theme_builder.dart b/lib/widgets/theme_builder.dart index 3ab0b3d..a22ae9b 100644 --- a/lib/widgets/theme_builder.dart +++ b/lib/widgets/theme_builder.dart @@ -7,6 +7,7 @@ import 'dart:async'; import 'package:collection/collection.dart'; import 'package:dynamic_color/dynamic_color.dart'; +import 'package:tatchat/config/setting_keys.dart'; import 'package:tatchat/utils/account_config.dart'; import 'package:tatchat/utils/color_value.dart'; import 'package:material_ui/material_ui.dart'; @@ -43,6 +44,9 @@ class ThemeController extends State { Color? _primaryColor; Color? _localPrimaryColor; Color? _accountPrimaryColor; + bool _softenPrimaryColor = false; + bool _localSoftenPrimaryColor = false; + bool? _accountSoftenPrimaryColor; Client? _accountClient; StreamSubscription? _accountClientSubscription; int _accountClientGeneration = 0; @@ -51,6 +55,8 @@ class ThemeController extends State { Color? get primaryColor => _primaryColor; + bool get softenPrimaryColor => _softenPrimaryColor; + static ThemeController of(BuildContext context) => Provider.of(context, listen: false); @@ -63,6 +69,15 @@ class ThemeController extends State { }); } + void _updateSoftenPrimaryColor() { + if (!mounted) return; + final newValue = _accountSoftenPrimaryColor ?? _localSoftenPrimaryColor; + if (newValue == _softenPrimaryColor) return; + setState(() { + _softenPrimaryColor = newValue; + }); + } + Future _loadData(_) async { final preferences = _sharedPreferences ??= await SharedPreferences.getInstance(); @@ -76,8 +91,10 @@ class ThemeController extends State { (value) => value.name == rawThemeMode, ); _localPrimaryColor = rawColor == null ? null : Color(rawColor); + _localSoftenPrimaryColor = AppSettings.softenThemeColor.value; }); _updatePrimaryColor(); + _updateSoftenPrimaryColor(); } Future setThemeMode(ThemeMode newThemeMode) async { @@ -108,6 +125,23 @@ class ThemeController extends State { } } + Future setSoftenPrimaryColor(bool soften) async { + await AppSettings.softenThemeColor.setItem(soften); + if (!mounted) return; + setState(() { + _localSoftenPrimaryColor = soften; + _accountSoftenPrimaryColor = soften; + _softenPrimaryColor = soften; + }); + final client = _accountClient; + if (client == null) return; + try { + await client.setApplicationAccountSoftenThemeColor(soften); + } catch (err) { + Logs().e('TatuChat: failed to sync soften theme color', err); + } + } + void setAccountClient(Client? client) { if (!mounted) return; final generation = ++_accountClientGeneration; @@ -115,10 +149,12 @@ class ThemeController extends State { _accountClientSubscription = null; _accountClient = client; _accountPrimaryColor = null; + _accountSoftenPrimaryColor = null; _updatePrimaryColor(); + _updateSoftenPrimaryColor(); if (client == null) return; - unawaited(_loadAccountClientColor(client, generation)); + unawaited(_loadAccountClientConfig(client, generation)); _accountClientSubscription = client.onSync.stream .where( (sync) => @@ -129,10 +165,10 @@ class ThemeController extends State { ) ?? false, ) - .listen((_) => _applyAccountClientColor(client, generation)); + .listen((_) => _applyAccountClientConfig(client, generation)); } - Future _loadAccountClientColor(Client client, int generation) async { + Future _loadAccountClientConfig(Client client, int generation) async { final accountDataLoading = client.accountDataLoading; if (accountDataLoading != null) { try { @@ -144,18 +180,21 @@ class ThemeController extends State { !identical(_accountClient, client)) { return; } - _applyAccountClientColor(client, generation); + _applyAccountClientConfig(client, generation); } - void _applyAccountClientColor(Client client, int generation) { + void _applyAccountClientConfig(Client client, int generation) { if (!mounted || generation != _accountClientGeneration || !identical(_accountClient, client)) { return; } - final primaryColor = client.applicationAccountConfig.primaryColor; + final config = client.applicationAccountConfig; + final primaryColor = config.primaryColor; _accountPrimaryColor = primaryColor == null ? null : Color(primaryColor); + _accountSoftenPrimaryColor = config.softenThemeColor; _updatePrimaryColor(); + _updateSoftenPrimaryColor(); } @override diff --git a/test/account_config_test.dart b/test/account_config_test.dart index 4acae20..95cf6e1 100644 --- a/test/account_config_test.dart +++ b/test/account_config_test.dart @@ -47,12 +47,14 @@ void main() { wallpaperOpacity: 0.3, wallpaperBlur: 4, primaryColor: 0xFF6750A4, + softenThemeColor: true, ); final parsed = ApplicationAccountConfig.fromJson(config.toJson()); expect(parsed.wallpaperUrl, config.wallpaperUrl); expect(parsed.wallpaperOpacity, config.wallpaperOpacity); expect(parsed.wallpaperBlur, config.wallpaperBlur); expect(parsed.primaryColor, config.primaryColor); + expect(parsed.softenThemeColor, config.softenThemeColor); }); test('Keeps existing wallpaper defaults when parsing color only', () { @@ -63,5 +65,50 @@ void main() { expect(config.wallpaperOpacity, 1); expect(config.wallpaperBlur, isNull); }); + + test('Parses soften theme color from account data', () { + expect( + ApplicationAccountConfig.fromJson({ + 'soften_theme_color': true, + }).softenThemeColor, + isTrue, + ); + expect( + ApplicationAccountConfig.fromJson({ + 'soften_theme_color': false, + }).softenThemeColor, + isFalse, + ); + }); + + test('Ignores soften theme color of unexpected type', () { + expect( + ApplicationAccountConfig.fromJson({ + 'soften_theme_color': 'yes', + }).softenThemeColor, + isNull, + ); + }); + + test('Defaults soften theme color to null when absent', () { + expect(ApplicationAccountConfig.fromJson({}).softenThemeColor, isNull); + }); + + test('Serializes soften theme color', () { + const config = ApplicationAccountConfig(softenThemeColor: true); + expect(config.toJson()['soften_theme_color'], true); + }); + + test('Serializes missing soften theme color as null', () { + const config = ApplicationAccountConfig(primaryColor: 0xFF00D084); + expect(config.toJson()['soften_theme_color'], isNull); + }); + + test('Round trips soften theme color through json', () { + final parsed = ApplicationAccountConfig.fromJson( + const ApplicationAccountConfig(softenThemeColor: true).toJson(), + ); + expect(parsed.softenThemeColor, isTrue); + }); }); }