Compare commits

...

6 Commits

Author SHA1 Message Date
3f681feb26 Merge branch 'main' of https://gitea.weltonmoura.com.br/welton/tatuchat
Some checks failed
Main Deploy Workflow / deploy_web (push) Has been cancelled
Main Deploy Workflow / deploy_playstore_internal (push) Has been cancelled
Close stale issues and PRs / stale (push) Has been cancelled
2026-09-25 14:41:51 -03:00
a31a88f87a feat: zoom widget de imagem 2026-09-25 14:41:13 -03:00
9382a1f946 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
2026-09-25 13:46:00 -03:00
6f32eb8972 fix: incluir o wallpaper padrao no bundle e mostrar na previa
O Flutter nao empacota subdirectorios de forma recursiva, e assets/logo/ nao estava listado no pubspec, portanto assets/logo/background.png nunca chegava ao bundle e o wallpaper padrao das conversas nao aparecia.

- pubspec: declara assets/logo/background.png
- chat_view: normaliza o path do asset
- settings_style_view: a previa passa a mostrar o wallpaper padrao quando nao ha imagem custom, com opacidade 0.3 e sem blur, em vez de ficar sem fundo
2026-09-25 13:15:03 -03:00
058926cf6f 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
2026-09-25 12:53:21 -03:00
7534ecf7d1 fix: simplify chat context menu 2026-09-25 10:27:41 -03:00
17 changed files with 658 additions and 190 deletions

View File

