From 1d92e07c4751f033ca388e3d7891bb344025f184 Mon Sep 17 00:00:00 2001 From: krille-chan Date: Thu, 20 Nov 2025 18:13:06 +0100 Subject: [PATCH 01/13] refactor: Improved design and UX for sticker editor --- lib/l10n/intl_en.arb | 5 +- .../settings_emotes/settings_emotes.dart | 74 ++--- .../settings_emotes/settings_emotes_view.dart | 257 +++++++++--------- pubspec.lock | 16 +- 4 files changed, 180 insertions(+), 172 deletions(-) diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 2a56f0379..0c8f45c98 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -3452,5 +3452,8 @@ } }, "thread": "Thread", - "backToMainChat": "Back to main chat" + "backToMainChat": "Back to main chat", + "saveChanges": "Save changes", + "add": "Add", + "newSticker": "New sticker" } diff --git a/lib/pages/settings_emotes/settings_emotes.dart b/lib/pages/settings_emotes/settings_emotes.dart index 56a96ffc8..f3afd598d 100644 --- a/lib/pages/settings_emotes/settings_emotes.dart +++ b/lib/pages/settings_emotes/settings_emotes.dart @@ -38,6 +38,7 @@ class EmotesSettingsController extends State { bool showSave = false; TextEditingController newImageCodeController = TextEditingController(); + ValueNotifier newImageController = ValueNotifier(null); @@ -69,25 +70,25 @@ class EmotesSettingsController extends State { return; } final client = Matrix.of(context).client; - if (room != null) { - await showFutureLoadingDialog( - context: context, - future: () => client.setRoomStateWithKey( - room!.id, - 'im.ponies.room_emotes', - stateKey ?? '', - pack!.toJson(), - ), - ); - } else { - await showFutureLoadingDialog( - context: context, - future: () => client.setAccountData( - client.userID!, - 'im.ponies.user_emotes', - pack!.toJson(), - ), - ); + final result = await showFutureLoadingDialog( + context: context, + future: () => room != null + ? client.setRoomStateWithKey( + room!.id, + 'im.ponies.room_emotes', + stateKey ?? '', + pack!.toJson(), + ) + : client.setAccountData( + client.userID!, + 'im.ponies.user_emotes', + pack!.toJson(), + ), + ); + if (!result.isError) { + setState(() { + showSave = false; + }); } } @@ -172,6 +173,13 @@ class EmotesSettingsController extends State { bool get readonly => room == null ? false : !(room!.canSendEvent('im.ponies.room_emotes')); + void resetAction() { + setState(() { + _pack = _getPack(); + showSave = false; + }); + } + void saveAction() async { await save(context); setState(() { @@ -227,24 +235,25 @@ class EmotesSettingsController extends State { ); final pickedFile = result.firstOrNull; if (pickedFile == null) return; + var file = MatrixImageFile( bytes: await pickedFile.readAsBytes(), name: pickedFile.name, ); - try { - file = (await file.generateThumbnail( - nativeImplementations: ClientManager.nativeImplementations, - ))!; - } catch (e, s) { - Logs().w('Unable to create thumbnail', e, s); - } + final uploadResp = await showFutureLoadingDialog( context: context, - future: () => Matrix.of(context).client.uploadContent( - file.bytes, - filename: file.name, - contentType: file.mimeType, - ), + future: () async { + file = await file.generateThumbnail( + nativeImplementations: ClientManager.nativeImplementations, + ) ?? + file; + return Matrix.of(context).client.uploadContent( + file.bytes, + filename: file.name, + contentType: file.mimeType, + ); + }, ); if (uploadResp.error == null) { setState(() { @@ -266,6 +275,9 @@ class EmotesSettingsController extends State { 'url': uploadResp.result.toString(), 'info': info, }); + if (newImageCodeController.text.isEmpty) { + newImageCodeController.text = pickedFile.name.split('.').first; + } }); } } diff --git a/lib/pages/settings_emotes/settings_emotes_view.dart b/lib/pages/settings_emotes/settings_emotes_view.dart index 3134cba8d..0163aa32a 100644 --- a/lib/pages/settings_emotes/settings_emotes_view.dart +++ b/lib/pages/settings_emotes/settings_emotes_view.dart @@ -25,91 +25,92 @@ class EmotesSettingsView extends StatelessWidget { final imageKeys = controller.pack!.images.keys.toList(); return Scaffold( appBar: AppBar( - leading: const Center(child: BackButton()), - title: Text(L10n.of(context).customEmojisAndStickers), + automaticallyImplyLeading: !controller.showSave, + title: controller.showSave + ? TextButton( + onPressed: controller.resetAction, + child: Text(L10n.of(context).cancel), + ) + : Text(L10n.of(context).customEmojisAndStickers), actions: [ - PopupMenuButton( - useRootNavigator: true, - onSelected: (value) { - switch (value) { - case PopupMenuEmojiActions.export: - controller.exportAsZip(); - break; - case PopupMenuEmojiActions.import: - controller.importEmojiZip(); - break; - } - }, - enabled: !controller.readonly, - itemBuilder: (context) => [ - PopupMenuItem( - value: PopupMenuEmojiActions.import, - child: Text(L10n.of(context).importFromZipFile), + if (controller.showSave) + ElevatedButton( + onPressed: () => controller.save(context), + style: ElevatedButton.styleFrom( + backgroundColor: theme.colorScheme.primary, + foregroundColor: theme.colorScheme.onPrimary, ), - PopupMenuItem( - value: PopupMenuEmojiActions.export, - child: Text(L10n.of(context).exportEmotePack), - ), - ], - ), + child: Text(L10n.of(context).saveChanges), + ) + else + PopupMenuButton( + useRootNavigator: true, + onSelected: (value) { + switch (value) { + case PopupMenuEmojiActions.export: + controller.exportAsZip(); + break; + case PopupMenuEmojiActions.import: + controller.importEmojiZip(); + break; + } + }, + enabled: !controller.readonly, + itemBuilder: (context) => [ + PopupMenuItem( + value: PopupMenuEmojiActions.import, + child: Text(L10n.of(context).importFromZipFile), + ), + PopupMenuItem( + value: PopupMenuEmojiActions.export, + child: Text(L10n.of(context).exportEmotePack), + ), + ], + ), ], ), - floatingActionButton: controller.showSave - ? FloatingActionButton( - onPressed: controller.saveAction, - child: const Icon(Icons.save_outlined, color: Colors.white), - ) - : null, body: MaxWidthBody( child: Column( mainAxisSize: MainAxisSize.min, children: [ if (!controller.readonly) - Container( + Padding( padding: const EdgeInsets.symmetric( vertical: 8.0, ), child: ListTile( - leading: Container( - width: 180.0, - height: 38, - padding: const EdgeInsets.symmetric(horizontal: 8), - decoration: BoxDecoration( - borderRadius: const BorderRadius.all(Radius.circular(10)), - color: theme.secondaryHeaderColor, - ), - child: TextField( - controller: controller.newImageCodeController, - autocorrect: false, - minLines: 1, - maxLines: 1, - decoration: InputDecoration( - hintText: L10n.of(context).emoteShortcode, - prefixText: ': ', - suffixText: ':', - prefixStyle: TextStyle( - color: theme.colorScheme.secondary, - fontWeight: FontWeight.bold, - ), - suffixStyle: TextStyle( - color: theme.colorScheme.secondary, - fontWeight: FontWeight.bold, - ), - border: InputBorder.none, + title: TextField( + controller: controller.newImageCodeController, + autocorrect: false, + minLines: 1, + maxLines: 1, + readOnly: controller.showSave, + decoration: InputDecoration( + hintText: L10n.of(context).newSticker, + prefixText: ': ', + suffixText: ':', + prefixStyle: TextStyle( + color: theme.colorScheme.secondary, + fontWeight: FontWeight.bold, ), + suffixStyle: TextStyle( + color: theme.colorScheme.secondary, + fontWeight: FontWeight.bold, + ), + border: InputBorder.none, ), ), - title: _ImagePicker( + leading: _ImagePicker( + readOnly: controller.showSave, controller: controller.newImageController, onPressed: controller.imagePickerAction, ), - trailing: InkWell( - onTap: controller.addImageAction, - child: const Icon( - Icons.add_outlined, - color: Colors.green, - size: 32.0, - ), + trailing: TextButton( + onPressed: controller.showSave || + controller.newImageController.value == null + ? null + : controller.addImageAction, + child: Text(L10n.of(context).add), ), ), ), @@ -148,80 +149,65 @@ class EmotesSettingsView extends StatelessWidget { final useShortCuts = (PlatformInfos.isWeb || PlatformInfos.isDesktop); return ListTile( - leading: Container( - width: 180.0, - height: 38, - padding: const EdgeInsets.symmetric(horizontal: 8), - decoration: BoxDecoration( - borderRadius: - const BorderRadius.all(Radius.circular(10)), - color: theme.secondaryHeaderColor, - ), - child: Shortcuts( - shortcuts: !useShortCuts + title: Shortcuts( + shortcuts: !useShortCuts + ? {} + : { + LogicalKeySet(LogicalKeyboardKey.enter): + SubmitLineIntent(), + }, + child: Actions( + actions: !useShortCuts ? {} : { - LogicalKeySet(LogicalKeyboardKey.enter): - SubmitLineIntent(), + SubmitLineIntent: CallbackAction( + onInvoke: (i) { + controller.submitImageAction( + imageCode, + textEditingController.text, + image, + textEditingController, + ); + return null; + }, + ), }, - child: Actions( - actions: !useShortCuts - ? {} - : { - SubmitLineIntent: CallbackAction( - onInvoke: (i) { - controller.submitImageAction( - imageCode, - textEditingController.text, - image, - textEditingController, - ); - return null; - }, - ), - }, - child: TextField( - readOnly: controller.readonly, - controller: textEditingController, - autocorrect: false, - minLines: 1, - maxLines: 1, - decoration: InputDecoration( - hintText: L10n.of(context).emoteShortcode, - prefixText: ': ', - suffixText: ':', - prefixStyle: TextStyle( - color: theme.colorScheme.secondary, - fontWeight: FontWeight.bold, + child: TextField( + readOnly: controller.readonly, + controller: textEditingController, + autocorrect: false, + minLines: 1, + maxLines: 1, + maxLength: 128, + decoration: InputDecoration( + hintText: L10n.of(context).emoteShortcode, + prefixText: ': ', + suffixText: ':', + counter: const SizedBox.shrink(), + filled: false, + enabledBorder: const OutlineInputBorder( + borderSide: BorderSide( + color: Colors.transparent, ), - suffixStyle: TextStyle( - color: theme.colorScheme.secondary, - fontWeight: FontWeight.bold, - ), - border: InputBorder.none, - ), - onSubmitted: (s) => - controller.submitImageAction( - imageCode, - s, - image, - textEditingController, ), ), + onSubmitted: (s) => controller.submitImageAction( + imageCode, + s, + image, + textEditingController, + ), ), ), ), - title: _EmoteImage(image.url), + leading: _EmoteImage(image.url), trailing: controller.readonly ? null - : InkWell( - onTap: () => + : IconButton( + tooltip: L10n.of(context).delete, + onPressed: () => controller.removeImageAction(imageCode), - child: const Icon( - Icons.delete_outlined, - color: Colors.red, - size: 32.0, - ), + icon: const Icon(Icons.delete_outlined), ), ); }, @@ -256,10 +242,15 @@ class _EmoteImage extends StatelessWidget { class _ImagePicker extends StatefulWidget { final ValueNotifier controller; + final bool readOnly; final void Function(ValueNotifier) onPressed; - const _ImagePicker({required this.controller, required this.onPressed}); + const _ImagePicker({ + required this.controller, + this.readOnly = false, + required this.onPressed, + }); @override _ImagePickerState createState() => _ImagePickerState(); @@ -269,9 +260,11 @@ class _ImagePickerState extends State<_ImagePicker> { @override Widget build(BuildContext context) { if (widget.controller.value == null) { - return ElevatedButton( - onPressed: () => widget.onPressed(widget.controller), - child: Text(L10n.of(context).pickImage), + return IconButton( + tooltip: L10n.of(context).select, + onPressed: + widget.readOnly ? null : () => widget.onPressed(widget.controller), + icon: const Icon(Icons.upload_outlined), ); } else { return _EmoteImage(widget.controller.value!.url); diff --git a/pubspec.lock b/pubspec.lock index 965667782..b55ddbb7b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1096,10 +1096,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.16.0" mgrs_dart: dependency: transitive description: @@ -1821,26 +1821,26 @@ packages: dependency: transitive description: name: test - sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" + sha256: "65e29d831719be0591f7b3b1a32a3cda258ec98c58c7b25f7b84241bc31215bb" url: "https://pub.dev" source: hosted - version: "1.26.3" + version: "1.26.2" test_api: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.6" test_core: dependency: transitive description: name: test_core - sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" + sha256: "80bf5a02b60af04b09e14f6fe68b921aad119493e26e490deaca5993fef1b05a" url: "https://pub.dev" source: hosted - version: "0.6.12" + version: "0.6.11" timezone: dependency: transitive description: From 5a3703ff2d989d64f9538237cf59baba74a2f7c6 Mon Sep 17 00:00:00 2001 From: krille-chan Date: Thu, 20 Nov 2025 18:34:14 +0100 Subject: [PATCH 02/13] feat: Upload multiple stickers at once --- lib/l10n/intl_en.arb | 3 +- .../settings_emotes/settings_emotes.dart | 142 +++++++----------- .../settings_emotes/settings_emotes_view.dart | 77 +--------- 3 files changed, 58 insertions(+), 164 deletions(-) diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 0c8f45c98..55f108571 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -3454,6 +3454,5 @@ "thread": "Thread", "backToMainChat": "Back to main chat", "saveChanges": "Save changes", - "add": "Add", - "newSticker": "New sticker" + "createSticker": "Create sticker or emoji" } diff --git a/lib/pages/settings_emotes/settings_emotes.dart b/lib/pages/settings_emotes/settings_emotes.dart index f3afd598d..173c6b310 100644 --- a/lib/pages/settings_emotes/settings_emotes.dart +++ b/lib/pages/settings_emotes/settings_emotes.dart @@ -3,7 +3,6 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:collection/collection.dart'; import 'package:go_router/go_router.dart'; import 'package:http/http.dart' hide Client; import 'package:matrix/matrix.dart'; @@ -37,10 +36,6 @@ class EmotesSettingsController extends State { String? get stateKey => GoRouterState.of(context).pathParameters['state_key']; bool showSave = false; - TextEditingController newImageCodeController = TextEditingController(); - - ValueNotifier newImageController = - ValueNotifier(null); ImagePackContent _getPack() { final client = Matrix.of(context).client; @@ -135,6 +130,7 @@ class EmotesSettingsController extends State { ImagePackImageContent image, TextEditingController controller, ) { + controller.text = controller.text.trim().replaceAll(' ', '-'); if (pack!.images.keys.any((k) => k == imageCode && k != oldImageCode)) { controller.text = oldImageCode; showOkAlertDialog( @@ -187,99 +183,63 @@ class EmotesSettingsController extends State { }); } - void addImageAction() async { - if (newImageCodeController.text.isEmpty || - newImageController.value == null) { - await showOkAlertDialog( - useRootNavigator: false, - context: context, - title: L10n.of(context).emoteWarnNeedToPick, - okLabel: L10n.of(context).ok, - ); - return; - } - final imageCode = newImageCodeController.text; - if (pack!.images.containsKey(imageCode)) { - await showOkAlertDialog( - useRootNavigator: false, - context: context, - title: L10n.of(context).emoteExists, - okLabel: L10n.of(context).ok, - ); - return; - } - if (!RegExp(r'^[-\w]+$').hasMatch(imageCode)) { - await showOkAlertDialog( - useRootNavigator: false, - context: context, - title: L10n.of(context).emoteInvalid, - okLabel: L10n.of(context).ok, - ); - return; - } - pack!.images[imageCode] = newImageController.value!; - await save(context); - setState(() { - newImageCodeController.text = ''; - newImageController.value = null; - showSave = false; - }); - } - - void imagePickerAction( - ValueNotifier controller, - ) async { - final result = await selectFiles( + void createStickers() async { + final pickedFiles = await selectFiles( context, type: FileSelectorType.images, + allowMultiple: true, ); - final pickedFile = result.firstOrNull; - if (pickedFile == null) return; + if (pickedFiles.isEmpty) return; + if (!mounted) return; - var file = MatrixImageFile( - bytes: await pickedFile.readAsBytes(), - name: pickedFile.name, - ); - - final uploadResp = await showFutureLoadingDialog( + await showFutureLoadingDialog( context: context, - future: () async { - file = await file.generateThumbnail( - nativeImplementations: ClientManager.nativeImplementations, - ) ?? - file; - return Matrix.of(context).client.uploadContent( - file.bytes, - filename: file.name, - contentType: file.mimeType, - ); + futureWithProgress: (setProgress) async { + for (final (i, pickedFile) in pickedFiles.indexed) { + setProgress(i / pickedFiles.length); + var file = MatrixImageFile( + bytes: await pickedFile.readAsBytes(), + name: pickedFile.name, + ); + file = await file.generateThumbnail( + nativeImplementations: ClientManager.nativeImplementations, + ) ?? + file; + final uri = await Matrix.of(context).client.uploadContent( + file.bytes, + filename: file.name, + contentType: file.mimeType, + ); + + setState(() { + final info = { + ...file.info, + }; + // normalize width / height to 256, required for stickers + if (info['w'] is int && info['h'] is int) { + final ratio = info['w'] / info['h']; + if (info['w'] > info['h']) { + info['w'] = 256; + info['h'] = (256.0 / ratio).round(); + } else { + info['h'] = 256; + info['w'] = (ratio * 256.0).round(); + } + } + final imageCode = pickedFile.name.split('.').first; + pack!.images[imageCode] = + ImagePackImageContent.fromJson({ + 'url': uri.toString(), + 'info': info, + }); + }); + } }, ); - if (uploadResp.error == null) { - setState(() { - final info = { - ...file.info, - }; - // normalize width / height to 256, required for stickers - if (info['w'] is int && info['h'] is int) { - final ratio = info['w'] / info['h']; - if (info['w'] > info['h']) { - info['w'] = 256; - info['h'] = (256.0 / ratio).round(); - } else { - info['h'] = 256; - info['w'] = (ratio * 256.0).round(); - } - } - controller.value = ImagePackImageContent.fromJson({ - 'url': uploadResp.result.toString(), - 'info': info, - }); - if (newImageCodeController.text.isEmpty) { - newImageCodeController.text = pickedFile.name.split('.').first; - } - }); - } + + setState(() { + showSave = true; + }); } @override diff --git a/lib/pages/settings_emotes/settings_emotes_view.dart b/lib/pages/settings_emotes/settings_emotes_view.dart index 0163aa32a..238ac4cea 100644 --- a/lib/pages/settings_emotes/settings_emotes_view.dart +++ b/lib/pages/settings_emotes/settings_emotes_view.dart @@ -1,8 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:matrix/matrix.dart'; - import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/utils/platform_infos.dart'; import 'package:fluffychat/widgets/layouts/max_width_body.dart'; @@ -72,46 +70,15 @@ class EmotesSettingsView extends StatelessWidget { body: MaxWidthBody( child: Column( mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (!controller.readonly) Padding( - padding: const EdgeInsets.symmetric( - vertical: 8.0, - ), - child: ListTile( - title: TextField( - controller: controller.newImageCodeController, - autocorrect: false, - minLines: 1, - maxLines: 1, - readOnly: controller.showSave, - decoration: InputDecoration( - hintText: L10n.of(context).newSticker, - prefixText: ': ', - suffixText: ':', - prefixStyle: TextStyle( - color: theme.colorScheme.secondary, - fontWeight: FontWeight.bold, - ), - suffixStyle: TextStyle( - color: theme.colorScheme.secondary, - fontWeight: FontWeight.bold, - ), - border: InputBorder.none, - ), - ), - leading: _ImagePicker( - readOnly: controller.showSave, - controller: controller.newImageController, - onPressed: controller.imagePickerAction, - ), - trailing: TextButton( - onPressed: controller.showSave || - controller.newImageController.value == null - ? null - : controller.addImageAction, - child: Text(L10n.of(context).add), - ), + padding: const EdgeInsets.all(16.0), + child: ElevatedButton.icon( + onPressed: controller.createStickers, + icon: const Icon(Icons.upload_outlined), + label: Text(L10n.of(context).createSticker), ), ), if (controller.room != null) @@ -240,36 +207,4 @@ class _EmoteImage extends StatelessWidget { } } -class _ImagePicker extends StatefulWidget { - final ValueNotifier controller; - final bool readOnly; - - final void Function(ValueNotifier) onPressed; - - const _ImagePicker({ - required this.controller, - this.readOnly = false, - required this.onPressed, - }); - - @override - _ImagePickerState createState() => _ImagePickerState(); -} - -class _ImagePickerState extends State<_ImagePicker> { - @override - Widget build(BuildContext context) { - if (widget.controller.value == null) { - return IconButton( - tooltip: L10n.of(context).select, - onPressed: - widget.readOnly ? null : () => widget.onPressed(widget.controller), - icon: const Icon(Icons.upload_outlined), - ); - } else { - return _EmoteImage(widget.controller.value!.url); - } - } -} - class SubmitLineIntent extends Intent {} From 18d69ae608b7692a9cdd953a5da970fbb7126971 Mon Sep 17 00:00:00 2001 From: krille-chan Date: Thu, 20 Nov 2025 18:55:19 +0100 Subject: [PATCH 03/13] feat: Set usage of custom emojis and stickers --- lib/l10n/intl_en.arb | 4 +- .../settings_emotes/settings_emotes.dart | 9 ++ .../settings_emotes/settings_emotes_view.dart | 136 ++++++++++++------ 3 files changed, 104 insertions(+), 45 deletions(-) diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 55f108571..694a130fe 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -3454,5 +3454,7 @@ "thread": "Thread", "backToMainChat": "Back to main chat", "saveChanges": "Save changes", - "createSticker": "Create sticker or emoji" + "createSticker": "Create sticker or emoji", + "useAsSticker": "Use as sticker", + "useAsEmoji": "Use as emoji" } diff --git a/lib/pages/settings_emotes/settings_emotes.dart b/lib/pages/settings_emotes/settings_emotes.dart index 173c6b310..4f6d92c5d 100644 --- a/lib/pages/settings_emotes/settings_emotes.dart +++ b/lib/pages/settings_emotes/settings_emotes.dart @@ -124,6 +124,15 @@ class EmotesSettingsController extends State { showSave = true; }); + void toggleUsage(String imageCode, ImagePackUsage usage) { + setState(() { + final usages = + pack!.images[imageCode]!.usage ??= List.from(ImagePackUsage.values); + if (!usages.remove(usage)) usages.add(usage); + showSave = true; + }); + } + void submitImageAction( String oldImageCode, String imageCode, diff --git a/lib/pages/settings_emotes/settings_emotes_view.dart b/lib/pages/settings_emotes/settings_emotes_view.dart index 238ac4cea..030e884e6 100644 --- a/lib/pages/settings_emotes/settings_emotes_view.dart +++ b/lib/pages/settings_emotes/settings_emotes_view.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:matrix/matrix_api_lite/model/events/image_pack_content.dart'; + import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/utils/platform_infos.dart'; import 'package:fluffychat/widgets/layouts/max_width_body.dart'; @@ -116,56 +118,102 @@ class EmotesSettingsView extends StatelessWidget { final useShortCuts = (PlatformInfos.isWeb || PlatformInfos.isDesktop); return ListTile( - title: Shortcuts( - shortcuts: !useShortCuts - ? {} - : { - LogicalKeySet(LogicalKeyboardKey.enter): - SubmitLineIntent(), - }, - child: Actions( - actions: !useShortCuts - ? {} - : { - SubmitLineIntent: CallbackAction( - onInvoke: (i) { - controller.submitImageAction( - imageCode, - textEditingController.text, - image, - textEditingController, - ); - return null; + title: Row( + children: [ + Expanded( + child: Shortcuts( + shortcuts: !useShortCuts + ? {} + : { + LogicalKeySet(LogicalKeyboardKey.enter): + SubmitLineIntent(), }, + child: Actions( + actions: !useShortCuts + ? {} + : { + SubmitLineIntent: CallbackAction( + onInvoke: (i) { + controller.submitImageAction( + imageCode, + textEditingController.text, + image, + textEditingController, + ); + return null; + }, + ), + }, + child: TextField( + readOnly: controller.readonly, + controller: textEditingController, + autocorrect: false, + minLines: 1, + maxLines: 1, + maxLength: 128, + decoration: InputDecoration( + hintText: L10n.of(context).emoteShortcode, + prefixText: ': ', + suffixText: ':', + counter: const SizedBox.shrink(), + filled: false, + enabledBorder: const OutlineInputBorder( + borderSide: BorderSide( + color: Colors.transparent, + ), + ), + ), + onSubmitted: (s) => + controller.submitImageAction( + imageCode, + s, + image, + textEditingController, ), - }, - child: TextField( - readOnly: controller.readonly, - controller: textEditingController, - autocorrect: false, - minLines: 1, - maxLines: 1, - maxLength: 128, - decoration: InputDecoration( - hintText: L10n.of(context).emoteShortcode, - prefixText: ': ', - suffixText: ':', - counter: const SizedBox.shrink(), - filled: false, - enabledBorder: const OutlineInputBorder( - borderSide: BorderSide( - color: Colors.transparent, ), ), ), - onSubmitted: (s) => controller.submitImageAction( - imageCode, - s, - image, - textEditingController, - ), ), - ), + PopupMenuButton( + onSelected: (usage) => controller.toggleUsage( + imageCode, + usage, + ), + itemBuilder: (context) => [ + PopupMenuItem( + value: ImagePackUsage.sticker, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (image.usage?.contains( + ImagePackUsage.sticker, + ) ?? + true) + const Icon(Icons.check_outlined), + const SizedBox(width: 12), + Text(L10n.of(context).useAsSticker), + ], + ), + ), + PopupMenuItem( + value: ImagePackUsage.emoticon, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (image.usage?.contains( + ImagePackUsage.emoticon, + ) ?? + true) + const Icon(Icons.check_outlined), + const SizedBox(width: 12), + Text(L10n.of(context).useAsEmoji), + ], + ), + ), + ], + icon: const Icon(Icons.edit_outlined), + ), + ], ), leading: _EmoteImage(image.url), trailing: controller.readonly From 2b4381dd07c437c23a1551d375384f329e4f6295 Mon Sep 17 00:00:00 2001 From: krille-chan Date: Thu, 20 Nov 2025 18:57:12 +0100 Subject: [PATCH 04/13] chore: Improve room custom emote UX --- .../settings_emotes/settings_emotes_view.dart | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/lib/pages/settings_emotes/settings_emotes_view.dart b/lib/pages/settings_emotes/settings_emotes_view.dart index 030e884e6..ada3d90df 100644 --- a/lib/pages/settings_emotes/settings_emotes_view.dart +++ b/lib/pages/settings_emotes/settings_emotes_view.dart @@ -61,10 +61,11 @@ class EmotesSettingsView extends StatelessWidget { value: PopupMenuEmojiActions.import, child: Text(L10n.of(context).importFromZipFile), ), - PopupMenuItem( - value: PopupMenuEmojiActions.export, - child: Text(L10n.of(context).exportEmotePack), - ), + if (imageKeys.isNotEmpty) + PopupMenuItem( + value: PopupMenuEmojiActions.export, + child: Text(L10n.of(context).exportEmotePack), + ), ], ), ], @@ -83,14 +84,14 @@ class EmotesSettingsView extends StatelessWidget { label: Text(L10n.of(context).createSticker), ), ), - if (controller.room != null) + if (!controller.readonly || controller.room != null) + const Divider(), + if (controller.room != null && imageKeys.isNotEmpty) SwitchListTile.adaptive( title: Text(L10n.of(context).enableEmotesGlobally), value: controller.isGloballyActive(client), onChanged: controller.setIsGloballyActive, ), - if (!controller.readonly || controller.room != null) - const Divider(), imageKeys.isEmpty ? Center( child: Padding( From 726de6e92b115e685d0ff5c860bea39b3362b9de Mon Sep 17 00:00:00 2001 From: krille-chan Date: Thu, 20 Nov 2025 19:11:51 +0100 Subject: [PATCH 05/13] chore: Make sticker previews in editor clickable --- lib/pages/settings_emotes/settings_emotes_view.dart | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/lib/pages/settings_emotes/settings_emotes_view.dart b/lib/pages/settings_emotes/settings_emotes_view.dart index ada3d90df..34fb50ccd 100644 --- a/lib/pages/settings_emotes/settings_emotes_view.dart +++ b/lib/pages/settings_emotes/settings_emotes_view.dart @@ -7,6 +7,7 @@ import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/utils/platform_infos.dart'; import 'package:fluffychat/widgets/layouts/max_width_body.dart'; import 'package:fluffychat/widgets/mxc_image.dart'; +import 'package:fluffychat/widgets/mxc_image_viewer.dart'; import '../../widgets/matrix.dart'; import 'settings_emotes.dart'; @@ -242,15 +243,19 @@ class _EmoteImage extends StatelessWidget { @override Widget build(BuildContext context) { - const size = 38.0; - return SizedBox.square( - dimension: size, + const size = 44.0; + return InkWell( + borderRadius: BorderRadius.circular(4), + onTap: () => showDialog( + context: context, + builder: (_) => MxcImageViewer(mxc), + ), child: MxcImage( uri: mxc, fit: BoxFit.contain, width: size, height: size, - isThumbnail: false, + isThumbnail: true, ), ); } From ed945311d94d9d45e2301106f89242f0540b6cc2 Mon Sep 17 00:00:00 2001 From: krille-chan Date: Thu, 20 Nov 2025 19:17:04 +0100 Subject: [PATCH 06/13] chore: Improve sticker editor UX --- .../settings_emotes/settings_emotes.dart | 3 +- .../settings_emotes/settings_emotes_view.dart | 86 +++++++++---------- 2 files changed, 43 insertions(+), 46 deletions(-) diff --git a/lib/pages/settings_emotes/settings_emotes.dart b/lib/pages/settings_emotes/settings_emotes.dart index 4f6d92c5d..82b69bb52 100644 --- a/lib/pages/settings_emotes/settings_emotes.dart +++ b/lib/pages/settings_emotes/settings_emotes.dart @@ -175,8 +175,7 @@ class EmotesSettingsController extends State { ?.tryGetMap(stateKey ?? '') != null; - bool get readonly => - room == null ? false : !(room!.canSendEvent('im.ponies.room_emotes')); + bool get readonly => room?.canSendEvent('im.ponies.room_emotes') ?? false; void resetAction() { setState(() { diff --git a/lib/pages/settings_emotes/settings_emotes_view.dart b/lib/pages/settings_emotes/settings_emotes_view.dart index 34fb50ccd..d00fd8b4f 100644 --- a/lib/pages/settings_emotes/settings_emotes_view.dart +++ b/lib/pages/settings_emotes/settings_emotes_view.dart @@ -76,7 +76,7 @@ class EmotesSettingsView extends StatelessWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (!controller.readonly) + if (!controller.readonly) ...[ Padding( padding: const EdgeInsets.all(16.0), child: ElevatedButton.icon( @@ -85,8 +85,8 @@ class EmotesSettingsView extends StatelessWidget { label: Text(L10n.of(context).createSticker), ), ), - if (!controller.readonly || controller.room != null) const Divider(), + ], if (controller.room != null && imageKeys.isNotEmpty) SwitchListTile.adaptive( title: Text(L10n.of(context).enableEmotesGlobally), @@ -108,11 +108,8 @@ class EmotesSettingsView extends StatelessWidget { physics: const NeverScrollableScrollPhysics(), separatorBuilder: (BuildContext context, int i) => const SizedBox.shrink(), - itemCount: imageKeys.length + 1, + itemCount: imageKeys.length, itemBuilder: (BuildContext context, int i) { - if (i >= imageKeys.length) { - return Container(height: 70); - } final imageCode = imageKeys[i]; final image = controller.pack!.images[imageCode]!; final textEditingController = TextEditingController(); @@ -176,45 +173,46 @@ class EmotesSettingsView extends StatelessWidget { ), ), ), - PopupMenuButton( - onSelected: (usage) => controller.toggleUsage( - imageCode, - usage, + if (!controller.readonly) + PopupMenuButton( + onSelected: (usage) => controller.toggleUsage( + imageCode, + usage, + ), + itemBuilder: (context) => [ + PopupMenuItem( + value: ImagePackUsage.sticker, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (image.usage?.contains( + ImagePackUsage.sticker, + ) ?? + true) + const Icon(Icons.check_outlined), + const SizedBox(width: 12), + Text(L10n.of(context).useAsSticker), + ], + ), + ), + PopupMenuItem( + value: ImagePackUsage.emoticon, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (image.usage?.contains( + ImagePackUsage.emoticon, + ) ?? + true) + const Icon(Icons.check_outlined), + const SizedBox(width: 12), + Text(L10n.of(context).useAsEmoji), + ], + ), + ), + ], + icon: const Icon(Icons.edit_outlined), ), - itemBuilder: (context) => [ - PopupMenuItem( - value: ImagePackUsage.sticker, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (image.usage?.contains( - ImagePackUsage.sticker, - ) ?? - true) - const Icon(Icons.check_outlined), - const SizedBox(width: 12), - Text(L10n.of(context).useAsSticker), - ], - ), - ), - PopupMenuItem( - value: ImagePackUsage.emoticon, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (image.usage?.contains( - ImagePackUsage.emoticon, - ) ?? - true) - const Icon(Icons.check_outlined), - const SizedBox(width: 12), - Text(L10n.of(context).useAsEmoji), - ], - ), - ), - ], - icon: const Icon(Icons.edit_outlined), - ), ], ), leading: _EmoteImage(image.url), From b625249ff8d0965849c737a85a7010503b4e2974 Mon Sep 17 00:00:00 2001 From: krille-chan Date: Thu, 20 Nov 2025 19:24:15 +0100 Subject: [PATCH 07/13] fix: State problem when not changing emote name --- lib/pages/settings_emotes/settings_emotes.dart | 3 ++- lib/pages/settings_emotes/settings_emotes_view.dart | 2 -- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/pages/settings_emotes/settings_emotes.dart b/lib/pages/settings_emotes/settings_emotes.dart index 82b69bb52..56c24ca93 100644 --- a/lib/pages/settings_emotes/settings_emotes.dart +++ b/lib/pages/settings_emotes/settings_emotes.dart @@ -135,11 +135,12 @@ class EmotesSettingsController extends State { void submitImageAction( String oldImageCode, - String imageCode, ImagePackImageContent image, TextEditingController controller, ) { controller.text = controller.text.trim().replaceAll(' ', '-'); + final imageCode = controller.text; + if (imageCode == oldImageCode) return; if (pack!.images.keys.any((k) => k == imageCode && k != oldImageCode)) { controller.text = oldImageCode; showOkAlertDialog( diff --git a/lib/pages/settings_emotes/settings_emotes_view.dart b/lib/pages/settings_emotes/settings_emotes_view.dart index d00fd8b4f..47a08aa60 100644 --- a/lib/pages/settings_emotes/settings_emotes_view.dart +++ b/lib/pages/settings_emotes/settings_emotes_view.dart @@ -135,7 +135,6 @@ class EmotesSettingsView extends StatelessWidget { onInvoke: (i) { controller.submitImageAction( imageCode, - textEditingController.text, image, textEditingController, ); @@ -165,7 +164,6 @@ class EmotesSettingsView extends StatelessWidget { onSubmitted: (s) => controller.submitImageAction( imageCode, - s, image, textEditingController, ), From 3c86da7932b2460d511811cb34b0bd601f9a3dd5 Mon Sep 17 00:00:00 2001 From: krille-chan Date: Thu, 20 Nov 2025 19:50:27 +0100 Subject: [PATCH 08/13] refactor: Display all sticker packs in same editor with filterchips --- lib/config/routes.dart | 27 ++------ .../settings_emotes/settings_emotes.dart | 30 +++++++-- .../settings_emotes/settings_emotes_view.dart | 45 ++++++++++++- .../settings_multiple_emotes.dart | 19 ------ .../settings_multiple_emotes_view.dart | 64 ------------------- lib/widgets/chat_settings_popup_menu.dart | 15 +---- 6 files changed, 76 insertions(+), 124 deletions(-) delete mode 100644 lib/pages/settings_multiple_emotes/settings_multiple_emotes.dart delete mode 100644 lib/pages/settings_multiple_emotes/settings_multiple_emotes_view.dart diff --git a/lib/config/routes.dart b/lib/config/routes.dart index 8f92d4c6d..a598ffd70 100644 --- a/lib/config/routes.dart +++ b/lib/config/routes.dart @@ -27,7 +27,6 @@ import 'package:fluffychat/pages/settings_chat/settings_chat.dart'; import 'package:fluffychat/pages/settings_emotes/settings_emotes.dart'; import 'package:fluffychat/pages/settings_homeserver/settings_homeserver.dart'; import 'package:fluffychat/pages/settings_ignore_list/settings_ignore_list.dart'; -import 'package:fluffychat/pages/settings_multiple_emotes/settings_multiple_emotes.dart'; import 'package:fluffychat/pages/settings_notifications/settings_notifications.dart'; import 'package:fluffychat/pages/settings_password/settings_password.dart'; import 'package:fluffychat/pages/settings_security/settings_security.dart'; @@ -249,7 +248,9 @@ abstract class AppRoutes { pageBuilder: (context, state) => defaultPageBuilder( context, state, - const EmotesSettings(), + EmotesSettings( + roomId: state.pathParameters['roomid'], + ), ), ), ], @@ -441,30 +442,14 @@ abstract class AppRoutes { ), redirect: loggedOutRedirect, ), - GoRoute( - path: 'multiple_emotes', - pageBuilder: (context, state) => defaultPageBuilder( - context, - state, - const MultipleEmotesSettings(), - ), - redirect: loggedOutRedirect, - ), GoRoute( path: 'emotes', pageBuilder: (context, state) => defaultPageBuilder( context, state, - const EmotesSettings(), - ), - redirect: loggedOutRedirect, - ), - GoRoute( - path: 'emotes/:state_key', - pageBuilder: (context, state) => defaultPageBuilder( - context, - state, - const EmotesSettings(), + EmotesSettings( + roomId: state.pathParameters['roomid'], + ), ), redirect: loggedOutRedirect, ), diff --git a/lib/pages/settings_emotes/settings_emotes.dart b/lib/pages/settings_emotes/settings_emotes.dart index 56c24ca93..95ec753e9 100644 --- a/lib/pages/settings_emotes/settings_emotes.dart +++ b/lib/pages/settings_emotes/settings_emotes.dart @@ -3,7 +3,6 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; import 'package:http/http.dart' hide Client; import 'package:matrix/matrix.dart'; @@ -21,19 +20,38 @@ import 'package:archive/archive.dart' if (dart.library.io) 'package:archive/archive_io.dart'; class EmotesSettings extends StatefulWidget { - const EmotesSettings({super.key}); + final String? roomId; + const EmotesSettings({required this.roomId, super.key}); @override EmotesSettingsController createState() => EmotesSettingsController(); } class EmotesSettingsController extends State { - String? get roomId => GoRouterState.of(context).pathParameters['roomid']; + Room? get room => widget.roomId != null + ? Matrix.of(context).client.getRoomById(widget.roomId!) + : null; - Room? get room => - roomId != null ? Matrix.of(context).client.getRoomById(roomId!) : null; + String? stateKey; - String? get stateKey => GoRouterState.of(context).pathParameters['state_key']; + List? get packKeys { + final room = this.room; + if (room == null) return null; + final keys = room.states['im.ponies.room_emotes']?.keys.toList() ?? []; + keys.sort(); + return keys; + } + + @override + void initState() { + super.initState(); + stateKey = packKeys?.first; + } + + void setStateKey(String key) { + stateKey = key; + resetAction(); + } bool showSave = false; diff --git a/lib/pages/settings_emotes/settings_emotes_view.dart b/lib/pages/settings_emotes/settings_emotes_view.dart index 47a08aa60..cc2111b97 100644 --- a/lib/pages/settings_emotes/settings_emotes_view.dart +++ b/lib/pages/settings_emotes/settings_emotes_view.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:matrix/matrix_api_lite/model/events/image_pack_content.dart'; +import 'package:matrix/matrix.dart'; import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/utils/platform_infos.dart'; @@ -24,6 +24,7 @@ class EmotesSettingsView extends StatelessWidget { final client = Matrix.of(context).client; final imageKeys = controller.pack!.images.keys.toList(); + final packKeys = controller.packKeys; return Scaffold( appBar: AppBar( automaticallyImplyLeading: !controller.showSave, @@ -70,6 +71,45 @@ class EmotesSettingsView extends StatelessWidget { ], ), ], + bottom: packKeys == null + ? null + : PreferredSize( + preferredSize: const Size.fromHeight(48), + child: Padding( + padding: const EdgeInsets.all(4.0), + child: SizedBox( + height: 40, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: packKeys.length, + itemBuilder: (context, i) { + final key = packKeys[i]; + final event = controller.room + ?.getState('im.ponies.room_emotes', packKeys[i]); + + final eventPack = + event?.content.tryGetMap('pack'); + final packName = + eventPack?.tryGet('displayname') ?? + eventPack?.tryGet('name') ?? + (key.isNotEmpty ? key : 'Default'); + + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 4.0, + ), + child: FilterChip( + label: Text(packName), + selected: controller.stateKey == packKeys[i], + onSelected: (_) => + controller.setStateKey(packKeys[i]), + ), + ); + }, + ), + ), + ), + ), ), body: MaxWidthBody( child: Column( @@ -240,6 +280,7 @@ class _EmoteImage extends StatelessWidget { @override Widget build(BuildContext context) { const size = 44.0; + final key = 'sticker_preview_$mxc'; return InkWell( borderRadius: BorderRadius.circular(4), onTap: () => showDialog( @@ -247,6 +288,8 @@ class _EmoteImage extends StatelessWidget { builder: (_) => MxcImageViewer(mxc), ), child: MxcImage( + key: ValueKey(key), + cacheKey: key, uri: mxc, fit: BoxFit.contain, width: size, diff --git a/lib/pages/settings_multiple_emotes/settings_multiple_emotes.dart b/lib/pages/settings_multiple_emotes/settings_multiple_emotes.dart deleted file mode 100644 index 276bc08ad..000000000 --- a/lib/pages/settings_multiple_emotes/settings_multiple_emotes.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:go_router/go_router.dart'; - -import 'settings_multiple_emotes_view.dart'; - -class MultipleEmotesSettings extends StatefulWidget { - const MultipleEmotesSettings({super.key}); - - @override - MultipleEmotesSettingsController createState() => - MultipleEmotesSettingsController(); -} - -class MultipleEmotesSettingsController extends State { - String? get roomId => GoRouterState.of(context).pathParameters['roomid']; - @override - Widget build(BuildContext context) => MultipleEmotesSettingsView(this); -} diff --git a/lib/pages/settings_multiple_emotes/settings_multiple_emotes_view.dart b/lib/pages/settings_multiple_emotes/settings_multiple_emotes_view.dart deleted file mode 100644 index 6559d0f15..000000000 --- a/lib/pages/settings_multiple_emotes/settings_multiple_emotes_view.dart +++ /dev/null @@ -1,64 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:go_router/go_router.dart'; -import 'package:matrix/matrix.dart'; - -import 'package:fluffychat/l10n/l10n.dart'; -import 'package:fluffychat/pages/settings_multiple_emotes/settings_multiple_emotes.dart'; -import 'package:fluffychat/widgets/matrix.dart'; - -class MultipleEmotesSettingsView extends StatelessWidget { - final MultipleEmotesSettingsController controller; - - const MultipleEmotesSettingsView(this.controller, {super.key}); - - @override - Widget build(BuildContext context) { - final room = Matrix.of(context).client.getRoomById(controller.roomId!)!; - return Scaffold( - appBar: AppBar( - leading: const Center(child: BackButton()), - title: Text(L10n.of(context).emotePacks), - ), - body: StreamBuilder( - stream: room.client.onRoomState.stream - .where((update) => update.roomId == room.id), - builder: (context, snapshot) { - final packStateEvents = room.states['im.ponies.room_emotes']; - // we need to manually convert the map using Map.of, otherwise assigning null will throw a type error. - final packs = packStateEvents != null - ? Map.of(packStateEvents) - : {}; - if (!packs.containsKey('')) { - packs[''] = null; - } - final keys = packs.keys.toList(); - keys.sort(); - return ListView.separated( - separatorBuilder: (BuildContext context, int i) => - const SizedBox.shrink(), - itemCount: keys.length, - itemBuilder: (BuildContext context, int i) { - final event = packs[keys[i]]; - final eventPack = - event?.content.tryGetMap('pack'); - final packName = eventPack?.tryGet('displayname') ?? - eventPack?.tryGet('name') ?? - (keys[i].isNotEmpty ? keys[i] : 'Default Pack'); - - return ListTile( - title: Text(packName), - onTap: () async { - context.go( - ['', 'rooms', room.id, 'details', 'emotes', keys[i]] - .join('/'), - ); - }, - ); - }, - ); - }, - ), - ); - } -} diff --git a/lib/widgets/chat_settings_popup_menu.dart b/lib/widgets/chat_settings_popup_menu.dart index 6969fd901..81123f62f 100644 --- a/lib/widgets/chat_settings_popup_menu.dart +++ b/lib/widgets/chat_settings_popup_menu.dart @@ -31,19 +31,8 @@ class ChatSettingsPopupMenuState extends State { super.dispose(); } - void goToEmoteSettings() async { - final room = widget.room; - // okay, we need to test if there are any emote state events other than the default one - // if so, we need to be directed to a selection screen for which pack we want to look at - // otherwise, we just open the normal one. - if ((room.states['im.ponies.room_emotes'] ?? {}) - .keys - .any((String s) => s.isNotEmpty)) { - context.push('/rooms/${room.id}/details/multiple_emotes'); - } else { - context.push('/rooms/${room.id}/details/emotes'); - } - } + void goToEmoteSettings() => + context.push('/rooms/${widget.room.id}/details/emotes'); @override Widget build(BuildContext context) { From 43c5c35fcc342e5101ea2d6e1ae4ae4c3efbfa77 Mon Sep 17 00:00:00 2001 From: krille-chan Date: Thu, 20 Nov 2025 19:56:03 +0100 Subject: [PATCH 09/13] chore: Follow up emote settings --- lib/pages/settings_emotes/settings_emotes.dart | 2 +- lib/pages/settings_emotes/settings_emotes_view.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pages/settings_emotes/settings_emotes.dart b/lib/pages/settings_emotes/settings_emotes.dart index 95ec753e9..4b50975da 100644 --- a/lib/pages/settings_emotes/settings_emotes.dart +++ b/lib/pages/settings_emotes/settings_emotes.dart @@ -45,7 +45,7 @@ class EmotesSettingsController extends State { @override void initState() { super.initState(); - stateKey = packKeys?.first; + stateKey = packKeys?.firstOrNull; } void setStateKey(String key) { diff --git a/lib/pages/settings_emotes/settings_emotes_view.dart b/lib/pages/settings_emotes/settings_emotes_view.dart index cc2111b97..31b61b102 100644 --- a/lib/pages/settings_emotes/settings_emotes_view.dart +++ b/lib/pages/settings_emotes/settings_emotes_view.dart @@ -71,7 +71,7 @@ class EmotesSettingsView extends StatelessWidget { ], ), ], - bottom: packKeys == null + bottom: packKeys == null || packKeys.isEmpty ? null : PreferredSize( preferredSize: const Size.fromHeight(48), From 089932a9f4a39729ddd3a94d4dcdcf387a6df15c Mon Sep 17 00:00:00 2001 From: krille-chan Date: Thu, 20 Nov 2025 20:30:49 +0100 Subject: [PATCH 10/13] feat: Create new sticker packs --- lib/l10n/intl_en.arb | 4 +- .../settings_emotes/settings_emotes.dart | 56 +++++++++++++++++-- .../settings_emotes/settings_emotes_view.dart | 48 ++++++++++++++-- 3 files changed, 97 insertions(+), 11 deletions(-) diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 694a130fe..22d4190ae 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -3456,5 +3456,7 @@ "saveChanges": "Save changes", "createSticker": "Create sticker or emoji", "useAsSticker": "Use as sticker", - "useAsEmoji": "Use as emoji" + "useAsEmoji": "Use as emoji", + "stickerPackNameAlreadyExists": "Sticker pack name already exists", + "newStickerPack": "New sticker pack" } diff --git a/lib/pages/settings_emotes/settings_emotes.dart b/lib/pages/settings_emotes/settings_emotes.dart index 4b50975da..e412c1b9d 100644 --- a/lib/pages/settings_emotes/settings_emotes.dart +++ b/lib/pages/settings_emotes/settings_emotes.dart @@ -11,6 +11,7 @@ import 'package:fluffychat/utils/client_manager.dart'; import 'package:fluffychat/utils/file_selector.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_file_extension.dart'; import 'package:fluffychat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart'; +import 'package:fluffychat/widgets/adaptive_dialogs/show_text_input_dialog.dart'; import 'package:fluffychat/widgets/future_loading_dialog.dart'; import '../../widgets/matrix.dart'; import 'import_archive_dialog.dart'; @@ -28,9 +29,7 @@ class EmotesSettings extends StatefulWidget { } class EmotesSettingsController extends State { - Room? get room => widget.roomId != null - ? Matrix.of(context).client.getRoomById(widget.roomId!) - : null; + late final Room? room; String? stateKey; @@ -45,6 +44,9 @@ class EmotesSettingsController extends State { @override void initState() { super.initState(); + room = widget.roomId != null + ? Matrix.of(context).client.getRoomById(widget.roomId!) + : null; stateKey = packKeys?.firstOrNull; } @@ -194,7 +196,9 @@ class EmotesSettingsController extends State { ?.tryGetMap(stateKey ?? '') != null; - bool get readonly => room?.canSendEvent('im.ponies.room_emotes') ?? false; + bool get readonly => room == null + ? false + : room?.canChangeStateEvent('im.ponies.room_emotes') == false; void resetAction() { setState(() { @@ -203,6 +207,50 @@ class EmotesSettingsController extends State { }); } + void createImagePack() async { + final room = this.room; + if (room == null) throw Exception('Cannot create image pack without room'); + + final input = await showTextInputDialog( + context: context, + title: L10n.of(context).newStickerPack, + hintText: L10n.of(context).name, + okLabel: L10n.of(context).create, + ); + final name = input?.trim(); + if (name == null || name.isEmpty) return; + if (!mounted) return; + + final keyName = name.toLowerCase().replaceAll(' ', '_'); + + if (packKeys?.contains(name) ?? false) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(L10n.of(context).stickerPackNameAlreadyExists), + ), + ); + return; + } + + await showFutureLoadingDialog( + context: context, + future: () => room.client.setRoomStateWithKey( + room.id, + 'im.ponies.room_emotes', + keyName, + { + 'images': {}, + 'pack': {'display_name': name}, + }, + ), + ); + if (!mounted) return; + setState(() {}); + await room.client.oneShotSync(); + if (!mounted) return; + setState(() {}); + } + void saveAction() async { await save(context); setState(() { diff --git a/lib/pages/settings_emotes/settings_emotes_view.dart b/lib/pages/settings_emotes/settings_emotes_view.dart index 31b61b102..8a55d8bfe 100644 --- a/lib/pages/settings_emotes/settings_emotes_view.dart +++ b/lib/pages/settings_emotes/settings_emotes_view.dart @@ -20,11 +20,25 @@ class EmotesSettingsView extends StatelessWidget { @override Widget build(BuildContext context) { + if (controller.widget.roomId != null && controller.room == null) { + return Scaffold( + appBar: AppBar( + title: Text(L10n.of(context).oopsSomethingWentWrong), + ), + body: Center( + child: Text(L10n.of(context).youAreNoLongerParticipatingInThisChat), + ), + ); + } final theme = Theme.of(context); final client = Matrix.of(context).client; final imageKeys = controller.pack!.images.keys.toList(); final packKeys = controller.packKeys; + if (packKeys != null && packKeys.isEmpty) { + packKeys.add(''); + } + return Scaffold( appBar: AppBar( automaticallyImplyLeading: !controller.showSave, @@ -71,7 +85,7 @@ class EmotesSettingsView extends StatelessWidget { ], ), ], - bottom: packKeys == null || packKeys.isEmpty + bottom: packKeys == null ? null : PreferredSize( preferredSize: const Size.fromHeight(48), @@ -81,8 +95,28 @@ class EmotesSettingsView extends StatelessWidget { height: 40, child: ListView.builder( scrollDirection: Axis.horizontal, - itemCount: packKeys.length, + itemCount: packKeys.length + 1, itemBuilder: (context, i) { + if (i == 0) { + if (controller.readonly) { + return const SizedBox.shrink(); + } + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 4.0, + ), + child: FilterChip( + label: const Icon( + Icons.add_outlined, + size: 20, + ), + onSelected: controller.showSave + ? null + : (_) => controller.createImagePack(), + ), + ); + } + i--; final key = packKeys[i]; final event = controller.room ?.getState('im.ponies.room_emotes', packKeys[i]); @@ -90,7 +124,7 @@ class EmotesSettingsView extends StatelessWidget { final eventPack = event?.content.tryGetMap('pack'); final packName = - eventPack?.tryGet('displayname') ?? + eventPack?.tryGet('display_name') ?? eventPack?.tryGet('name') ?? (key.isNotEmpty ? key : 'Default'); @@ -100,9 +134,11 @@ class EmotesSettingsView extends StatelessWidget { ), child: FilterChip( label: Text(packName), - selected: controller.stateKey == packKeys[i], - onSelected: (_) => - controller.setStateKey(packKeys[i]), + selected: controller.stateKey == key || + (controller.stateKey == null && key.isEmpty), + onSelected: controller.showSave + ? null + : (_) => controller.setStateKey(key), ), ); }, From 87ca3c1f1fe712a44c9b6be8a136c4912ea60e5f Mon Sep 17 00:00:00 2001 From: krille-chan Date: Thu, 20 Nov 2025 20:47:27 +0100 Subject: [PATCH 11/13] chore: Display attribution for sticker packs --- lib/l10n/intl_en.arb | 4 ++- .../settings_emotes/settings_emotes.dart | 24 +++++++++++++-- .../settings_emotes/settings_emotes_view.dart | 29 +++++++++++++++++++ 3 files changed, 53 insertions(+), 4 deletions(-) diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 22d4190ae..99e856036 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -3458,5 +3458,7 @@ "useAsSticker": "Use as sticker", "useAsEmoji": "Use as emoji", "stickerPackNameAlreadyExists": "Sticker pack name already exists", - "newStickerPack": "New sticker pack" + "newStickerPack": "New sticker pack", + "stickerPackName": "Sticker pack name", + "attribution": "Attribution" } diff --git a/lib/pages/settings_emotes/settings_emotes.dart b/lib/pages/settings_emotes/settings_emotes.dart index e412c1b9d..0dc860ea3 100644 --- a/lib/pages/settings_emotes/settings_emotes.dart +++ b/lib/pages/settings_emotes/settings_emotes.dart @@ -47,12 +47,24 @@ class EmotesSettingsController extends State { room = widget.roomId != null ? Matrix.of(context).client.getRoomById(widget.roomId!) : null; - stateKey = packKeys?.firstOrNull; + setStateKey(packKeys?.firstOrNull, reset: false); } - void setStateKey(String key) { + void setStateKey(String? key, {reset = true}) { stateKey = key; - resetAction(); + + final event = key == null + ? null + : room?.getState( + 'im.ponies.room_emotes', + key, + ); + final eventPack = event?.content.tryGetMap('pack'); + packDisplayNameController.text = + eventPack?.tryGet('display_name') ?? ''; + packAttributionController.text = + eventPack?.tryGet('attribution') ?? ''; + if (reset) resetAction(); } bool showSave = false; @@ -139,6 +151,12 @@ class EmotesSettingsController extends State { setState(() {}); } + final TextEditingController packDisplayNameController = + TextEditingController(); + + final TextEditingController packAttributionController = + TextEditingController(); + void removeImageAction(String oldImageCode) => setState(() { pack!.images.remove(oldImageCode); showSave = true; diff --git a/lib/pages/settings_emotes/settings_emotes_view.dart b/lib/pages/settings_emotes/settings_emotes_view.dart index 8a55d8bfe..58dc5ffac 100644 --- a/lib/pages/settings_emotes/settings_emotes_view.dart +++ b/lib/pages/settings_emotes/settings_emotes_view.dart @@ -152,6 +152,35 @@ class EmotesSettingsView extends StatelessWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + if (controller.room != null) ...[ + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: TextField( + maxLength: 256, + controller: controller.packDisplayNameController, + readOnly: true, //controller.readonly, + decoration: InputDecoration( + counter: const SizedBox.shrink(), + hintText: controller.stateKey, + labelText: L10n.of(context).stickerPackName, + ), + ), + ), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: TextField( + maxLength: 256, + controller: controller.packAttributionController, + readOnly: true, //controller.readonly, + decoration: InputDecoration( + counter: const SizedBox.shrink(), + labelText: L10n.of(context).attribution, + ), + ), + ), + ], if (!controller.readonly) ...[ Padding( padding: const EdgeInsets.all(16.0), From 489402be91c45a4a8b85a7b3157e0f1b86fb1978 Mon Sep 17 00:00:00 2001 From: krille-chan Date: Thu, 20 Nov 2025 20:55:36 +0100 Subject: [PATCH 12/13] feat: Edit displayname and attribution for sticker packs --- .../settings_emotes/settings_emotes.dart | 22 +++++++++++++++++++ .../settings_emotes/settings_emotes_view.dart | 7 ++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/lib/pages/settings_emotes/settings_emotes.dart b/lib/pages/settings_emotes/settings_emotes.dart index 0dc860ea3..b1de7f161 100644 --- a/lib/pages/settings_emotes/settings_emotes.dart +++ b/lib/pages/settings_emotes/settings_emotes.dart @@ -171,6 +171,28 @@ class EmotesSettingsController extends State { }); } + void submitDisplaynameAction() { + if (readonly) return; + packDisplayNameController.text = packDisplayNameController.text.trim(); + final input = packDisplayNameController.text; + + setState(() { + pack!.pack.displayName = input; + showSave = true; + }); + } + + void submitAttributionAction() { + if (readonly) return; + packAttributionController.text = packAttributionController.text.trim(); + final input = packAttributionController.text; + + setState(() { + pack!.pack.attribution = input; + showSave = true; + }); + } + void submitImageAction( String oldImageCode, ImagePackImageContent image, diff --git a/lib/pages/settings_emotes/settings_emotes_view.dart b/lib/pages/settings_emotes/settings_emotes_view.dart index 58dc5ffac..d2f1b47ba 100644 --- a/lib/pages/settings_emotes/settings_emotes_view.dart +++ b/lib/pages/settings_emotes/settings_emotes_view.dart @@ -159,7 +159,8 @@ class EmotesSettingsView extends StatelessWidget { child: TextField( maxLength: 256, controller: controller.packDisplayNameController, - readOnly: true, //controller.readonly, + readOnly: controller.readonly, + onSubmitted: (_) => controller.submitDisplaynameAction(), decoration: InputDecoration( counter: const SizedBox.shrink(), hintText: controller.stateKey, @@ -173,7 +174,8 @@ class EmotesSettingsView extends StatelessWidget { child: TextField( maxLength: 256, controller: controller.packAttributionController, - readOnly: true, //controller.readonly, + readOnly: controller.readonly, + onSubmitted: (_) => controller.submitAttributionAction(), decoration: InputDecoration( counter: const SizedBox.shrink(), labelText: L10n.of(context).attribution, @@ -360,6 +362,7 @@ class _EmoteImage extends StatelessWidget { width: size, height: size, isThumbnail: true, + animated: true, ), ); } From 85500e76c2b7d71edcab5b4bdfad584644abeb2b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 20 Nov 2025 21:08:45 +0000 Subject: [PATCH 13/13] build: (deps): bump actions/checkout from 5 to 6 Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/check_duplicates.yaml | 2 +- .github/workflows/integrate.yaml | 10 +++++----- .github/workflows/main_deploy.yaml | 4 ++-- .github/workflows/release.yaml | 10 +++++----- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/check_duplicates.yaml b/.github/workflows/check_duplicates.yaml index 6a761f554..c0fa53130 100644 --- a/.github/workflows/check_duplicates.yaml +++ b/.github/workflows/check_duplicates.yaml @@ -13,7 +13,7 @@ jobs: number: ${{ github.event.issue.number }} GH_TOKEN: ${{ github.token }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Check duplicates run: | title=$(printf %q "${{ env.title }}") diff --git a/.github/workflows/integrate.yaml b/.github/workflows/integrate.yaml index c21bda7ef..9c8cbbb64 100644 --- a/.github/workflows/integrate.yaml +++ b/.github/workflows/integrate.yaml @@ -8,7 +8,7 @@ jobs: code_tests: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - run: ./scripts/generate-locale-config.sh - run: git diff --exit-code - run: cat .github/workflows/versions.env >> $GITHUB_ENV @@ -33,7 +33,7 @@ jobs: build_debug_apk: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - run: cat .github/workflows/versions.env >> $GITHUB_ENV - uses: actions/setup-java@v5 with: @@ -51,7 +51,7 @@ jobs: build_debug_web: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - run: cat .github/workflows/versions.env >> $GITHUB_ENV - uses: subosito/flutter-action@v2 with: @@ -70,7 +70,7 @@ jobs: arch: [ x64, arm64 ] runs-on: ${{ matrix.arch == 'arm64' && 'ubuntu-24.04-arm' || 'ubuntu-latest'}} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - run: cat .github/workflows/versions.env >> $GITHUB_ENV - name: Install dependencies run: sudo apt-get update && sudo apt-get install git wget curl clang cmake ninja-build pkg-config libgtk-3-dev libblkid-dev liblzma-dev libjsoncpp-dev cmake-data libsecret-1-dev libsecret-1-0 librhash0 libssl-dev libwebkit2gtk-4.1-dev -y @@ -85,7 +85,7 @@ jobs: build_debug_ios: runs-on: macos-15 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - run: cat .github/workflows/versions.env >> $GITHUB_ENV - uses: subosito/flutter-action@v2 with: diff --git a/.github/workflows/main_deploy.yaml b/.github/workflows/main_deploy.yaml index 4cd67bfc3..4636ce7f7 100644 --- a/.github/workflows/main_deploy.yaml +++ b/.github/workflows/main_deploy.yaml @@ -14,7 +14,7 @@ jobs: deploy_web: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - run: cat .github/workflows/versions.env >> $GITHUB_ENV - uses: subosito/flutter-action@v2 with: @@ -39,7 +39,7 @@ jobs: deploy_playstore_internal: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - run: cat .github/workflows/versions.env >> $GITHUB_ENV - uses: actions/setup-java@v5 with: diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 6737d5b1e..bf9644fb4 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -17,7 +17,7 @@ jobs: build_web: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - run: cat .github/workflows/versions.env >> $GITHUB_ENV - uses: subosito/flutter-action@v2 with: @@ -72,7 +72,7 @@ jobs: build_apk: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - run: cat .github/workflows/versions.env >> $GITHUB_ENV - uses: actions/setup-java@v5 with: @@ -112,7 +112,7 @@ jobs: arch: [ x64, arm64 ] runs-on: ${{ matrix.arch == 'arm64' && 'ubuntu-24.04-arm' || 'ubuntu-latest'}} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - run: cat .github/workflows/versions.env >> $GITHUB_ENV - name: Install dependencies run: sudo apt-get update && sudo apt-get install curl clang cmake ninja-build pkg-config libgtk-3-dev libblkid-dev liblzma-dev libjsoncpp-dev cmake-data libsecret-1-dev libsecret-1-0 librhash0 libssl-dev libwebkit2gtk-4.1-dev -y @@ -138,7 +138,7 @@ jobs: deploy_playstore: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - run: cat .github/workflows/versions.env >> $GITHUB_ENV - uses: actions/setup-java@v5 with: @@ -195,7 +195,7 @@ jobs: packages: write steps: - name: Check out Git repository - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Log in to the Container registry uses: docker/login-action@v3 with: