feat: sincronizar cor do tema via account data da conta Matrix

A cor escolhida no Appearance passa a ser guardada em im.fluffychat.account_config (primary_color), em vez de apenas nas preferencias locais do dispositivo.

- ApplicationAccountConfig ganha primaryColor, preservado em updateApplicationAccountConfig e no novo setApplicationAccountColor
- ThemeController restaura a cor da conta activa, aguarda accountDataLoading e reage a alteracoes remotas via onSync
- A cor final e derivada de _accountPrimaryColor ?? _localPrimaryColor, evitando corrida entre as preferencias locais e o sync
- Matrix notifica a troca de conta activa em initMatrix, setActiveClient e logout, corrigindo o indice do cliente activo
- Apagar o wallpaper preserva a cor; testes de serializacao do modelo
This commit is contained in:
2026-09-25 12:53:21 -03:00
parent 7534ecf7d1
commit 058926cf6f
6 changed files with 262 additions and 49 deletions

View File

@@ -10,6 +10,7 @@ import 'package:tatchat/utils/file_selector.dart';
import 'package:tatchat/widgets/future_loading_dialog.dart'; import 'package:tatchat/widgets/future_loading_dialog.dart';
import 'package:tatchat/widgets/theme_builder.dart'; import 'package:tatchat/widgets/theme_builder.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
import 'package:matrix/matrix.dart';
import '../../widgets/matrix.dart'; import '../../widgets/matrix.dart';
import 'settings_style_view.dart'; import 'settings_style_view.dart';
@@ -22,11 +23,18 @@ class SettingsStyle extends StatefulWidget {
} }
class SettingsStyleController extends State<SettingsStyle> { class SettingsStyleController extends State<SettingsStyle> {
void setChatColor(Color? color) { Future<void> setChatColor(Color? color) async {
final client = Matrix.of(context).client;
final themeController = ThemeController.of(context);
AppSettings.colorSchemeSeedInt.setItem( AppSettings.colorSchemeSeedInt.setItem(
color?.toARGB32() ?? AppSettings.colorSchemeSeedInt.defaultValue, color?.toARGB32() ?? AppSettings.colorSchemeSeedInt.defaultValue,
); );
ThemeController.of(context).setPrimaryColor(color); await themeController.setPrimaryColor(color);
try {
await client.setApplicationAccountColor(color?.toARGB32());
} catch (e, s) {
Logs().e('Unable to save chat color', e, s);
}
} }
Future<void> setWallpaper() async { Future<void> setWallpaper() async {
@@ -105,12 +113,19 @@ class SettingsStyleController extends State<SettingsStyle> {
}); });
} }
void deleteChatWallpaper() => showFutureLoadingDialog( void deleteChatWallpaper() {
final client = Matrix.of(context).client;
showFutureLoadingDialog(
context: context, context: context,
future: () => Matrix.of(context).client.setApplicationAccountConfig( future: () => client.setApplicationAccountConfig(
const ApplicationAccountConfig(wallpaperUrl: null, wallpaperBlur: null), ApplicationAccountConfig(
wallpaperUrl: null,
wallpaperBlur: null,
primaryColor: client.applicationAccountConfig.primaryColor,
),
), ),
); );
}
ThemeMode get currentTheme => ThemeController.of(context).themeMode; ThemeMode get currentTheme => ThemeController.of(context).themeMode;
Color? get currentColor => ThemeController.of(context).primaryColor; Color? get currentColor => ThemeController.of(context).primaryColor;

View File

