diff --git a/lib/pages/settings_style/settings_style.dart b/lib/pages/settings_style/settings_style.dart index aecace3..d8bd001 100644 --- a/lib/pages/settings_style/settings_style.dart +++ b/lib/pages/settings_style/settings_style.dart @@ -10,6 +10,7 @@ import 'package:tatchat/utils/file_selector.dart'; import 'package:tatchat/widgets/future_loading_dialog.dart'; import 'package:tatchat/widgets/theme_builder.dart'; import 'package:material_ui/material_ui.dart'; +import 'package:matrix/matrix.dart'; import '../../widgets/matrix.dart'; import 'settings_style_view.dart'; @@ -22,11 +23,18 @@ class SettingsStyle extends StatefulWidget { } class SettingsStyleController extends State { - void setChatColor(Color? color) { + Future setChatColor(Color? color) async { + final client = Matrix.of(context).client; + final themeController = ThemeController.of(context); AppSettings.colorSchemeSeedInt.setItem( 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 setWallpaper() async { @@ -105,12 +113,19 @@ class SettingsStyleController extends State { }); } - void deleteChatWallpaper() => showFutureLoadingDialog( - context: context, - future: () => Matrix.of(context).client.setApplicationAccountConfig( - const ApplicationAccountConfig(wallpaperUrl: null, wallpaperBlur: null), - ), - ); + void deleteChatWallpaper() { + final client = Matrix.of(context).client; + showFutureLoadingDialog( + context: context, + future: () => client.setApplicationAccountConfig( + ApplicationAccountConfig( + wallpaperUrl: null, + wallpaperBlur: null, + primaryColor: client.applicationAccountConfig.primaryColor, + ), + ), + ); + } ThemeMode get currentTheme => ThemeController.of(context).themeMode; Color? get currentColor => ThemeController.of(context).primaryColor; diff --git a/lib/utils/account_config.dart b/lib/utils/account_config.dart index a5f246a..78e3846 100644 --- a/lib/utils/account_config.dart +++ b/lib/utils/account_config.dart @@ -27,20 +27,35 @@ extension ApplicationAccountConfigExtension on Client { wallpaperOpacity: config.wallpaperOpacity ?? currentConfig.wallpaperOpacity, wallpaperBlur: config.wallpaperBlur ?? currentConfig.wallpaperBlur, + primaryColor: config.primaryColor ?? currentConfig.primaryColor, ).toJson(), ); } + + Future setApplicationAccountColor(int? primaryColor) { + final currentConfig = applicationAccountConfig; + return setApplicationAccountConfig( + ApplicationAccountConfig( + wallpaperUrl: currentConfig.wallpaperUrl, + wallpaperOpacity: currentConfig.wallpaperOpacity, + wallpaperBlur: currentConfig.wallpaperBlur, + primaryColor: primaryColor, + ), + ); + } } class ApplicationAccountConfig { final Uri? wallpaperUrl; final double? wallpaperOpacity; final double? wallpaperBlur; + final int? primaryColor; const ApplicationAccountConfig({ this.wallpaperUrl, this.wallpaperOpacity, this.wallpaperBlur, + this.primaryColor, }); static double _sanitizedOpacity(double? opacity) { @@ -58,11 +73,13 @@ class ApplicationAccountConfig { json.tryGet('wallpaper_opacity'), ), wallpaperBlur: json.tryGet('wallpaper_blur'), + primaryColor: json.tryGet('primary_color'), ); Map toJson() => { 'wallpaper_url': wallpaperUrl?.toString(), 'wallpaper_opacity': wallpaperOpacity, 'wallpaper_blur': wallpaperBlur, + 'primary_color': primaryColor, }; } diff --git a/lib/widgets/matrix.dart b/lib/widgets/matrix.dart index 31e7082..fdaa47c 100644 --- a/lib/widgets/matrix.dart +++ b/lib/widgets/matrix.dart @@ -44,10 +44,13 @@ class Matrix extends StatefulWidget { final SharedPreferences store; + final ValueChanged? onActiveClientChanged; + const Matrix({ this.child, required this.clients, required this.store, + this.onActiveClientChanged, this.queryParameters, super.key, }); @@ -89,11 +92,21 @@ class MatrixState extends State { final i = widget.clients.indexWhere((c) => c == cl); if (i != -1) { _activeClient = i; + _notifyActiveClientChanged(); } else { 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? get currentBundle { if (!hasComplexBundles) { return List.from(widget.clients); @@ -256,19 +269,27 @@ class MatrixState extends State { .where((state) => state == LoginState.loggedOut) .listen((_) { final loggedInWithMultipleClients = widget.clients.length > 1; + final activeClient = client; + final removedIndex = widget.clients.indexOf(c); + final wasActive = removedIndex != -1 && activeClient == c; _cancelSubs(c.clientName); widget.clients.remove(c); ClientManager.removeClientNameFromStore(c.clientName, store); InitWithRestoreExtension.deleteSessionBackup(name); + if (removedIndex != -1 && _activeClient > removedIndex) { + _activeClient--; + } + if (wasActive) { + _activeClient = -1; + } + if (removedIndex != -1) { + _notifyActiveClientChanged(); + } if (loggedInWithMultipleClients) { final snackbarContext = - TatuChatApp - .router - .routerDelegate - .navigatorKey - .currentContext ?? + TatuChatApp.router.routerDelegate.navigatorKey.currentContext ?? context; if (!snackbarContext.mounted) return; @@ -349,6 +370,7 @@ class MatrixState extends State { }, ); } + _notifyActiveClientChanged(); } void didChangeAppLifecycleState(AppLifecycleState state) { diff --git a/lib/widgets/tatu_chat_app.dart b/lib/widgets/tatu_chat_app.dart index 850fdcc..3d7384f 100644 --- a/lib/widgets/tatu_chat_app.dart +++ b/lib/widgets/tatu_chat_app.dart @@ -65,38 +65,46 @@ class TatuChatApp extends StatelessWidget { @override Widget build(BuildContext context) { return ThemeBuilder( - builder: (context, themeMode, primaryColor) => MaterialApp.router( - title: AppSettings.applicationName.value, - themeMode: themeMode, - theme: FluffyThemes.buildTheme(context, Brightness.light, primaryColor), - darkTheme: FluffyThemes.buildTheme( - context, - Brightness.dark, - primaryColor, - ), - scrollBehavior: CustomScrollBehavior(), - localizationsDelegates: [ - ...L10n.localizationsDelegates, - ...GlobalMaterialLocalizations.delegates, - ...GlobalCupertinoLocalizations.delegates, - ], - supportedLocales: L10n.supportedLocales, - routerConfig: router, - builder: (context, child) => AppLockWidget( - pincode: appLockSettings.pincode, - useBiometrics: appLockSettings.useBiometrics, - isLoggedIn: clients.any((client) => client.isLogged()), - // Need a navigator above the Matrix widget for - // displaying dialogs - child: Matrix( - clients: clients, - store: store, - child: CallOverlay( - child: testWidget ?? child ?? const SizedBox.shrink(), + builder: (context, themeMode, primaryColor, themeController) => + MaterialApp.router( + title: AppSettings.applicationName.value, + themeMode: themeMode, + theme: FluffyThemes.buildTheme( + context, + Brightness.light, + primaryColor, + ), + darkTheme: FluffyThemes.buildTheme( + context, + Brightness.dark, + primaryColor, + ), + scrollBehavior: CustomScrollBehavior(), + localizationsDelegates: [ + ...L10n.localizationsDelegates, + ...GlobalMaterialLocalizations.delegates, + ...GlobalCupertinoLocalizations.delegates, + ], + supportedLocales: L10n.supportedLocales, + routerConfig: router, + builder: (context, child) => AppLockWidget( + pincode: appLockSettings.pincode, + useBiometrics: appLockSettings.useBiometrics, + isLoggedIn: clients.any((client) => client.isLogged()), + // Need a navigator above the Matrix widget for + // displaying dialogs + child: Matrix( + clients: clients, + store: store, + onActiveClientChanged: (client) { + themeController.setAccountClient(client); + }, + child: CallOverlay( + child: testWidget ?? child ?? const SizedBox.shrink(), + ), + ), ), ), - ), - ), ); } } diff --git a/lib/widgets/theme_builder.dart b/lib/widgets/theme_builder.dart index ecb8446..3ab0b3d 100644 --- a/lib/widgets/theme_builder.dart +++ b/lib/widgets/theme_builder.dart @@ -3,10 +3,14 @@ // // SPDX-License-Identifier: AGPL-3.0-or-later +import 'dart:async'; + import 'package:collection/collection.dart'; import 'package:dynamic_color/dynamic_color.dart'; +import 'package:tatchat/utils/account_config.dart'; import 'package:tatchat/utils/color_value.dart'; import 'package:material_ui/material_ui.dart'; +import 'package:matrix/matrix.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -15,6 +19,7 @@ class ThemeBuilder extends StatefulWidget { BuildContext context, ThemeMode themeMode, Color? primaryColor, + ThemeController controller, ) builder; @@ -36,6 +41,11 @@ class ThemeController extends State { SharedPreferences? _sharedPreferences; ThemeMode? _themeMode; Color? _primaryColor; + Color? _localPrimaryColor; + Color? _accountPrimaryColor; + Client? _accountClient; + StreamSubscription? _accountClientSubscription; + int _accountClientGeneration = 0; ThemeMode get themeMode => _themeMode ?? ThemeMode.system; @@ -44,6 +54,15 @@ class ThemeController extends State { static ThemeController of(BuildContext context) => Provider.of(context, listen: false); + void _updatePrimaryColor() { + if (!mounted) return; + final newColor = _accountPrimaryColor ?? _localPrimaryColor; + if (newColor == _primaryColor) return; + setState(() { + _primaryColor = newColor; + }); + } + Future _loadData(_) async { final preferences = _sharedPreferences ??= await SharedPreferences.getInstance(); @@ -51,12 +70,14 @@ class ThemeController extends State { final rawThemeMode = preferences.getString(widget.themeModeSettingsKey); final rawColor = preferences.getInt(widget.primaryColorSettingsKey); + if (!mounted) return; setState(() { _themeMode = ThemeMode.values.singleWhereOrNull( (value) => value.name == rawThemeMode, ); - _primaryColor = rawColor == null ? null : Color(rawColor); + _localPrimaryColor = rawColor == null ? null : Color(rawColor); }); + _updatePrimaryColor(); } Future setThemeMode(ThemeMode newThemeMode) async { @@ -69,6 +90,12 @@ class ThemeController extends State { } Future setPrimaryColor(Color? newPrimaryColor) async { + if (!mounted) return; + setState(() { + _localPrimaryColor = newPrimaryColor; + _accountPrimaryColor = newPrimaryColor; + _primaryColor = newPrimaryColor; + }); final preferences = _sharedPreferences ??= await SharedPreferences.getInstance(); if (newPrimaryColor == null) { @@ -79,9 +106,56 @@ class ThemeController extends State { 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 _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 @@ -95,9 +169,19 @@ class ThemeController extends State { return Provider( create: (_) => this, child: DynamicColorBuilder( - builder: (light, _) => - widget.builder(context, themeMode, primaryColor ?? light?.primary), + builder: (light, _) => widget.builder( + context, + themeMode, + primaryColor ?? light?.primary, + this, + ), ), ); } + + @override + void dispose() { + _accountClientSubscription?.cancel(); + super.dispose(); + } } diff --git a/test/account_config_test.dart b/test/account_config_test.dart new file mode 100644 index 0000000..4acae20 --- /dev/null +++ b/test/account_config_test.dart @@ -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); + }); + }); +}