From 5a44b3b3d1fe99e1b574fd53b32070f56acf8dfb Mon Sep 17 00:00:00 2001 From: Sorunome Date: Sat, 24 Jul 2021 18:19:59 +0200 Subject: [PATCH] chore: cleanup image pack editor to attach metadata and prepare to make it more general --- lib/pages/settings_emotes.dart | 159 ++++------ lib/pages/views/settings_emotes_view.dart | 364 +++++++++++----------- lib/utils/resize_image.dart | 25 +- 3 files changed, 269 insertions(+), 279 deletions(-) diff --git a/lib/pages/settings_emotes.dart b/lib/pages/settings_emotes.dart index bc8b0149..26bdb460 100644 --- a/lib/pages/settings_emotes.dart +++ b/lib/pages/settings_emotes.dart @@ -2,16 +2,15 @@ import 'package:adaptive_dialog/adaptive_dialog.dart'; import 'package:matrix/matrix.dart'; import 'package:file_picker_cross/file_picker_cross.dart'; -import 'package:fluffychat/utils/platform_infos.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:vrouter/vrouter.dart'; import 'package:future_loading_dialog/future_loading_dialog.dart'; -import 'package:image_picker/image_picker.dart'; import 'views/settings_emotes_view.dart'; import '../widgets/matrix.dart'; +import '../utils/resize_image.dart'; class EmotesSettings extends StatefulWidget { EmotesSettings({Key key}) : super(key: key); @@ -20,24 +19,18 @@ class EmotesSettings extends StatefulWidget { EmotesSettingsController createState() => EmotesSettingsController(); } -class EmoteEntry { - String emote; - String mxc; - EmoteEntry({this.emote, this.mxc}); -} - class EmotesSettingsController extends State { String get roomId => VRouter.of(context).pathParameters['roomid']; Room get room => roomId != null ? Matrix.of(context).client.getRoomById(roomId) : null; String get stateKey => VRouter.of(context).pathParameters['state_key']; - List emotes; bool showSave = false; - TextEditingController newEmoteController = TextEditingController(); - TextEditingController newMxcController = TextEditingController(); + TextEditingController newImageCodeController = TextEditingController(); + ValueNotifier newImageController = + ValueNotifier(null); - ImagePackContent _getPack(BuildContext context) { + ImagePackContent _getPack() { final client = Matrix.of(context).client; final event = (room != null ? room.getState('im.ponies.room_emotes', stateKey ?? '') @@ -50,33 +43,20 @@ class EmotesSettingsController extends State { return BasicEvent.fromJson(event.toJson()).parsedImagePackContent; } + ImagePackContent _pack; + ImagePackContent get pack { + if (_pack != null) { + return _pack; + } + _pack = _getPack(); + return _pack; + } + Future _save(BuildContext context) async { if (readonly) { return; } final client = Matrix.of(context).client; - final pack = _getPack(context); - // add / update changed emotes - final allowedShortcodes = {}; - for (final emote in emotes) { - allowedShortcodes.add(emote.emote); - if (pack.images.containsKey(emote.emote)) { - pack.images[emote.emote].url = Uri.parse(emote.mxc); - } else { - pack.images[emote.emote] = - ImagePackImageContent.fromJson({ - 'url': emote.mxc, - }); - } - } - // remove emotes no more needed - // we make the iterator .toList() here so that we don't get into trouble modifying the very - // thing we are iterating over - for (final shortcode in pack.images.keys.toList()) { - if (!allowedShortcodes.contains(shortcode)) { - pack.images.remove(shortcode); - } - } if (room != null) { await showFutureLoadingDialog( context: context, @@ -121,19 +101,19 @@ class EmotesSettingsController extends State { setState(() => null); } - void removeEmoteAction(EmoteEntry emote) => setState(() { - emotes.removeWhere((e) => e.emote == emote.emote); + void removeImageAction(String oldImageCode) => setState(() { + pack.images.remove(oldImageCode); showSave = true; }); - void submitEmoteAction( - String emoteCode, - EmoteEntry emote, + void submitImageAction( + String oldImageCode, + String imageCode, + ImagePackImageContent image, TextEditingController controller, ) { - if (emotes.indexWhere((e) => e.emote == emoteCode && e.mxc != emote.mxc) != - -1) { - controller.text = emote.emote; + if (pack.images.keys.any((k) => k == imageCode && k != oldImageCode)) { + controller.text = oldImageCode; showOkAlertDialog( useRootNavigator: false, context: context, @@ -142,8 +122,8 @@ class EmotesSettingsController extends State { ); return; } - if (!RegExp(r'^[-\w]+$').hasMatch(emoteCode)) { - controller.text = emote.emote; + if (!RegExp(r'^[-\w]+$').hasMatch(imageCode)) { + controller.text = oldImageCode; showOkAlertDialog( useRootNavigator: false, context: context, @@ -153,7 +133,8 @@ class EmotesSettingsController extends State { return; } setState(() { - emote.emote = emoteCode; + pack.images[imageCode] = image; + pack.images.remove(oldImageCode); showSave = true; }); } @@ -177,11 +158,10 @@ class EmotesSettingsController extends State { }); } - void addEmoteAction() async { - if (newEmoteController.text == null || - newEmoteController.text.isEmpty || - newMxcController.text == null || - newMxcController.text.isEmpty) { + void addImageAction() async { + if (newImageCodeController.text == null || + newImageCodeController.text.isEmpty || + newImageController.value == null) { await showOkAlertDialog( useRootNavigator: false, context: context, @@ -190,9 +170,8 @@ class EmotesSettingsController extends State { ); return; } - final emoteCode = '${newEmoteController.text}'; - final mxc = newMxcController.text; - if (emotes.indexWhere((e) => e.emote == emoteCode && e.mxc != mxc) != -1) { + final imageCode = newImageCodeController.text; + if (pack.images.containsKey(imageCode)) { await showOkAlertDialog( useRootNavigator: false, context: context, @@ -201,7 +180,7 @@ class EmotesSettingsController extends State { ); return; } - if (!RegExp(r'^[-\w]+$').hasMatch(emoteCode)) { + if (!RegExp(r'^[-\w]+$').hasMatch(imageCode)) { await showOkAlertDialog( useRootNavigator: false, context: context, @@ -210,41 +189,28 @@ class EmotesSettingsController extends State { ); return; } - emotes.add(EmoteEntry(emote: emoteCode, mxc: mxc)); + pack.images[imageCode] = newImageController.value; await _save(context); setState(() { - newEmoteController.text = ''; - newMxcController.text = ''; + newImageCodeController.text = ''; + newImageController.value = null; showSave = false; }); } - void emoteImagePickerAction(TextEditingController controller) async { - if (kIsWeb) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(L10n.of(context).notSupportedInWeb))); - return; - } - MatrixFile file; - if (PlatformInfos.isMobile) { - final result = await ImagePicker().getImage( - source: ImageSource.gallery, - imageQuality: 50, - maxWidth: 1600, - maxHeight: 1600); - if (result == null) return; - file = MatrixFile( - bytes: await result.readAsBytes(), - name: result.path, - ); - } else { - final result = - await FilePickerCross.importFromStorage(type: FileTypeCross.image); - if (result == null) return; - file = MatrixFile( - bytes: result.toUint8List(), - name: result.fileName, - ); + void imagePickerAction( + ValueNotifier controller) async { + final result = + await FilePickerCross.importFromStorage(type: FileTypeCross.image); + if (result == null) return; + var file = MatrixImageFile( + bytes: result.toUint8List(), + name: result.fileName, + ); + try { + file = await resizeImage(file, max: 1600); + } catch (_) { + // do nothing } final uploadResp = await showFutureLoadingDialog( context: context, @@ -253,21 +219,30 @@ class EmotesSettingsController extends State { ); if (uploadResp.error == null) { setState(() { - controller.text = uploadResp.result; + 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, + 'info': info, + }); }); } } @override Widget build(BuildContext context) { - if (emotes == null) { - emotes = []; - final pack = _getPack(context); - for (final entry in pack.images.entries) { - emotes - .add(EmoteEntry(emote: entry.key, mxc: entry.value.url.toString())); - } - } return EmotesSettingsView(this); } } diff --git a/lib/pages/views/settings_emotes_view.dart b/lib/pages/views/settings_emotes_view.dart index b00fdd69..aca94202 100644 --- a/lib/pages/views/settings_emotes_view.dart +++ b/lib/pages/views/settings_emotes_view.dart @@ -17,6 +17,7 @@ class EmotesSettingsView extends StatelessWidget { @override Widget build(BuildContext context) { final client = Matrix.of(context).client; + final imageKeys = controller.pack.images.keys.toList(); return Scaffold( appBar: AppBar( leading: BackButton(), @@ -29,200 +30,195 @@ class EmotesSettingsView extends StatelessWidget { ) : null, body: MaxWidthBody( - child: StreamBuilder( - stream: controller.room?.onUpdate?.stream, - builder: (context, snapshot) { - return Column( - children: [ - if (!controller.readonly) - Container( - padding: EdgeInsets.symmetric( - vertical: 8.0, + child: Column( + children: [ + if (!controller.readonly) + Container( + padding: EdgeInsets.symmetric( + vertical: 8.0, + ), + child: ListTile( + leading: Container( + width: 180.0, + height: 38, + padding: EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(10)), + color: Theme.of(context).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.of(context).colorScheme.secondary, + fontWeight: FontWeight.bold, + ), + suffixStyle: TextStyle( + color: Theme.of(context).colorScheme.secondary, + fontWeight: FontWeight.bold, + ), + border: InputBorder.none, ), - child: ListTile( - leading: Container( - width: 180.0, - height: 38, - padding: EdgeInsets.symmetric(horizontal: 8), - decoration: BoxDecoration( - borderRadius: BorderRadius.all(Radius.circular(10)), - color: Theme.of(context).secondaryHeaderColor, - ), - child: TextField( - controller: controller.newEmoteController, - autocorrect: false, - minLines: 1, - maxLines: 1, - decoration: InputDecoration( - hintText: L10n.of(context).emoteShortcode, - prefixText: ': ', - suffixText: ':', - prefixStyle: TextStyle( - color: Theme.of(context).colorScheme.secondary, - fontWeight: FontWeight.bold, - ), - suffixStyle: TextStyle( - color: Theme.of(context).colorScheme.secondary, - fontWeight: FontWeight.bold, - ), - border: InputBorder.none, + ), + ), + title: _ImagePicker( + controller: controller.newImageController, + onPressed: controller.imagePickerAction, + ), + trailing: InkWell( + onTap: controller.addImageAction, + child: Icon( + Icons.add_outlined, + color: Colors.green, + size: 32.0, + ), + ), + ), + ), + if (controller.room != null) + ListTile( + title: Text(L10n.of(context).enableEmotesGlobally), + trailing: Switch( + value: controller.isGloballyActive(client), + onChanged: controller.setIsGloballyActive, + ), + ), + if (!controller.readonly || controller.room != null) + Divider( + height: 2, + thickness: 2, + color: Theme.of(context).primaryColor, + ), + Expanded( + child: imageKeys.isEmpty + ? Center( + child: Padding( + padding: EdgeInsets.all(16), + child: Text( + L10n.of(context).noEmotesFound, + style: TextStyle(fontSize: 20), + ), + ), + ) + : ListView.separated( + separatorBuilder: (BuildContext context, int i) => + Container(), + itemCount: imageKeys.length + 1, + 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(); + textEditingController.text = imageCode; + final useShortCuts = + (PlatformInfos.isWeb || PlatformInfos.isDesktop); + return ListTile( + leading: Container( + width: 180.0, + height: 38, + padding: EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + borderRadius: + BorderRadius.all(Radius.circular(10)), + color: Theme.of(context).secondaryHeaderColor, ), - ), - ), - title: _EmoteImagePicker( - controller: controller.newMxcController, - onPressed: controller.emoteImagePickerAction, - ), - trailing: InkWell( - onTap: controller.addEmoteAction, - child: Icon( - Icons.add_outlined, - color: Colors.green, - size: 32.0, - ), - ), - ), - ), - if (controller.room != null) - ListTile( - title: Text(L10n.of(context).enableEmotesGlobally), - trailing: Switch( - value: controller.isGloballyActive(client), - onChanged: controller.setIsGloballyActive, - ), - ), - if (!controller.readonly || controller.room != null) - Divider( - height: 2, - thickness: 2, - color: Theme.of(context).primaryColor, - ), - Expanded( - child: controller.emotes.isEmpty - ? Center( - child: Padding( - padding: EdgeInsets.all(16), - child: Text( - L10n.of(context).noEmotesFound, - style: TextStyle(fontSize: 20), - ), - ), - ) - : ListView.separated( - separatorBuilder: (BuildContext context, int i) => - Container(), - itemCount: controller.emotes.length + 1, - itemBuilder: (BuildContext context, int i) { - if (i >= controller.emotes.length) { - return Container(height: 70); - } - final emote = controller.emotes[i]; - final textEditingController = - TextEditingController(); - textEditingController.text = emote.emote; - final useShortCuts = (PlatformInfos.isWeb || - PlatformInfos.isDesktop); - return ListTile( - leading: Container( - width: 180.0, - height: 38, - padding: EdgeInsets.symmetric(horizontal: 8), - decoration: BoxDecoration( - borderRadius: - BorderRadius.all(Radius.circular(10)), - color: - Theme.of(context).secondaryHeaderColor, - ), - child: Shortcuts( - shortcuts: !useShortCuts - ? {} - : { - LogicalKeySet( - LogicalKeyboardKey.enter): - SubmitLineIntent(), - }, - child: Actions( - actions: !useShortCuts - ? {} - : { - SubmitLineIntent: - CallbackAction(onInvoke: (i) { - controller.submitEmoteAction( - textEditingController.text, - emote, - 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.of(context) - .colorScheme - .secondary, - fontWeight: FontWeight.bold, - ), - suffixStyle: TextStyle( - color: Theme.of(context) - .colorScheme - .secondary, - fontWeight: FontWeight.bold, - ), - border: InputBorder.none, - ), - onSubmitted: (s) => - controller.submitEmoteAction( - s, - emote, - textEditingController, - ), - ), + 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, + decoration: InputDecoration( + hintText: L10n.of(context).emoteShortcode, + prefixText: ': ', + suffixText: ':', + prefixStyle: TextStyle( + color: Theme.of(context) + .colorScheme + .secondary, + fontWeight: FontWeight.bold, ), + suffixStyle: TextStyle( + color: Theme.of(context) + .colorScheme + .secondary, + fontWeight: FontWeight.bold, + ), + border: InputBorder.none, + ), + onSubmitted: (s) => + controller.submitImageAction( + imageCode, + s, + image, + textEditingController, ), ), - title: _EmoteImage(emote.mxc), - trailing: controller.readonly - ? null - : InkWell( - onTap: () => - controller.removeEmoteAction(emote), - child: Icon( - Icons.delete_forever_outlined, - color: Colors.red, - size: 32.0, - ), - ), - ); - }, + ), + ), ), - ), - ], - ); - }), + title: _EmoteImage(image.url), + trailing: controller.readonly + ? null + : InkWell( + onTap: () => + controller.removeImageAction(imageCode), + child: Icon( + Icons.delete_forever_outlined, + color: Colors.red, + size: 32.0, + ), + ), + ); + }, + ), + ), + ], + ), ), ); } } class _EmoteImage extends StatelessWidget { - final String mxc; + final Uri mxc; _EmoteImage(this.mxc); @override Widget build(BuildContext context) { final size = 38.0; final devicePixelRatio = MediaQuery.of(context).devicePixelRatio; - final url = Uri.parse(mxc)?.getThumbnail( + final url = mxc?.getThumbnail( Matrix.of(context).client, width: size * devicePixelRatio, height: size * devicePixelRatio, @@ -237,27 +233,27 @@ class _EmoteImage extends StatelessWidget { } } -class _EmoteImagePicker extends StatefulWidget { - final TextEditingController controller; +class _ImagePicker extends StatefulWidget { + final ValueNotifier controller; - final void Function(TextEditingController) onPressed; + final void Function(ValueNotifier) onPressed; - _EmoteImagePicker({@required this.controller, @required this.onPressed}); + _ImagePicker({@required this.controller, @required this.onPressed}); @override - _EmoteImagePickerState createState() => _EmoteImagePickerState(); + _ImagePickerState createState() => _ImagePickerState(); } -class _EmoteImagePickerState extends State<_EmoteImagePicker> { +class _ImagePickerState extends State<_ImagePicker> { @override Widget build(BuildContext context) { - if (widget.controller.text == null || widget.controller.text.isEmpty) { + if (widget.controller.value == null) { return ElevatedButton( onPressed: () => widget.onPressed(widget.controller), child: Text(L10n.of(context).pickImage), ); } else { - return _EmoteImage(widget.controller.text); + return _EmoteImage(widget.controller.value.url); } } } diff --git a/lib/utils/resize_image.dart b/lib/utils/resize_image.dart index 47b9f04e..1522f463 100644 --- a/lib/utils/resize_image.dart +++ b/lib/utils/resize_image.dart @@ -12,7 +12,16 @@ Future resizeImage(MatrixImageFile file, // freeze up the UI a bit // we can't do width / height fetching in a separate isolate, as that may use the UI stuff - await native.init(); + + // somehow doing native.init twice fixes it for linux desktop? + // TODO: once native imaging is on sound null safety the errors are consistent and + // then we can properly handle this instead + // https://gitlab.com/famedly/company/frontend/libraries/native_imaging/-/issues/5 + try { + await native.init(); + } catch (_) { + await native.init(); + } _IsolateArgs args; try { @@ -47,9 +56,14 @@ Future resizeImage(MatrixImageFile file, mimeType: 'image/jpeg', width: res.width, height: res.height, + blurhash: res.blurhash, ); // only return the thumbnail if the size actually decreased - return thumbnail.size >= file.size ? file : thumbnail; + return thumbnail.size >= file.size || + thumbnail.width >= file.width || + thumbnail.height >= file.height + ? file + : thumbnail; } class _IsolateArgs { @@ -70,7 +84,12 @@ class _IsolateResponse { } Future<_IsolateResponse> _isolateFunction(_IsolateArgs args) async { - await native.init(); + // Hack for desktop, see above why + try { + await native.init(); + } catch (_) { + await native.init(); + } var nativeImg = native.Image(); try {