@@ -27,20 +27,35 @@ extension ApplicationAccountConfigExtension on Client {
wallpaperOpacity: wallpaperOpacity:
config.wallpaperOpacity ?? currentConfig.wallpaperOpacity, config.wallpaperOpacity ?? currentConfig.wallpaperOpacity,
wallpaperBlur: config.wallpaperBlur ?? currentConfig.wallpaperBlur, wallpaperBlur: config.wallpaperBlur ?? currentConfig.wallpaperBlur,
primaryColor: config.primaryColor ?? currentConfig.primaryColor,
).toJson(), ).toJson(),
); );
} }
Future<void> setApplicationAccountColor(int? primaryColor) {
final currentConfig = applicationAccountConfig;
return setApplicationAccountConfig(
ApplicationAccountConfig(
wallpaperUrl: currentConfig.wallpaperUrl,
wallpaperOpacity: currentConfig.wallpaperOpacity,
wallpaperBlur: currentConfig.wallpaperBlur,
primaryColor: primaryColor,
),
);
}
} }
class ApplicationAccountConfig { class ApplicationAccountConfig {
final Uri? wallpaperUrl; final Uri? wallpaperUrl;
final double? wallpaperOpacity; final double? wallpaperOpacity;
final double? wallpaperBlur; final double? wallpaperBlur;
final int? primaryColor;
const ApplicationAccountConfig({ const ApplicationAccountConfig({
this.wallpaperUrl, this.wallpaperUrl,
this.wallpaperOpacity, this.wallpaperOpacity,
this.wallpaperBlur, this.wallpaperBlur,
this.primaryColor,
}); });
static double _sanitizedOpacity(double? opacity) { static double _sanitizedOpacity(double? opacity) {
@@ -58,11 +73,13 @@ class ApplicationAccountConfig {
json.tryGet<double>('wallpaper_opacity'), json.tryGet<double>('wallpaper_opacity'),
), ),
wallpaperBlur: json.tryGet<double>('wallpaper_blur'), wallpaperBlur: json.tryGet<double>('wallpaper_blur'),
primaryColor: json.tryGet<int>('primary_color'),
); );
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
'wallpaper_url': wallpaperUrl?.toString(), 'wallpaper_url': wallpaperUrl?.toString(),
'wallpaper_opacity': wallpaperOpacity, 'wallpaper_opacity': wallpaperOpacity,
'wallpaper_blur': wallpaperBlur, 'wallpaper_blur': wallpaperBlur,
'primary_color': primaryColor,
}; };
} }

View File

