feat: usar a cor escolhida tal e qual e opcao de suavizar

- TatuPalette.scheme aceita softenPrimary e passa a usar a seed como primary, onPrimary por contraste e surfaceTint, em vez da paleta tonal do Material 3
- nova opcao AppSettings.softenThemeColor (default false, ou seja cor exacta)
- interruptor 'Suavizar cor do tema' em Configuracoes > Aparencia, com l10n en/pt
- a opcao e sincronizada via account data (soften_theme_color) e mantida localmente como fallback, tal como a cor do tema
- setApplicationAccountColor e updateApplicationAccountConfig preservam o novo campo
- um unico sync de account data actualiza cor e opcao
- testes de serializacao e round-trip para o novo campo
This commit is contained in:
2026-09-25 13:46:00 -03:00
parent 6f32eb8972
commit 9382a1f946
11 changed files with 171 additions and 13 deletions

View File

@@ -59,6 +59,11 @@ enum AppSettings<T> {
),
// colorSchemeSeed stored as ARGB int
colorSchemeSeedInt<int>('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<bool>('chat.fluffy.soften_theme_color', false),
emojiSuggestionLocale<String>('emoji_suggestion_locale', ''),
enableSoftLogout<bool>('chat.fluffy.enable_soft_logout', true),
enableMatrixNativeOIDC<bool>('chat.fluffy.enable_matrix_native_oidc', true),

View File

@@ -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;
}
}

View File

@@ -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

View File

@@ -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": {}
}
}

View File

@@ -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."
}

View File

@@ -130,6 +130,13 @@ class SettingsStyleController extends State<SettingsStyle> {
ThemeMode get currentTheme => ThemeController.of(context).themeMode;
Color? get currentColor => ThemeController.of(context).primaryColor;
bool get softenThemeColor => ThemeController.of(context).softenPrimaryColor;
Future<void> setSoftenThemeColor(bool soften) async {
await ThemeController.of(context).setSoftenPrimaryColor(soften);
setState(() {});
}
void switchTheme(ThemeMode? newTheme) {
if (newTheme == null) return;
switch (newTheme) {

View File

@@ -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(

View File

@@ -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<void> 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<double>('wallpaper_blur'),
primaryColor: json.tryGet<int>('primary_color'),
softenThemeColor: json.tryGet<bool>('soften_theme_color'),
);
Map<String, dynamic> toJson() => {
@@ -81,5 +100,6 @@ class ApplicationAccountConfig {
'wallpaper_opacity': wallpaperOpacity,
'wallpaper_blur': wallpaperBlur,
'primary_color': primaryColor,
'soften_theme_color': softenThemeColor,
};
}

View File

@@ -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: [

View File

@@ -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<ThemeBuilder> {
Color? _primaryColor;
Color? _localPrimaryColor;
Color? _accountPrimaryColor;
bool _softenPrimaryColor = false;
bool _localSoftenPrimaryColor = false;
bool? _accountSoftenPrimaryColor;
Client? _accountClient;
StreamSubscription<SyncUpdate>? _accountClientSubscription;
int _accountClientGeneration = 0;
@@ -51,6 +55,8 @@ class ThemeController extends State<ThemeBuilder> {
Color? get primaryColor => _primaryColor;
bool get softenPrimaryColor => _softenPrimaryColor;
static ThemeController of(BuildContext context) =>
Provider.of<ThemeController>(context, listen: false);
@@ -63,6 +69,15 @@ class ThemeController extends State<ThemeBuilder> {
});
}
void _updateSoftenPrimaryColor() {
if (!mounted) return;
final newValue = _accountSoftenPrimaryColor ?? _localSoftenPrimaryColor;
if (newValue == _softenPrimaryColor) return;
setState(() {
_softenPrimaryColor = newValue;
});
}
Future<void> _loadData(_) async {
final preferences = _sharedPreferences ??=
await SharedPreferences.getInstance();
@@ -76,8 +91,10 @@ class ThemeController extends State<ThemeBuilder> {
(value) => value.name == rawThemeMode,
);
_localPrimaryColor = rawColor == null ? null : Color(rawColor);
_localSoftenPrimaryColor = AppSettings.softenThemeColor.value;
});
_updatePrimaryColor();
_updateSoftenPrimaryColor();
}
Future<void> setThemeMode(ThemeMode newThemeMode) async {
@@ -108,6 +125,23 @@ class ThemeController extends State<ThemeBuilder> {
}
}
Future<void> 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<ThemeBuilder> {
_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<ThemeBuilder> {
) ??
false,
)
.listen((_) => _applyAccountClientColor(client, generation));
.listen((_) => _applyAccountClientConfig(client, generation));
}
Future<void> _loadAccountClientColor(Client client, int generation) async {
Future<void> _loadAccountClientConfig(Client client, int generation) async {
final accountDataLoading = client.accountDataLoading;
if (accountDataLoading != null) {
try {
@@ -144,18 +180,21 @@ class ThemeController extends State<ThemeBuilder> {
!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