@@ -59,6 +59,11 @@ enum AppSettings<T> {
), ),
// colorSchemeSeed stored as ARGB int // colorSchemeSeed stored as ARGB int
colorSchemeSeedInt<int>('chat.fluffy.color_scheme_seed', 0xFF00D084), 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', ''), emojiSuggestionLocale<String>('emoji_suggestion_locale', ''),
enableSoftLogout<bool>('chat.fluffy.enable_soft_logout', true), enableSoftLogout<bool>('chat.fluffy.enable_soft_logout', true),
enableMatrixNativeOIDC<bool>('chat.fluffy.enable_matrix_native_oidc', 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 /// 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 /// dynamic color), its tonal values are used for the primary family while
/// the brand surfaces, accent and error colors are preserved. /// 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 isDark = brightness == Brightness.dark;
final p = isDark ? _dark : _light; final p = isDark ? _dark : _light;
final base = ColorScheme.fromSeed( final base = ColorScheme.fromSeed(
@@ -106,12 +113,17 @@ abstract class TatuPalette {
dynamicSchemeVariant: DynamicSchemeVariant.rainbow, dynamicSchemeVariant: DynamicSchemeVariant.rainbow,
); );
final useBrandPrimary = seed == null; 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( return base.copyWith(
primary: useBrandPrimary ? p.primary : null, primary: useBrandPrimary ? p.primary : exactPrimary,
onPrimary: useBrandPrimary ? p.onPrimary : null, onPrimary: useBrandPrimary
? p.onPrimary
: (exactPrimary == null ? null : _contrastOn(exactPrimary)),
primaryContainer: useBrandPrimary ? p.primaryContainer : null, primaryContainer: useBrandPrimary ? p.primaryContainer : null,
onPrimaryContainer: useBrandPrimary ? p.onPrimaryContainer : null, onPrimaryContainer: useBrandPrimary ? p.onPrimaryContainer : null,
surfaceTint: useBrandPrimary ? p.primary : null, surfaceTint: useBrandPrimary ? p.primary : exactPrimary,
secondary: p.secondary, secondary: p.secondary,
onSecondary: p.onSecondary, onSecondary: p.onSecondary,
secondaryContainer: p.secondaryContainer, secondaryContainer: p.secondaryContainer,
@@ -141,6 +153,12 @@ abstract class TatuPalette {
surfaceBright: p.surfaceBright, 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 { class _Scheme {
@@ -209,4 +227,4 @@ class _Scheme {
final Color surfaceHighest; final Color surfaceHighest;
final Color surfaceDim; final Color surfaceDim;
final Color surfaceBright; final Color surfaceBright;
} }

View File

@@ -47,8 +47,9 @@ abstract class FluffyThemes {
BuildContext context, BuildContext context,
Brightness brightness, [ Brightness brightness, [
Color? seed, Color? seed,
bool softenPrimary = false,
]) { ]) {
final colorScheme = TatuPalette.scheme(brightness, seed); final colorScheme = TatuPalette.scheme(brightness, seed, softenPrimary);
final isColumnMode = FluffyThemes.isColumnMode(context); final isColumnMode = FluffyThemes.isColumnMode(context);
final dividerColor = brightness == Brightness.dark final dividerColor = brightness == Brightness.dark
? colorScheme.surfaceContainerHighest ? colorScheme.surfaceContainerHighest

View File

@@ -3029,5 +3029,15 @@
"@uninstallAppConfirm": { "@uninstallAppConfirm": {
"type": "String", "type": "String",
"placeholders": {} "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", "type": "String",
"placeholders": {} "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

@@ -525,7 +525,7 @@ class ChatListController extends State<ChatList>
) )
.toList(); .toList();
var action = await showModalActionPopup<ChatContextAction>( final action = await showModalActionPopup<ChatContextAction>(
context: posContext, context: posContext,
cancelLabel: l10n.cancel, cancelLabel: l10n.cancel,
actions: [ actions: [
@@ -564,36 +564,6 @@ class ChatListController extends State<ChatList>
room.isFavourite ? Icons.push_pin : Icons.push_pin_outlined, room.isFavourite ? Icons.push_pin : Icons.push_pin_outlined,
), ),
), ),
],
AdaptiveModalAction(
value: ChatContextAction.leave,
label: room.membership == Membership.invite
? l10n.delete
: l10n.leave,
icon: const Icon(Icons.delete_outlined),
isDestructive: true,
),
if (room.membership == Membership.invite)
AdaptiveModalAction(
value: ChatContextAction.block,
label: l10n.block,
icon: const Icon(Icons.block_outlined),
isDestructive: true,
),
if (room.membership == Membership.join)
AdaptiveModalAction(
value: ChatContextAction.showMore,
label: l10n.more,
icon: Icon(Icons.adaptive.more_outlined),
),
],
);
if (!posContext.mounted || !mounted) return;
if (action == ChatContextAction.showMore) {
action = await showModalActionPopup<ChatContextAction>(
context: posContext,
cancelLabel: l10n.cancel,
actions: [
if (!room.isFavourite) if (!room.isFavourite)
AdaptiveModalAction( AdaptiveModalAction(
value: ChatContextAction.lowPriority, value: ChatContextAction.lowPriority,
@@ -625,8 +595,24 @@ class ChatListController extends State<ChatList>
icon: const Icon(Icons.group_work_outlined), icon: const Icon(Icons.group_work_outlined),
), ),
], ],
); AdaptiveModalAction(
} value: ChatContextAction.leave,
label: room.membership == Membership.invite
? l10n.delete
: l10n.leave,
icon: const Icon(Icons.delete_outlined),
isDestructive: true,
),
if (room.membership == Membership.invite)
AdaptiveModalAction(
value: ChatContextAction.block,
label: l10n.block,
icon: const Icon(Icons.block_outlined),
isDestructive: true,
),
],
);
if (!posContext.mounted || !mounted) return;
if (action == null) return; if (action == null) return;
if (!mounted) return; if (!mounted) return;
@@ -753,8 +739,6 @@ class ChatListController extends State<ChatList>
future: () => room.removeTag(activeTag!), future: () => room.removeTag(activeTag!),
); );
return; return;
case ChatContextAction.showMore:
throw ('Should not be handled!');
} }
} }
@@ -1021,5 +1005,4 @@ enum ChatContextAction {
leave, leave,
addToSpace, addToSpace,
block, block,
showMore,
} }

View File