@@ -44,10 +44,13 @@ class Matrix extends StatefulWidget {
final SharedPreferences store; final SharedPreferences store;
final ValueChanged<Client?>? onActiveClientChanged;
const Matrix({ const Matrix({
this.child, this.child,
required this.clients, required this.clients,
required this.store, required this.store,
this.onActiveClientChanged,
this.queryParameters, this.queryParameters,
super.key, super.key,
}); });
@@ -89,11 +92,21 @@ class MatrixState extends State<Matrix> {
final i = widget.clients.indexWhere((c) => c == cl); final i = widget.clients.indexWhere((c) => c == cl);
if (i != -1) { if (i != -1) {
_activeClient = i; _activeClient = i;
_notifyActiveClientChanged();
} else { } else {
Logs().w('Tried to set an unknown client ${cl!.userID} as active'); Logs().w('Tried to set an unknown client ${cl!.userID} as active');
} }
} }
void _notifyActiveClientChanged() {
final callback = widget.onActiveClientChanged;
if (callback == null) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
callback(widget.clients.isEmpty ? null : client);
});
}
List<Client?>? get currentBundle { List<Client?>? get currentBundle {
if (!hasComplexBundles) { if (!hasComplexBundles) {
return List.from(widget.clients); return List.from(widget.clients);
@@ -256,19 +269,27 @@ class MatrixState extends State<Matrix> {
.where((state) => state == LoginState.loggedOut) .where((state) => state == LoginState.loggedOut)
.listen((_) { .listen((_) {
final loggedInWithMultipleClients = widget.clients.length > 1; final loggedInWithMultipleClients = widget.clients.length > 1;
final activeClient = client;
final removedIndex = widget.clients.indexOf(c);
final wasActive = removedIndex != -1 && activeClient == c;
_cancelSubs(c.clientName); _cancelSubs(c.clientName);
widget.clients.remove(c); widget.clients.remove(c);
ClientManager.removeClientNameFromStore(c.clientName, store); ClientManager.removeClientNameFromStore(c.clientName, store);
InitWithRestoreExtension.deleteSessionBackup(name); InitWithRestoreExtension.deleteSessionBackup(name);
if (removedIndex != -1 && _activeClient > removedIndex) {
_activeClient--;
}
if (wasActive) {
_activeClient = -1;
}
if (removedIndex != -1) {
_notifyActiveClientChanged();
}
if (loggedInWithMultipleClients) { if (loggedInWithMultipleClients) {
final snackbarContext = final snackbarContext =
TatuChatApp TatuChatApp.router.routerDelegate.navigatorKey.currentContext ??
.router
.routerDelegate
.navigatorKey
.currentContext ??
context; context;
if (!snackbarContext.mounted) return; if (!snackbarContext.mounted) return;
@@ -349,6 +370,7 @@ class MatrixState extends State<Matrix> {
}, },
); );
} }
_notifyActiveClientChanged();
} }
void didChangeAppLifecycleState(AppLifecycleState state) { void didChangeAppLifecycleState(AppLifecycleState state) {

View File

@@ -65,10 +65,15 @@ class TatuChatApp extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ThemeBuilder( return ThemeBuilder(
builder: (context, themeMode, primaryColor) => MaterialApp.router( builder: (context, themeMode, primaryColor, themeController) =>
MaterialApp.router(
title: AppSettings.applicationName.value, title: AppSettings.applicationName.value,
themeMode: themeMode, themeMode: themeMode,
theme: FluffyThemes.buildTheme(context, Brightness.light, primaryColor), theme: FluffyThemes.buildTheme(
context,
Brightness.light,
primaryColor,
),
darkTheme: FluffyThemes.buildTheme( darkTheme: FluffyThemes.buildTheme(
context, context,
Brightness.dark, Brightness.dark,
@@ -91,6 +96,9 @@ class TatuChatApp extends StatelessWidget {
child: Matrix( child: Matrix(
clients: clients, clients: clients,
store: store, store: store,
onActiveClientChanged: (client) {
themeController.setAccountClient(client);
},
child: CallOverlay( child: CallOverlay(
child: testWidget ?? child ?? const SizedBox.shrink(), child: testWidget ?? child ?? const SizedBox.shrink(),
), ),

View File

@@ -3,10 +3,14 @@
// //
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
import 'dart:async';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:dynamic_color/dynamic_color.dart'; import 'package:dynamic_color/dynamic_color.dart';
import 'package:tatchat/utils/account_config.dart';
import 'package:tatchat/utils/color_value.dart'; import 'package:tatchat/utils/color_value.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
import 'package:matrix/matrix.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@@ -15,6 +19,7 @@ class ThemeBuilder extends StatefulWidget {
BuildContext context, BuildContext context,
ThemeMode themeMode, ThemeMode themeMode,
Color? primaryColor, Color? primaryColor,
ThemeController controller,
) )
builder; builder;
@@ -36,6 +41,11 @@ class ThemeController extends State<ThemeBuilder> {
SharedPreferences? _sharedPreferences; SharedPreferences? _sharedPreferences;
ThemeMode? _themeMode; ThemeMode? _themeMode;
Color? _primaryColor; Color? _primaryColor;
Color? _localPrimaryColor;
Color? _accountPrimaryColor;
Client? _accountClient;
StreamSubscription<SyncUpdate>? _accountClientSubscription;
int _accountClientGeneration = 0;
ThemeMode get themeMode => _themeMode ?? ThemeMode.system; ThemeMode get themeMode => _themeMode ?? ThemeMode.system;
@@ -44,6 +54,15 @@ class ThemeController extends State<ThemeBuilder> {
static ThemeController of(BuildContext context) => static ThemeController of(BuildContext context) =>
Provider.of<ThemeController>(context, listen: false); Provider.of<ThemeController>(context, listen: false);
void _updatePrimaryColor() {
if (!mounted) return;
final newColor = _accountPrimaryColor ?? _localPrimaryColor;
if (newColor == _primaryColor) return;
setState(() {
_primaryColor = newColor;
});
}
Future<void> _loadData(_) async { Future<void> _loadData(_) async {
final preferences = _sharedPreferences ??= final preferences = _sharedPreferences ??=
await SharedPreferences.getInstance(); await SharedPreferences.getInstance();
@@ -51,12 +70,14 @@ class ThemeController extends State<ThemeBuilder> {
final rawThemeMode = preferences.getString(widget.themeModeSettingsKey); final rawThemeMode = preferences.getString(widget.themeModeSettingsKey);
final rawColor = preferences.getInt(widget.primaryColorSettingsKey); final rawColor = preferences.getInt(widget.primaryColorSettingsKey);
if (!mounted) return;
setState(() { setState(() {
_themeMode = ThemeMode.values.singleWhereOrNull( _themeMode = ThemeMode.values.singleWhereOrNull(
(value) => value.name == rawThemeMode, (value) => value.name == rawThemeMode,
); );
_primaryColor = rawColor == null ? null : Color(rawColor); _localPrimaryColor = rawColor == null ? null : Color(rawColor);
}); });
_updatePrimaryColor();
} }
Future<void> setThemeMode(ThemeMode newThemeMode) async { Future<void> setThemeMode(ThemeMode newThemeMode) async {
@@ -69,6 +90,12 @@ class ThemeController extends State<ThemeBuilder> {
} }
Future<void> setPrimaryColor(Color? newPrimaryColor) async { Future<void> setPrimaryColor(Color? newPrimaryColor) async {
if (!mounted) return;
setState(() {
_localPrimaryColor = newPrimaryColor;
_accountPrimaryColor = newPrimaryColor;
_primaryColor = newPrimaryColor;
});
final preferences = _sharedPreferences ??= final preferences = _sharedPreferences ??=
await SharedPreferences.getInstance(); await SharedPreferences.getInstance();
if (newPrimaryColor == null) { if (newPrimaryColor == null) {
@@ -79,9 +106,56 @@ class ThemeController extends State<ThemeBuilder> {
newPrimaryColor.hexValue, newPrimaryColor.hexValue,
); );
} }
setState(() { }
_primaryColor = newPrimaryColor;
}); void setAccountClient(Client? client) {
if (!mounted) return;
final generation = ++_accountClientGeneration;
_accountClientSubscription?.cancel();
_accountClientSubscription = null;
_accountClient = client;
_accountPrimaryColor = null;
_updatePrimaryColor();
if (client == null) return;
unawaited(_loadAccountClientColor(client, generation));
_accountClientSubscription = client.onSync.stream
.where(
(sync) =>
sync.accountData?.any(
(accountData) =>
accountData.type ==
ApplicationAccountConfigExtension.accountDataKey,
) ??
false,
)
.listen((_) => _applyAccountClientColor(client, generation));
}
Future<void> _loadAccountClientColor(Client client, int generation) async {
final accountDataLoading = client.accountDataLoading;
if (accountDataLoading != null) {
try {
await accountDataLoading;
} catch (_) {}
}
if (!mounted ||
generation != _accountClientGeneration ||
!identical(_accountClient, client)) {
return;
}
_applyAccountClientColor(client, generation);
}
void _applyAccountClientColor(Client client, int generation) {
if (!mounted ||
generation != _accountClientGeneration ||
!identical(_accountClient, client)) {
return;
}
final primaryColor = client.applicationAccountConfig.primaryColor;
_accountPrimaryColor = primaryColor == null ? null : Color(primaryColor);
_updatePrimaryColor();
} }
@override @override
@@ -95,9 +169,19 @@ class ThemeController extends State<ThemeBuilder> {
return Provider( return Provider(
create: (_) => this, create: (_) => this,
child: DynamicColorBuilder( child: DynamicColorBuilder(
builder: (light, _) => builder: (light, _) => widget.builder(
widget.builder(context, themeMode, primaryColor ?? light?.primary), context,
themeMode,
primaryColor ?? light?.primary,
this,
),
), ),
); );
} }
@override
void dispose() {
_accountClientSubscription?.cancel();
super.dispose();
}
} }

View File

@@ -0,0 +1,67 @@
// SPDX-FileCopyrightText: 2026 TatuChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'package:flutter_test/flutter_test.dart';
import 'package:tatchat/utils/account_config.dart';
void main() {
group('ApplicationAccountConfig', () {
test('Parses primary color from account data', () {
final config = ApplicationAccountConfig.fromJson({
'primary_color': 0xFF6750A4,
});
expect(config.primaryColor, 0xFF6750A4);
});
test('Ignores primary color of unexpected type', () {
expect(
ApplicationAccountConfig.fromJson({
'primary_color': '4286369748',
}).primaryColor,
isNull,
);
expect(
ApplicationAccountConfig.fromJson({'primary_color': 1.5}).primaryColor,
isNull,
);
});
test('Defaults primary color to null when absent', () {
expect(ApplicationAccountConfig.fromJson({}).primaryColor, isNull);
});
test('Serializes primary color', () {
const config = ApplicationAccountConfig(primaryColor: 0xFF00D084);
expect(config.toJson()['primary_color'], 0xFF00D084);
});
test('Serializes missing primary color as null', () {
const config = ApplicationAccountConfig(wallpaperOpacity: 0.5);
expect(config.toJson()['primary_color'], isNull);
});
test('Round trips through json', () {
final config = ApplicationAccountConfig(
wallpaperUrl: Uri.parse('mxc://example.invalid/wallpaper'),
wallpaperOpacity: 0.3,
wallpaperBlur: 4,
primaryColor: 0xFF6750A4,
);
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);
});
test('Keeps existing wallpaper defaults when parsing color only', () {
final config = ApplicationAccountConfig.fromJson({
'primary_color': 0xFF6750A4,
});
expect(config.wallpaperUrl, isNull);
expect(config.wallpaperOpacity, 1);
expect(config.wallpaperBlur, isNull);
});
});
}