diff --git a/.github/workflows/check_duplicates.yaml b/.github/workflows/check_duplicates.yaml index 93e5722dc..f9c1a58d1 100644 --- a/.github/workflows/check_duplicates.yaml +++ b/.github/workflows/check_duplicates.yaml @@ -13,7 +13,7 @@ # 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 6e524cf4a..7cc825c9d 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: @@ -52,7 +52,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: @@ -71,7 +71,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 libcurl4-openssl-dev 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 libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev -y @@ -86,7 +86,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 f6e8d3e7f..dad9f5972 100644 --- a/.github/workflows/main_deploy.yaml +++ b/.github/workflows/main_deploy.yaml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest environment: staging steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - run: cat .github/workflows/versions.env >> $GITHUB_ENV - uses: subosito/flutter-action@v2 with: @@ -80,7 +80,7 @@ jobs: SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }} SENTRY_ORG: ${{ vars.SENTRY_ORG }} 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/release.yaml b/.github/workflows/release.yaml index ecbb7b004..6557278a6 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -40,7 +40,7 @@ jobs: runs-on: ubuntu-latest environment: production steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - run: cat .github/workflows/versions.env >> $GITHUB_ENV - uses: subosito/flutter-action@v2 with: @@ -90,7 +90,7 @@ jobs: env: WEB_APP_ENV: ${{ vars.WEB_APP_ENV }} 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/lib/config/routes.dart b/lib/config/routes.dart index ed8c53afa..dc596eb44 100644 --- a/lib/config/routes.dart +++ b/lib/config/routes.dart @@ -29,7 +29,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'; @@ -293,10 +292,7 @@ abstract class AppRoutes { // Pangea# : ChatList( activeChat: state.pathParameters['roomid'], - // #Pangea - // activeSpace: state.uri.queryParameters['spaceId'], - activeSpace: state.pathParameters['spaceid'], - // Pangea# + activeSpace: state.uri.queryParameters['spaceId'], ), ), routes: [ @@ -354,18 +350,6 @@ abstract class AppRoutes { redirect: loggedOutRedirect, ), // #Pangea - // ShellRoute( - // pageBuilder: (context, state, child) => defaultPageBuilder( - // context, - // state, - // FluffyThemes.isColumnMode(context) - // ? TwoColumnLayout( - // mainView: PangeaSideView(path: state.fullPath), - // sideView: child, - // ) - // : child, - // ), - // routes: [ GoRoute( path: 'course', pageBuilder: (context, state) => defaultPageBuilder( @@ -671,7 +655,9 @@ abstract class AppRoutes { pageBuilder: (context, state) => defaultPageBuilder( context, state, - const EmotesSettings(), + EmotesSettings( + roomId: state.pathParameters['roomid'], + ), ), ), ], @@ -990,6 +976,10 @@ abstract class AppRoutes { pageBuilder: (context, state) => defaultPageBuilder( context, state, + // #Pangea + // InvitationSelection( + // roomId: state.pathParameters['roomid']!, + // ), PangeaInvitationSelection( roomId: state.pathParameters['roomid']!, initialFilter: state.uri.queryParameters['filter'] != null @@ -998,6 +988,7 @@ abstract class AppRoutes { ) : null, ), + // Pangea# ), redirect: loggedOutRedirect, ), @@ -1049,42 +1040,20 @@ abstract class AppRoutes { // pageBuilder: (context, state) => defaultPageBuilder( // context, // state, - // PangeaInvitationSelection( + // InvitationSelection( // roomId: state.pathParameters['roomid']!, - // initialFilter: - // state.uri.queryParameters['filter'] != null - // ? InvitationFilter.fromString( - // state.uri.queryParameters['filter']!, - // ) - // : null, // ), // ), // 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, // ), @@ -1218,21 +1187,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(), + EmotesSettings( + roomId: state.pathParameters[roomKey]!, + ), ), redirect: loggedOutRedirect, ), @@ -1241,7 +1203,9 @@ abstract class AppRoutes { pageBuilder: (context, state) => defaultPageBuilder( context, state, - const EmotesSettings(), + EmotesSettings( + roomId: state.pathParameters[roomKey]!, + ), ), redirect: loggedOutRedirect, ), diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 945f4c3c7..d642b929a 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -3453,6 +3453,14 @@ }, "thread": "Thread", "backToMainChat": "Back to main chat", + "saveChanges": "Save changes", + "createSticker": "Create sticker or emoji", + "useAsSticker": "Use as sticker", + "useAsEmoji": "Use as emoji", + "stickerPackNameAlreadyExists": "Sticker pack name already exists", + "newStickerPack": "New sticker pack", + "stickerPackName": "Sticker pack name", + "attribution": "Attribution", "ignore": "Block", "ignoredUsers": "Blocked users", "writeAMessageLangCodes": "Type in {l1} or {l2}...", diff --git a/lib/pages/settings_emotes/settings_emotes.dart b/lib/pages/settings_emotes/settings_emotes.dart index 7823423dd..ffe039ee6 100644 --- a/lib/pages/settings_emotes/settings_emotes.dart +++ b/lib/pages/settings_emotes/settings_emotes.dart @@ -4,8 +4,6 @@ import 'package:archive/archive.dart' 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'; @@ -14,36 +12,60 @@ 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'; import 'settings_emotes_view.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 { - // #Pangea - // String? get roomId => GoRouterState.of(context).pathParameters['roomid']; - String? get roomId { - final pathParameters = GoRouterState.of(context).pathParameters; - return pathParameters['roomid'] ?? pathParameters['spaceid']; + late final Room? room; + + String? stateKey; + + 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; } - // Pangea# - Room? get room => - roomId != null ? Matrix.of(context).client.getRoomById(roomId!) : null; + @override + void initState() { + super.initState(); + room = widget.roomId != null + ? Matrix.of(context).client.getRoomById(widget.roomId!) + : null; + setStateKey(packKeys?.firstOrNull, reset: false); + } - String? get stateKey => GoRouterState.of(context).pathParameters['state_key']; + void setStateKey(String? key, {reset = true}) { + stateKey = key; + + 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; - TextEditingController newImageCodeController = TextEditingController(); - ValueNotifier newImageController = - ValueNotifier(null); ImagePackContent _getPack() { final client = Matrix.of(context).client; @@ -73,25 +95,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; + }); } } @@ -127,17 +149,56 @@ class EmotesSettingsController extends State { setState(() {}); } + final TextEditingController packDisplayNameController = + TextEditingController(); + + final TextEditingController packAttributionController = + TextEditingController(); + void removeImageAction(String oldImageCode) => setState(() { pack!.images.remove(oldImageCode); 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 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, - 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( @@ -173,8 +234,60 @@ class EmotesSettingsController extends State { ?.tryGetMap(stateKey ?? '') != null; - bool get readonly => - room == null ? false : !(room!.canSendEvent('im.ponies.room_emotes')); + bool get readonly => room == null + ? false + : room?.canChangeStateEvent('im.ponies.room_emotes') == false; + + void resetAction() { + setState(() { + _pack = _getPack(); + showSave = false; + }); + } + + 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); @@ -183,95 +296,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; - 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( + if (pickedFiles.isEmpty) return; + if (!mounted) return; + + await showFutureLoadingDialog( context: context, - future: () => Matrix.of(context).client.uploadContent( - file.bytes, - filename: file.name, - contentType: file.mimeType, - ), - ); - 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(); - } + 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, + }); + }); } - controller.value = ImagePackImageContent.fromJson({ - 'url': uploadResp.result.toString(), - 'info': info, - }); - }); - } + }, + ); + + 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 3134cba8d..d2f1b47ba 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'; @@ -19,108 +20,186 @@ 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( - 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), - ), - ], - ), - ], - ), - floatingActionButton: controller.showSave - ? FloatingActionButton( - onPressed: controller.saveAction, - child: const Icon(Icons.save_outlined, color: Colors.white), + child: Text(L10n.of(context).saveChanges), ) - : null, + 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), + ), + if (imageKeys.isNotEmpty) + PopupMenuItem( + value: PopupMenuEmojiActions.export, + child: Text(L10n.of(context).exportEmotePack), + ), + ], + ), + ], + 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 + 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]); + + final eventPack = + event?.content.tryGetMap('pack'); + final packName = + eventPack?.tryGet('display_name') ?? + eventPack?.tryGet('name') ?? + (key.isNotEmpty ? key : 'Default'); + + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 4.0, + ), + child: FilterChip( + label: Text(packName), + selected: controller.stateKey == key || + (controller.stateKey == null && key.isEmpty), + onSelected: controller.showSave + ? null + : (_) => controller.setStateKey(key), + ), + ); + }, + ), + ), + ), + ), + ), body: MaxWidthBody( child: Column( mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (!controller.readonly) - Container( - 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: _ImagePicker( - controller: controller.newImageController, - onPressed: controller.imagePickerAction, - ), - trailing: InkWell( - onTap: controller.addImageAction, - child: const Icon( - Icons.add_outlined, - color: Colors.green, - size: 32.0, - ), + if (controller.room != null) ...[ + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: TextField( + maxLength: 256, + controller: controller.packDisplayNameController, + readOnly: controller.readonly, + onSubmitted: (_) => controller.submitDisplaynameAction(), + decoration: InputDecoration( + counter: const SizedBox.shrink(), + hintText: controller.stateKey, + labelText: L10n.of(context).stickerPackName, ), ), ), - if (controller.room != null) + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: TextField( + maxLength: 256, + controller: controller.packAttributionController, + readOnly: controller.readonly, + onSubmitted: (_) => controller.submitAttributionAction(), + decoration: InputDecoration( + counter: const SizedBox.shrink(), + labelText: L10n.of(context).attribution, + ), + ), + ), + ], + if (!controller.readonly) ...[ + Padding( + padding: const EdgeInsets.all(16.0), + child: ElevatedButton.icon( + onPressed: controller.createStickers, + icon: const Icon(Icons.upload_outlined), + label: Text(L10n.of(context).createSticker), + ), + ), + 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( @@ -136,11 +215,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(); @@ -148,80 +224,110 @@ 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 - ? {} - : { - 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, + 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, + ), ), - }, - 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, + ), + onSubmitted: (s) => + controller.submitImageAction( + imageCode, + image, + textEditingController, + ), ), - suffixStyle: TextStyle( - color: theme.colorScheme.secondary, - fontWeight: FontWeight.bold, - ), - border: InputBorder.none, - ), - onSubmitted: (s) => - controller.submitImageAction( - imageCode, - s, - image, - textEditingController, ), ), ), - ), + 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), + ), + ], ), - 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), ), ); }, @@ -240,43 +346,26 @@ class _EmoteImage extends StatelessWidget { @override Widget build(BuildContext context) { - const size = 38.0; - return SizedBox.square( - dimension: size, + const size = 44.0; + final key = 'sticker_preview_$mxc'; + return InkWell( + borderRadius: BorderRadius.circular(4), + onTap: () => showDialog( + context: context, + builder: (_) => MxcImageViewer(mxc), + ), child: MxcImage( + key: ValueKey(key), + cacheKey: key, uri: mxc, fit: BoxFit.contain, width: size, height: size, - isThumbnail: false, + isThumbnail: true, + animated: true, ), ); } } -class _ImagePicker extends StatefulWidget { - final ValueNotifier controller; - - final void Function(ValueNotifier) onPressed; - - const _ImagePicker({required this.controller, required this.onPressed}); - - @override - _ImagePickerState createState() => _ImagePickerState(); -} - -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), - ); - } else { - return _EmoteImage(widget.controller.value!.url); - } - } -} - class SubmitLineIntent extends Intent {} 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 229a82104..000000000 --- a/lib/pages/settings_multiple_emotes/settings_multiple_emotes.dart +++ /dev/null @@ -1,26 +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 { - // #Pangea - // String? get roomId => GoRouterState.of(context).pathParameters['roomid']; - String? get roomId { - final pathParameters = GoRouterState.of(context).pathParameters; - return pathParameters['roomid'] ?? pathParameters['spaceid']; - } - // Pangea# - - @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 586312508..b41bbdfc5 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) {