@@ -33,6 +33,57 @@ class ImageViewer extends StatefulWidget {
class ImageViewerController extends State<ImageViewer> { class ImageViewerController extends State<ImageViewer> {
final FocusNode focusNode = FocusNode(); final FocusNode focusNode = FocusNode();
/// One [TransformationController] per image, so the zoom of one page never
/// leaks into its neighbours.
final Map<String, TransformationController> _transformations = {};
int _pointerCount = 0;
bool _isZoomed = false;
/// True while an image is zoomed in, so panning moves the image instead of
/// switching to the next one.
bool get isZoomed => _isZoomed;
/// The [PageView] must not compete for the gesture while pinching or while
/// an image is zoomed, otherwise the swipe recognizer wins the gesture arena
/// and pinch-to-zoom never happens.
bool get canSwipeToNextImage => _pointerCount < 2 && !_isZoomed;
TransformationController transformationControllerFor(Event event) =>
_transformations.putIfAbsent(event.eventId, () {
final controller = TransformationController();
controller.addListener(_onTransformChanged);
return controller;
});
void onPointerDown() {
if (!mounted) return;
setState(() => _pointerCount++);
}
void onPointerUp() {
if (!mounted || _pointerCount == 0) return;
setState(() => _pointerCount--);
}
void _onTransformChanged() {
if (!mounted) return;
final zoomed = _transformations.values.any(
(controller) => controller.value.getMaxScaleOnAxis() > 1.01,
);
if (zoomed == _isZoomed) return;
setState(() => _isZoomed = zoomed);
}
/// Resets the zoom of every image, e.g. after moving to another one.
void resetZooms() {
for (final controller in _transformations.values) {
if (!controller.value.isIdentity()) {
controller.value = Matrix4.identity();
}
}
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -72,6 +123,8 @@ class ImageViewerController extends State<ImageViewer> {
} }
Future<void> prevImage() async { Future<void> prevImage() async {
if (!canGoBack) return;
resetZooms();
await pageController.previousPage( await pageController.previousPage(
duration: FluffyThemes.animationDuration, duration: FluffyThemes.animationDuration,
curve: FluffyThemes.animationCurve, curve: FluffyThemes.animationCurve,
@@ -81,6 +134,8 @@ class ImageViewerController extends State<ImageViewer> {
} }
Future<void> nextImage() async { Future<void> nextImage() async {
if (!canGoNext) return;
resetZooms();
await pageController.nextPage( await pageController.nextPage(
duration: FluffyThemes.animationDuration, duration: FluffyThemes.animationDuration,
curve: FluffyThemes.animationCurve, curve: FluffyThemes.animationCurve,
@@ -112,23 +167,47 @@ class ImageViewerController extends State<ImageViewer> {
static const maxScaleFactor = 1.5; static const maxScaleFactor = 1.5;
/// Closes the viewer.
///
/// Always targets the root navigator, because the viewer is opened with
/// `showDialog` on the root navigator while the chat room lives in a nested
/// navigator of the go_router shell. Using a relative navigator here would
/// either pop nothing or pop the wrong route.
void closeAction() => Navigator.of(context, rootNavigator: true).pop();
/// Go back if user swiped it away /// Go back if user swiped it away
void onInteractionEnds(ScaleEndDetails endDetails) { void onInteractionEnds(ScaleEndDetails endDetails) {
if (PlatformInfos.usesTouchscreen == false) { if (PlatformInfos.usesTouchscreen == false) {
if (isZoomed) return;
if (endDetails.velocity.pixelsPerSecond.dy > if (endDetails.velocity.pixelsPerSecond.dy >
MediaQuery.sizeOf(context).height * maxScaleFactor) { MediaQuery.sizeOf(context).height * maxScaleFactor) {
Navigator.of(context, rootNavigator: false).pop(); closeAction();
} }
} }
} }
@override @override
void dispose() { void dispose() {
for (final controller in _transformations.values) {
controller.removeListener(_onTransformChanged);
controller.dispose();
}
_transformations.clear();
focusNode.dispose(); focusNode.dispose();
pageController.dispose(); pageController.dispose();
super.dispose(); super.dispose();
} }
@override @override
Widget build(BuildContext context) => ImageViewerView(this); Widget build(BuildContext context) => PopScope<Object?>(
// Claim the system back button (and predictive back gesture) for the
// viewer. Without this, the request can reach go_router instead, which
// pops the chat room out of the shell and leaves the image on screen.
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (didPop) return;
closeAction();
},
child: ImageViewerView(this),
);
} }

View File

@@ -25,7 +25,7 @@ class ImageViewerView extends StatelessWidget {
foregroundColor: Colors.white, foregroundColor: Colors.white,
); );
return GestureDetector( return GestureDetector(
onTap: () => Navigator.of(context).pop(), onTap: controller.closeAction,
child: Scaffold( child: Scaffold(
backgroundColor: Colors.black.withAlpha(128), backgroundColor: Colors.black.withAlpha(128),
extendBodyBehindAppBar: true, extendBodyBehindAppBar: true,
@@ -34,7 +34,7 @@ class ImageViewerView extends StatelessWidget {
leading: IconButton( leading: IconButton(
style: iconButtonStyle, style: iconButtonStyle,
icon: const Icon(Icons.close), icon: const Icon(Icons.close),
onPressed: Navigator.of(context).pop, onPressed: controller.closeAction,
color: Colors.white, color: Colors.white,
tooltip: L10n.of(context).close, tooltip: L10n.of(context).close,
), ),
@@ -78,50 +78,60 @@ class ImageViewerView extends StatelessWidget {
KeyboardListener( KeyboardListener(
focusNode: controller.focusNode, focusNode: controller.focusNode,
onKeyEvent: controller.onKeyEvent, onKeyEvent: controller.onKeyEvent,
child: PageView.builder( child: Listener(
scrollDirection: Axis.vertical, onPointerDown: (_) => controller.onPointerDown(),
controller: controller.pageController, onPointerUp: (_) => controller.onPointerUp(),
itemCount: controller.allEvents.length, onPointerCancel: (_) => controller.onPointerUp(),
itemBuilder: (context, i) { child: PageView.builder(
final event = controller.allEvents[i]; scrollDirection: Axis.vertical,
switch (event.messageType) { controller: controller.pageController,
case MessageTypes.Video: physics: controller.canSwipeToNextImage
return Padding( ? const PageScrollPhysics()
padding: const EdgeInsets.only(top: 52.0), : const NeverScrollableScrollPhysics(),
child: Center( itemCount: controller.allEvents.length,
child: GestureDetector( itemBuilder: (context, i) {
// Ignore taps to not go back here: final event = controller.allEvents[i];
onTap: () {}, switch (event.messageType) {
child: EventVideoPlayer(event), case MessageTypes.Video:
), return Padding(
), padding: const EdgeInsets.only(top: 52.0),
); child: Center(
case MessageTypes.Image:
case MessageTypes.Sticker:
default:
return InteractiveViewer(
minScale: 1.0,
maxScale: 10.0,
onInteractionEnd: controller.onInteractionEnds,
child: Center(
child: Hero(
tag: event.eventId,
child: GestureDetector( child: GestureDetector(
// Ignore taps to not go back here: // Ignore taps to not go back here:
onTap: () {}, onTap: () {},
child: MxcImage( child: EventVideoPlayer(event),
key: ValueKey(event.eventId), ),
event: event, ),
fit: BoxFit.contain, );
isThumbnail: false, case MessageTypes.Image:
animated: true, case MessageTypes.Sticker:
default:
return InteractiveViewer(
transformationController: controller
.transformationControllerFor(event),
minScale: 1.0,
maxScale: 10.0,
onInteractionEnd: controller.onInteractionEnds,
child: Center(
child: Hero(
tag: event.eventId,
child: GestureDetector(
// Ignore taps to not go back here:
onTap: () {},
child: MxcImage(
key: ValueKey(event.eventId),
event: event,
fit: BoxFit.contain,
isThumbnail: false,
animated: true,
),
), ),
), ),
), ),
), );
); }
} },
}, ),
), ),
), ),
if (hovered) if (hovered)

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,16 +113,30 @@ class SettingsStyleController extends State<SettingsStyle> {
}); });
} }
void deleteChatWallpaper() => showFutureLoadingDialog( void deleteChatWallpaper() {
context: context, final client = Matrix.of(context).client;
future: () => Matrix.of(context).client.setApplicationAccountConfig( showFutureLoadingDialog(
const ApplicationAccountConfig(wallpaperUrl: null, wallpaperBlur: null), context: context,
), future: () => client.setApplicationAccountConfig(
); 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;
bool get softenThemeColor => ThemeController.of(context).softenPrimaryColor;
Future<void> setSoftenThemeColor(bool soften) async {
await ThemeController.of(context).setSoftenPrimaryColor(soften);
setState(() {});
}
void switchTheme(ThemeMode? newTheme) { void switchTheme(ThemeMode? newTheme) {
if (newTheme == null) return; if (newTheme == null) return;
switch (newTheme) { 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), Divider(color: theme.dividerColor),
ListTile( ListTile(
title: Text( title: Text(
@@ -176,24 +183,38 @@ class SettingsStyleView extends StatelessWidget {
child: Stack( child: Stack(
alignment: Alignment.center, alignment: Alignment.center,
children: [ children: [
if (accountConfig.wallpaperUrl != null) Opacity(
Opacity( opacity: accountConfig.wallpaperUrl != null
opacity: controller.wallpaperOpacity, ? controller.wallpaperOpacity
child: ImageFiltered( : 0.3,
imageFilter: ImageFilter.blur( child: ImageFiltered(
sigmaX: controller.wallpaperBlur, imageFilter: ImageFilter.blur(
sigmaY: controller.wallpaperBlur, sigmaX: accountConfig.wallpaperUrl != null
), ? controller.wallpaperBlur
child: MxcImage( : 0.0,
key: ValueKey(accountConfig.wallpaperUrl), sigmaY: accountConfig.wallpaperUrl != null
uri: accountConfig.wallpaperUrl, ? controller.wallpaperBlur
fit: BoxFit.cover, : 0.0,
isThumbnail: true,
width: FluffyThemes.columnWidth * 2,
height: 212,
),
), ),
child: accountConfig.wallpaperUrl != null
? MxcImage(
key: ValueKey(
accountConfig.wallpaperUrl,
),
uri: accountConfig.wallpaperUrl,
fit: BoxFit.cover,
isThumbnail: true,
width: FluffyThemes.columnWidth * 2,
height: 212,
)
: Image.asset(
'assets/logo/background.png',
fit: BoxFit.cover,
width: FluffyThemes.columnWidth * 2,
height: 212,
),
), ),
),
Column( Column(
mainAxisSize: .min, mainAxisSize: .min,
children: [ children: [
@@ -213,12 +234,10 @@ class SettingsStyleView extends StatelessWidget {
), ),
), ),
Padding( Padding(
padding: EdgeInsets.only( padding: const EdgeInsets.only(
left: 12 + 12 + Avatar.defaultSize, left: 12 + 12 + Avatar.defaultSize,
right: 12, right: 12,
top: accountConfig.wallpaperUrl == null top: 12,
? 0
: 12,
bottom: 12, bottom: 12,
), ),
child: DecoratedBox( child: DecoratedBox(
@@ -246,12 +265,10 @@ class SettingsStyleView extends StatelessWidget {
Align( Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: Padding( child: Padding(
padding: EdgeInsets.only( padding: const EdgeInsets.only(
right: 12, right: 12,
left: 12, left: 12,
top: accountConfig.wallpaperUrl == null top: 12,
? 0
: 12,
bottom: 12, bottom: 12,
), ),
child: Material( child: Material(

View File

@@ -27,20 +27,53 @@ 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,
softenThemeColor:
config.softenThemeColor ?? currentConfig.softenThemeColor,
).toJson(), ).toJson(),
); );
} }
Future<void> setApplicationAccountColor(int? primaryColor) {
final currentConfig = applicationAccountConfig;
return setApplicationAccountConfig(
ApplicationAccountConfig(
wallpaperUrl: currentConfig.wallpaperUrl,
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,
),
);
}
} }
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;
final bool? softenThemeColor;
const ApplicationAccountConfig({ const ApplicationAccountConfig({
this.wallpaperUrl, this.wallpaperUrl,
this.wallpaperOpacity, this.wallpaperOpacity,
this.wallpaperBlur, this.wallpaperBlur,
this.primaryColor,
this.softenThemeColor,
}); });
static double _sanitizedOpacity(double? opacity) { static double _sanitizedOpacity(double? opacity) {
@@ -58,11 +91,15 @@ 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'),
softenThemeColor: json.tryGet<bool>('soften_theme_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,
'soften_theme_color': softenThemeColor,
}; };
} }

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

@@ -13,47 +13,61 @@ class MxcImageViewer extends StatelessWidget {
const MxcImageViewer(this.mxContent, {super.key}); const MxcImageViewer(this.mxContent, {super.key});
/// The viewer is opened with `showDialog` on the root navigator, so closing
/// it must always target the root navigator.
static void _close(BuildContext context) =>
Navigator.of(context, rootNavigator: true).pop();
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final iconButtonStyle = IconButton.styleFrom( final iconButtonStyle = IconButton.styleFrom(
backgroundColor: Colors.black.withAlpha(200), backgroundColor: Colors.black.withAlpha(200),
foregroundColor: Colors.white, foregroundColor: Colors.white,
); );
return GestureDetector( return PopScope<Object?>(
onTap: () => Navigator.of(context).pop(), // Claim the system back button for the viewer so that it is dismissed
child: Scaffold( // instead of letting the router navigate away underneath it.
backgroundColor: Colors.black.withAlpha(128), canPop: false,
extendBodyBehindAppBar: true, onPopInvokedWithResult: (didPop, _) {
appBar: AppBar( if (didPop) return;
elevation: 0, _close(context);
leading: IconButton( },
style: iconButtonStyle, child: GestureDetector(
icon: const Icon(Icons.close), onTap: () => _close(context),
onPressed: Navigator.of(context).pop, child: Scaffold(
color: Colors.white, backgroundColor: Colors.black.withAlpha(128),
tooltip: L10n.of(context).close, extendBodyBehindAppBar: true,
appBar: AppBar(
elevation: 0,
leading: IconButton(
style: iconButtonStyle,
icon: const Icon(Icons.close),
onPressed: () => _close(context),
color: Colors.white,
tooltip: L10n.of(context).close,
),
backgroundColor: Colors.transparent,
), ),
backgroundColor: Colors.transparent, body: InteractiveViewer(
), minScale: 1.0,
body: InteractiveViewer( maxScale: 10.0,
minScale: 1.0, onInteractionEnd: (endDetails) {
maxScale: 10.0, if (endDetails.velocity.pixelsPerSecond.dy >
onInteractionEnd: (endDetails) { MediaQuery.sizeOf(context).height * 1.5) {
if (endDetails.velocity.pixelsPerSecond.dy > _close(context);
MediaQuery.sizeOf(context).height * 1.5) { }
Navigator.of(context, rootNavigator: false).pop(); },
} child: Center(
}, child: GestureDetector(
child: Center( // Ignore taps to not go back here:
child: GestureDetector( onTap: () {},
// Ignore taps to not go back here: child: MxcImage(
onTap: () {}, key: ValueKey(mxContent.toString()),
child: MxcImage( uri: mxContent,
key: ValueKey(mxContent.toString()), fit: BoxFit.contain,
uri: mxContent, isThumbnail: false,
fit: BoxFit.contain, animated: true,
isThumbnail: false, ),
animated: true,
), ),
), ),
), ),

View File

@@ -65,38 +65,48 @@ 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) =>
title: AppSettings.applicationName.value, MaterialApp.router(
themeMode: themeMode, title: AppSettings.applicationName.value,
theme: FluffyThemes.buildTheme(context, Brightness.light, primaryColor), themeMode: themeMode,
darkTheme: FluffyThemes.buildTheme( theme: FluffyThemes.buildTheme(
context, context,
Brightness.dark, Brightness.light,
primaryColor, primaryColor,
), themeController.softenPrimaryColor,
scrollBehavior: CustomScrollBehavior(), ),
localizationsDelegates: [ darkTheme: FluffyThemes.buildTheme(
...L10n.localizationsDelegates, context,
...GlobalMaterialLocalizations.delegates, Brightness.dark,
...GlobalCupertinoLocalizations.delegates, primaryColor,
], themeController.softenPrimaryColor,
supportedLocales: L10n.supportedLocales, ),
routerConfig: router, scrollBehavior: CustomScrollBehavior(),
builder: (context, child) => AppLockWidget( localizationsDelegates: [
pincode: appLockSettings.pincode, ...L10n.localizationsDelegates,
useBiometrics: appLockSettings.useBiometrics, ...GlobalMaterialLocalizations.delegates,
isLoggedIn: clients.any((client) => client.isLogged()), ...GlobalCupertinoLocalizations.delegates,
// Need a navigator above the Matrix widget for ],
// displaying dialogs supportedLocales: L10n.supportedLocales,
child: Matrix( routerConfig: router,
clients: clients, builder: (context, child) => AppLockWidget(
store: store, pincode: appLockSettings.pincode,
child: CallOverlay( useBiometrics: appLockSettings.useBiometrics,
child: testWidget ?? child ?? const SizedBox.shrink(), 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(),
),
),
), ),
), ),
),
),
); );
} }
} }

View File

@@ -3,10 +3,15 @@
// //
// 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/config/setting_keys.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 +20,7 @@ class ThemeBuilder extends StatefulWidget {
BuildContext context, BuildContext context,
ThemeMode themeMode, ThemeMode themeMode,
Color? primaryColor, Color? primaryColor,
ThemeController controller,
) )
builder; builder;
@@ -36,14 +42,42 @@ class ThemeController extends State<ThemeBuilder> {
SharedPreferences? _sharedPreferences; SharedPreferences? _sharedPreferences;
ThemeMode? _themeMode; ThemeMode? _themeMode;
Color? _primaryColor; Color? _primaryColor;
Color? _localPrimaryColor;
Color? _accountPrimaryColor;
bool _softenPrimaryColor = false;
bool _localSoftenPrimaryColor = false;
bool? _accountSoftenPrimaryColor;
Client? _accountClient;
StreamSubscription<SyncUpdate>? _accountClientSubscription;
int _accountClientGeneration = 0;
ThemeMode get themeMode => _themeMode ?? ThemeMode.system; ThemeMode get themeMode => _themeMode ?? ThemeMode.system;
Color? get primaryColor => _primaryColor; Color? get primaryColor => _primaryColor;
bool get softenPrimaryColor => _softenPrimaryColor;
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;
});
}
void _updateSoftenPrimaryColor() {
if (!mounted) return;
final newValue = _accountSoftenPrimaryColor ?? _localSoftenPrimaryColor;
if (newValue == _softenPrimaryColor) return;
setState(() {
_softenPrimaryColor = newValue;
});
}
Future<void> _loadData(_) async { Future<void> _loadData(_) async {
final preferences = _sharedPreferences ??= final preferences = _sharedPreferences ??=
await SharedPreferences.getInstance(); await SharedPreferences.getInstance();
@@ -51,12 +85,16 @@ 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);
_localSoftenPrimaryColor = AppSettings.softenThemeColor.value;
}); });
_updatePrimaryColor();
_updateSoftenPrimaryColor();
} }
Future<void> setThemeMode(ThemeMode newThemeMode) async { Future<void> setThemeMode(ThemeMode newThemeMode) async {
@@ -69,6 +107,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 +123,78 @@ class ThemeController extends State<ThemeBuilder> {
newPrimaryColor.hexValue, newPrimaryColor.hexValue,
); );
} }
}
Future<void> setSoftenPrimaryColor(bool soften) async {
await AppSettings.softenThemeColor.setItem(soften);
if (!mounted) return;
setState(() { setState(() {
_primaryColor = newPrimaryColor; _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;
_accountClientSubscription?.cancel();
_accountClientSubscription = null;
_accountClient = client;
_accountPrimaryColor = null;
_accountSoftenPrimaryColor = null;
_updatePrimaryColor();
_updateSoftenPrimaryColor();
if (client == null) return;
unawaited(_loadAccountClientConfig(client, generation));
_accountClientSubscription = client.onSync.stream
.where(
(sync) =>
sync.accountData?.any(
(accountData) =>
accountData.type ==
ApplicationAccountConfigExtension.accountDataKey,
) ??
false,
)
.listen((_) => _applyAccountClientConfig(client, generation));
}
Future<void> _loadAccountClientConfig(Client client, int generation) async {
final accountDataLoading = client.accountDataLoading;
if (accountDataLoading != null) {
try {
await accountDataLoading;
} catch (_) {}
}
if (!mounted ||
generation != _accountClientGeneration ||
!identical(_accountClient, client)) {
return;
}
_applyAccountClientConfig(client, generation);
}
void _applyAccountClientConfig(Client client, int generation) {
if (!mounted ||
generation != _accountClientGeneration ||
!identical(_accountClient, client)) {
return;
}
final config = client.applicationAccountConfig;
final primaryColor = config.primaryColor;
_accountPrimaryColor = primaryColor == null ? null : Color(primaryColor);
_accountSoftenPrimaryColor = config.softenThemeColor;
_updatePrimaryColor();
_updateSoftenPrimaryColor();
} }
@override @override
@@ -95,9 +208,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

@@ -118,6 +118,7 @@ flutter:
uses-material-design: true uses-material-design: true
assets: assets:
- assets/ - assets/
- assets/logo/background.png
- assets/logo/mini/ - assets/logo/mini/
- assets/miniapps/ - assets/miniapps/
- assets/sounds/ - assets/sounds/

View File

@@ -0,0 +1,114 @@
// 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,
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', () {
final config = ApplicationAccountConfig.fromJson({
'primary_color': 0xFF6750A4,
});
expect(config.wallpaperUrl, isNull);
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);
});
});
}