From a8de718cbb25beb28f6eb7dd08b1824fb757fbf6 Mon Sep 17 00:00:00 2001 From: sherlock Date: Sun, 8 Oct 2023 22:42:49 +0700 Subject: [PATCH] TW-731: add image copy/paste in android [temp] --- lib/pages/chat/chat.dart | 37 ++++- lib/pages/chat/chat_view.dart | 2 +- lib/pages/chat/input_bar.dart | 62 ++++++++ lib/pages/chat/send_file_dialog.dart | 82 ++++------ .../model/clipboard/clipboard_image_info.dart | 19 +++ lib/utils/clipboard.dart | 147 ++++++++++++++++++ 6 files changed, 296 insertions(+), 53 deletions(-) create mode 100644 lib/presentation/model/clipboard/clipboard_image_info.dart create mode 100644 lib/utils/clipboard.dart diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index 9af39f1725..fb46ae9518 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -30,6 +30,7 @@ import 'package:fluffychat/presentation/mixins/media_picker_mixin.dart'; import 'package:fluffychat/presentation/mixins/send_files_mixin.dart'; import 'package:fluffychat/presentation/model/forward/forward_argument.dart'; import 'package:fluffychat/utils/adaptive_bottom_sheet.dart'; +import 'package:fluffychat/utils/clipboard.dart'; import 'package:fluffychat/utils/extension/build_context_extension.dart'; import 'package:fluffychat/utils/extension/value_notifier_extension.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/event_extension.dart'; @@ -45,7 +46,7 @@ import 'package:fluffychat/widgets/mixins/popup_context_menu_action_mixin.dart'; import 'package:fluffychat/widgets/mixins/popup_menu_widget_mixin.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; -import 'package:flutter/services.dart'; +import 'package:flutter/services.dart' as flutter; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart'; import 'package:fluttertoast/fluttertoast.dart'; @@ -756,8 +757,40 @@ class ChatController extends State return copyString; } + void copySingleEventAction() async { + if (selectedEvents.length == 1) { + final event = selectedEvents.first; + final matrixFile = event.getMatrixFile() ?? + await event.downloadAndDecryptAttachment( + getThumbnail: true, + ); + if (event.messageType == MessageTypes.Image) { + if (matrixFile.filePath != null) { + Clipboard.instance.copyImageAsStream( + File(matrixFile.filePath!), + mimeType: event.mimeType, + ); + } else if (matrixFile.bytes != null) { + Clipboard.instance.copyImageAsBytes( + matrixFile.bytes!, + mimeType: event.mimeType, + ); + } else { + Logs().e( + 'copySingleEventAction(): failed to copy file ${matrixFile.name}', + ); + } + } + } + } + void copyEventsAction() { - Clipboard.setData(ClipboardData(text: _getSelectedEventString())); + flutter.Clipboard.setData( + flutter.ClipboardData( + text: _getSelectedEventString(), + ), + ); + showEmojiPickerNotifier.value = false; setState(() { selectedEvents.clear(); diff --git a/lib/pages/chat/chat_view.dart b/lib/pages/chat/chat_view.dart index e84b126f2e..9c01c495f0 100644 --- a/lib/pages/chat/chat_view.dart +++ b/lib/pages/chat/chat_view.dart @@ -37,7 +37,7 @@ class ChatView extends StatelessWidget { TwakeIconButton( icon: Icons.copy_outlined, tooltip: L10n.of(context)!.copy, - onTap: controller.copyEventsAction, + onTap: controller.copySingleEventAction, ), if (controller.canRedactSelectedEvents) TwakeIconButton( diff --git a/lib/pages/chat/input_bar.dart b/lib/pages/chat/input_bar.dart index bce81fe17c..0be7ebb1df 100644 --- a/lib/pages/chat/input_bar.dart +++ b/lib/pages/chat/input_bar.dart @@ -1,3 +1,8 @@ +import 'dart:typed_data'; + +import 'package:fluffychat/pages/chat/send_file_dialog.dart'; +import 'package:fluffychat/presentation/model/clipboard/clipboard_image_info.dart'; +import 'package:fluffychat/utils/clipboard.dart'; import 'package:fluffychat/utils/extension/raw_key_event_extension.dart'; import 'package:fluffychat/widgets/avatar/avatar.dart'; import 'package:fluffychat/widgets/matrix.dart'; @@ -339,6 +344,63 @@ class InputBar extends StatelessWidget { onChanged!(text); }, textCapitalization: TextCapitalization.sentences, + contextMenuBuilder: ( + BuildContext context, + EditableTextState editableTextState, + ) { + return AdaptiveTextSelectionToolbar.editable( + anchors: editableTextState.contextMenuAnchors, + clipboardStatus: ClipboardStatus.pasteable, + // to apply the normal behavior when click on copy (copy in clipboard close toolbar) + // use an empty function `() {}` to hide this option from the toolbar + onCopy: () => editableTextState + .copySelection(SelectionChangedCause.toolbar), + // to apply the normal behavior when click on cut + onCut: () => + editableTextState.cutSelection(SelectionChangedCause.toolbar), + onPaste: () async { + if (room == null) { + // FIXME: need to handle the case when in draft chat + return; + } + // HERE will be called when the paste button is clicked in the toolbar + // apply your own logic here + + if (await Clipboard.instance.isReadableImageFormat()) { + await Clipboard.instance + .readImage((ClipboardImageInfo imageClipboard) async { + // FIXME: temp demo for demo in upstream + final data = await imageClipboard.stream.toList(); + final imageData = Uint8List.fromList( + data.expand((Uint8List uint8List) => uint8List).toList(), + ); + await showDialog( + context: context, + builder: (context) { + return SendFileDialog( + room: room!, + files: [ + MatrixFile( + name: imageClipboard.fileName ?? 'copied', + bytes: imageData, + ) + ], + ); + }, + ); + }); + } else if (Clipboard.instance.isReadableTextFormat()) { + editableTextState.pasteText(SelectionChangedCause.toolbar); + } + + // to apply the normal behavior when click on paste (add in input and close toolbar) + // editableTextState.pasteText(SelectionChangedCause.toolbar); + }, + // to apply the normal behavior when click on select all + onSelectAll: () => + editableTextState.selectAll(SelectionChangedCause.toolbar), + ); + }, ), suggestionsCallback: getSuggestions, itemBuilder: (context, suggestion) => SuggestionTile( diff --git a/lib/pages/chat/send_file_dialog.dart b/lib/pages/chat/send_file_dialog.dart index 42bf1949e9..7ed2e9e29b 100644 --- a/lib/pages/chat/send_file_dialog.dart +++ b/lib/pages/chat/send_file_dialog.dart @@ -1,10 +1,10 @@ +import 'package:fluffychat/presentation/extensions/send_file_extension.dart'; +import 'package:fluffychat/presentation/extensions/send_file_web_extension.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:future_loading_dialog/future_loading_dialog.dart'; import 'package:matrix/matrix.dart'; -import 'package:fluffychat/utils/size_string.dart'; import '../../utils/resize_image.dart'; class SendFileDialog extends StatefulWidget { @@ -22,8 +22,6 @@ class SendFileDialog extends StatefulWidget { } class SendFileDialogState extends State { - bool origImage = false; - /// Images smaller than 20kb don't need compression. static const int minSizeToCompress = 20 * 1024; @@ -43,18 +41,13 @@ class SendFileDialogState extends State { } // ignore: unused_local_variable final scaffoldMessenger = ScaffoldMessenger.of(context); - // widget.room - // .sendFileEvent( - // file, - // thumbnail: thumbnail, - // shrinkImageMaxDimension: origImage ? null : 1600, - // ) - // .catchError((e) { - // scaffoldMessenger.showSnackBar( - // SnackBar(content: Text((e as Object).toLocalizedString(context))), - // ); - // return null; - // }); + if (file.filePath != null) { + widget.room.sendFileEvent( + FileInfo(file.name, file.filePath!, file.size), + ); + } else { + widget.room.sendFileOnWebEvent(file); + } } Navigator.of(context, rootNavigator: false).pop(); @@ -63,25 +56,25 @@ class SendFileDialogState extends State { @override Widget build(BuildContext context) { - var sendStr = L10n.of(context)!.sendFile; - final bool allFilesAreImages = - widget.files.every((file) => file is MatrixImageFile); - final sizeString = widget.files - .fold(0, (p, file) => p + (file.bytes?.length ?? 0)) - .sizeString; - final fileName = widget.files.length == 1 - ? widget.files.single.name - : L10n.of(context)!.countFiles(widget.files.length.toString()); + // var sendStr = L10n.of(context)!.sendFile; + // final bool allFilesAreImages = + // widget.files.every((file) => file is MatrixImageFile); + // final sizeString = widget.files + // .fold(0, (p, file) => p + (file.bytes?.length ?? 0)) + // .sizeString; + // final fileName = widget.files.length == 1 + // ? widget.files.single.name + // : L10n.of(context)!.countFiles(widget.files.length.toString()); - if (allFilesAreImages) { - sendStr = L10n.of(context)!.sendImage; - } else if (widget.files.every((file) => file is MatrixAudioFile)) { - sendStr = L10n.of(context)!.sendAudio; - } else if (widget.files.every((file) => file is MatrixVideoFile)) { - sendStr = L10n.of(context)!.sendVideo; - } + // if (allFilesAreImages) { + // sendStr = L10n.of(context)!.sendImage; + // } else if (widget.files.every((file) => file is MatrixAudioFile)) { + // sendStr = L10n.of(context)!.sendAudio; + // } else if (widget.files.every((file) => file is MatrixVideoFile)) { + // sendStr = L10n.of(context)!.sendVideo; + // } Widget contentWidget; - if (allFilesAreImages) { + if (true) { contentWidget = Column( mainAxisSize: MainAxisSize.min, children: [ @@ -91,25 +84,14 @@ class SendFileDialogState extends State { fit: BoxFit.contain, ), ), - Row( - children: [ - Checkbox( - value: origImage, - onChanged: (v) => setState(() => origImage = v ?? false), - ), - InkWell( - onTap: () => setState(() => origImage = !origImage), - child: Text('${L10n.of(context)!.sendOriginal} ($sizeString)'), - ), - ], - ) ], ); - } else { - contentWidget = Text('$fileName ($sizeString)'); } + // } else { + // contentWidget = Text('$fileName ($sizeString)'); + // } return AlertDialog( - title: Text(sendStr), + title: const Text('send Image'), content: contentWidget, actions: [ TextButton( @@ -117,11 +99,11 @@ class SendFileDialogState extends State { // just close the dialog Navigator.of(context, rootNavigator: false).pop(); }, - child: Text(L10n.of(context)!.cancel), + child: const Text("Cancel"), ), TextButton( onPressed: _send, - child: Text(L10n.of(context)!.send), + child: const Text("Send"), ), ], ); diff --git a/lib/presentation/model/clipboard/clipboard_image_info.dart b/lib/presentation/model/clipboard/clipboard_image_info.dart new file mode 100644 index 0000000000..a172e3183f --- /dev/null +++ b/lib/presentation/model/clipboard/clipboard_image_info.dart @@ -0,0 +1,19 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter/services.dart'; + +class ClipboardImageInfo with EquatableMixin { + final Stream stream; + + final String? fileName; + + final int? fileSize; + + ClipboardImageInfo({ + required this.stream, + this.fileName, + this.fileSize, + }); + + @override + List get props => [stream, fileName, fileSize]; +} diff --git a/lib/utils/clipboard.dart b/lib/utils/clipboard.dart new file mode 100644 index 0000000000..65269d2025 --- /dev/null +++ b/lib/utils/clipboard.dart @@ -0,0 +1,147 @@ +import 'dart:io'; + +import 'package:fluffychat/presentation/model/clipboard/clipboard_image_info.dart'; +import 'package:flutter/services.dart'; +import 'package:matrix/matrix.dart'; +import 'package:mime/mime.dart'; +import 'package:super_clipboard/super_clipboard.dart'; + +class Clipboard { + static final _clipboard = Clipboard._(); + + ClipboardReader? reader; + + Clipboard._(); + + static Clipboard get instance => _clipboard; + + static const allImageFormatsSupported = [ + Formats.png, + Formats.jpeg, + Formats.heic, + Formats.heif, + Formats.svg, + ]; + + Future initReader() async { + reader ??= await ClipboardReader.readClipboard(); + } + + Future>?> getReaderFormat() async { + initReader(); + if (reader == null || reader!.items.isEmpty) { + return null; + } + return reader!.items.first.getFormats(Formats.standardFormats); + } + + Future copyImageAsStream(File image, {String? mimeType}) async { + final item = DataWriterItem(suggestedName: image.path); + final imageStream = image.openRead(); + final mime = mimeType ?? lookupMimeType(image.path); + await imageStream.forEach((data) { + item.add(getFormatFrom(mime)(Uint8List.fromList(data))); + }); + try { + await ClipboardWriter.instance.write([item]); + } catch (e) { + Logs().e('Clipboard::copyImageAsStream(): $e'); + } + } + + Future copyImageAsBytes(Uint8List data, {String? mimeType}) async { + try { + final item = DataWriterItem(); + item.add(Formats.jpeg(data)); + await ClipboardWriter.instance.write([item]); + } catch (e) { + Logs().e('Clipboard::copyImageAsBytes(): $e'); + } + } + + Future readImage( + void Function(ClipboardImageInfo info) onGetFileDone, + ) async { + initReader(); + ReadProgress? readProgress; + if (!(await isReadableImageFormat())) { + return readProgress; + } + + final readableFormats = reader?.getFormats(allImageFormatsSupported); + if (readableFormats?.isEmpty != false && + readableFormats![0] is! SimpleFileFormat) { + return readProgress; + } + + ClipboardImageInfo? imageInfo; + final progress = reader?.getFile( + readableFormats![0] as SimpleFileFormat, + (file) { + imageInfo = ClipboardImageInfo( + stream: file.getStream(), + fileName: file.fileName, + fileSize: file.fileSize, + ); + onGetFileDone(imageInfo!); + }, + onError: (error) { + Logs().e('Clipboard::readImage(): $error'); + }, + ); + return progress; + } + + Future isReadableImageFormat() async { + await initReader(); + return reader != null && + (reader!.canProvide(Formats.png) || + reader!.canProvide(Formats.jpeg) || + reader!.canProvide(Formats.heic) || + reader!.canProvide(Formats.heif) || + reader!.canProvide(Formats.svg)); + } + + Future readText() async { + initReader(); + String? copied; + + if (!isReadableTextFormat()) { + return copied; + } + + final readersFormat = reader?.getFormats(Formats.standardFormats); + if (readersFormat == null || readersFormat.isEmpty) { + return copied; + } + + reader?.getValue( + Formats.plainText, + (value) => copied = value, + onError: (error) { + Logs().e('Clipboard::readText(): $error'); + }, + ); + return copied; + } + + bool isReadableTextFormat() { + initReader(); + return reader != null && reader!.canProvide(Formats.plainText); + } + + SimpleFileFormat getFormatFrom(String? mimeType) { + switch (mimeType) { + case 'image/png': + return Formats.png; + case 'image/jpeg': + return Formats.jpeg; + case 'image/heic': + return Formats.heic; + case 'image/heif': + return Formats.heif; + default: + return Formats.plainTextFile; + } + } +}