diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 4b3aae7..3525633 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -65,6 +65,8 @@ PODS: - onnxruntime-c (= 1.22.0) - package_info_plus (0.4.5): - Flutter + - pasteboard (0.0.1): + - Flutter - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS @@ -120,6 +122,7 @@ DEPENDENCIES: - home_widget (from `.symlinks/plugins/home_widget/ios`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) + - pasteboard (from `.symlinks/plugins/pasteboard/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - quick_actions_ios (from `.symlinks/plugins/quick_actions_ios/ios`) - record_ios (from `.symlinks/plugins/record_ios/ios`) @@ -171,6 +174,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/image_picker_ios/ios" package_info_plus: :path: ".symlinks/plugins/package_info_plus/ios" + pasteboard: + :path: ".symlinks/plugins/pasteboard/ios" path_provider_foundation: :path: ".symlinks/plugins/path_provider_foundation/darwin" quick_actions_ios: @@ -218,6 +223,7 @@ SPEC CHECKSUMS: onnxruntime-c: 7f778680e96145956c0a31945f260321eed2611a onnxruntime-objc: 83d28b87525bd971259a66e153ea32b5d023de19 package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 + pasteboard: 49088aeb6119d51f976a421db60d8e1ab079b63c path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 quick_actions_ios: 500fcc11711d9f646739093395c4ae8eec25f779 record_ios: f75fa1d57f840012775c0e93a38a7f3ceea1a374 diff --git a/lib/features/chat/services/clipboard_attachment_service.dart b/lib/features/chat/services/clipboard_attachment_service.dart index 0bcd429..9987e0a 100644 --- a/lib/features/chat/services/clipboard_attachment_service.dart +++ b/lib/features/chat/services/clipboard_attachment_service.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'dart:typed_data'; import 'package:flutter/material.dart'; +import 'package:pasteboard/pasteboard.dart'; import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as path; import 'file_attachment_service.dart'; @@ -9,6 +10,11 @@ import 'file_attachment_service.dart'; /// /// This service converts pasted image data into [LocalAttachment] objects /// that integrate with the existing file attachment flow. +/// +/// Uses the pasteboard package to read images from the system clipboard, +/// which works across iOS, Android, and desktop platforms. On iOS 16+, +/// this properly handles privacy restrictions that prevent standard paste +/// operations from accessing image content. class ClipboardAttachmentService { /// Supported MIME types for image paste operations. static const Set supportedImageMimeTypes = { @@ -20,6 +26,95 @@ class ClipboardAttachmentService { 'image/bmp', }; + /// Reads an image from the system clipboard. + /// + /// Returns the image data as bytes if an image is present, null otherwise. + /// This uses the pasteboard package which properly interfaces with iOS's + /// UIPasteboard and works on other platforms as well. + Future getClipboardImage() async { + try { + final imageBytes = await Pasteboard.image; + return imageBytes; + } catch (e) { + debugPrint('ClipboardAttachmentService: Failed to read clipboard: $e'); + return null; + } + } + + /// Gets files from the clipboard (supported on desktop platforms). + /// + /// Returns a list of file paths if files are present, empty list otherwise. + Future> getClipboardFiles() async { + try { + final files = await Pasteboard.files(); + return files; + } catch (e) { + debugPrint( + 'ClipboardAttachmentService: Failed to read clipboard files: $e', + ); + return []; + } + } + + /// Checks if the clipboard currently contains image data. + /// + /// Note: This reads the full image data from the clipboard because the + /// pasteboard package doesn't provide a lightweight check method. On iOS, + /// this also triggers the paste confirmation dialog. + Future hasClipboardImage() async { + try { + // The pasteboard package doesn't have a dedicated hasImage method, + // so we need to attempt to read the image. On iOS this is required + // for the user to see the paste confirmation. + final imageBytes = await Pasteboard.image; + return imageBytes != null && imageBytes.isNotEmpty; + } catch (e) { + debugPrint('ClipboardAttachmentService: Failed to check clipboard: $e'); + return false; + } + } + + /// Creates a [LocalAttachment] from the current clipboard image. + /// + /// Works on iOS, Android, and desktop platforms via the pasteboard package. + /// Returns null if no image is in the clipboard or if the operation fails. + Future createAttachmentFromClipboard() async { + final imageData = await getClipboardImage(); + if (imageData == null || imageData.isEmpty) { + return null; + } + + // Pasteboard returns PNG data by default + return createAttachmentFromImageData( + imageData: imageData, + mimeType: 'image/png', + ); + } + + /// Creates [LocalAttachment]s from clipboard files. + /// + /// Useful on desktop platforms where files can be copied directly. + Future> createAttachmentsFromClipboardFiles() async { + final filePaths = await getClipboardFiles(); + final attachments = []; + + for (final filePath in filePaths) { + try { + final file = File(filePath); + if (await file.exists()) { + final fileName = path.basename(file.path); + attachments.add(LocalAttachment(file: file, displayName: fileName)); + } + } catch (e) { + debugPrint( + 'ClipboardAttachmentService: Failed to process file $filePath: $e', + ); + } + } + + return attachments; + } + /// Creates a [LocalAttachment] from pasted image data. /// /// The image data is saved to a temporary file with an appropriate extension diff --git a/lib/features/chat/views/chat_page.dart b/lib/features/chat/views/chat_page.dart index e88f721..19d979f 100644 --- a/lib/features/chat/views/chat_page.dart +++ b/lib/features/chat/views/chat_page.dart @@ -76,10 +76,6 @@ class _ChatPageState extends ConsumerState { return name.trim(); } - bool validateFileCount(int currentCount, int newCount, int maxCount) { - return (currentCount + newCount) <= maxCount; - } - bool validateFileSize(int fileSize, int maxSizeMB) { return fileSize <= (maxSizeMB * 1024 * 1024); } @@ -479,13 +475,6 @@ class _ChatPageState extends ConsumerState { final attachments = await fileService.pickFiles(); if (attachments.isEmpty) return; - // Validate file count - final currentFiles = ref.read(attachedFilesProvider); - if (!validateFileCount(currentFiles.length, attachments.length, 10)) { - if (!mounted) return; - return; - } - // Validate file sizes for (final attachment in attachments) { final fileSize = await attachment.file.length(); @@ -570,13 +559,6 @@ class _ChatPageState extends ConsumerState { return; } - // Validate file count (default 10 files limit like OpenWebUI) - final currentFiles = ref.read(attachedFilesProvider); - if (!validateFileCount(currentFiles.length, 1, 10)) { - if (!mounted) return; - return; - } - // Add image to the attachment list ref.read(attachedFilesProvider.notifier).addFiles([attachment]); DebugLogger.log('Image added to attachment list', scope: 'chat/page'); @@ -613,15 +595,11 @@ class _ChatPageState extends ConsumerState { scope: 'chat/page', ); - // Validate file count (default 10 files limit like OpenWebUI) - final currentFiles = ref.read(attachedFilesProvider); - if (!validateFileCount(currentFiles.length, attachments.length, 10)) { - if (!mounted) return; - return; - } + // Add attachments to the list + ref.read(attachedFilesProvider.notifier).addFiles(attachments); - // Validate file sizes and process each attachment - final validAttachments = []; + // Enqueue uploads via task queue for unified retry/progress + final activeConv = ref.read(activeConversationProvider); for (final attachment in attachments) { try { final fileSize = await attachment.file.length(); @@ -629,35 +607,6 @@ class _ChatPageState extends ConsumerState { 'Pasted file: ${attachment.displayName}, size: $fileSize bytes', scope: 'chat/page', ); - - // Validate file size (default 20MB limit like OpenWebUI) - if (!validateFileSize(fileSize, 20)) { - DebugLogger.log( - 'Skipping oversized pasted file: ${attachment.displayName}', - scope: 'chat/page', - ); - continue; - } - - validAttachments.add(attachment); - } catch (e) { - DebugLogger.log( - 'Error processing pasted attachment: $e', - scope: 'chat/page', - ); - } - } - - if (validAttachments.isEmpty) return; - - // Add attachments to the list - ref.read(attachedFilesProvider.notifier).addFiles(validAttachments); - - // Enqueue uploads via task queue for unified retry/progress - final activeConv = ref.read(activeConversationProvider); - for (final attachment in validAttachments) { - try { - final fileSize = await attachment.file.length(); await ref .read(taskQueueProvider.notifier) .enqueueUploadMedia( @@ -672,7 +621,7 @@ class _ChatPageState extends ConsumerState { } DebugLogger.log( - 'Added ${validAttachments.length} pasted attachment(s)', + 'Added ${attachments.length} pasted attachment(s)', scope: 'chat/page', ); } diff --git a/lib/features/chat/widgets/modern_chat_input.dart b/lib/features/chat/widgets/modern_chat_input.dart index 8b5f9b6..bf261f1 100644 --- a/lib/features/chat/widgets/modern_chat_input.dart +++ b/lib/features/chat/widgets/modern_chat_input.dart @@ -9,6 +9,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'dart:io' show Platform; import 'dart:async'; +import 'dart:typed_data'; import 'dart:ui'; import 'dart:math' as math; import '../providers/chat_providers.dart'; @@ -285,6 +286,90 @@ class _ModernChatInputState extends ConsumerState } } + /// Handles pasting images/files from clipboard with pre-loaded image data. + /// + /// This avoids a second clipboard read by using data already fetched when + /// building the context menu. + Future _handleClipboardPasteWithData(Uint8List imageData) async { + if (!widget.enabled) return; + + final onPasted = widget.onPastedAttachments; + if (onPasted == null) return; + + PlatformUtils.lightHaptic(); + + final attachment = await _clipboardService.createAttachmentFromImageData( + imageData: imageData, + mimeType: 'image/png', + ); + if (attachment != null) { + await onPasted([attachment]); + } + } + + /// Builds a custom context menu with standard options plus "Paste Image". + /// + /// The standard paste only works for text. This adds a "Paste Image" + /// option that uses the pasteboard package to read images from clipboard + /// on both iOS and Android. The option only appears when there's actually + /// an image in the clipboard. + Widget _buildContextMenu( + BuildContext context, + EditableTextState editableTextState, + ) { + final List buttonItems = List.from( + editableTextState.contextMenuButtonItems, + ); + + // Only add "Paste Image" if we have a callback for pasted attachments + if (widget.onPastedAttachments == null) { + return AdaptiveTextSelectionToolbar.buttonItems( + anchors: editableTextState.contextMenuAnchors, + buttonItems: buttonItems, + ); + } + + // Check clipboard for images - the data is captured in the closure to + // avoid double-read and stale cache issues + return FutureBuilder( + future: _clipboardService.getClipboardImage(), + builder: (context, snapshot) { + final imageData = snapshot.data; + final hasImage = imageData != null && imageData.isNotEmpty; + + if (hasImage) { + // Find the index of the standard Paste button to insert after it + final pasteIndex = buttonItems.indexWhere( + (item) => item.type == ContextMenuButtonType.paste, + ); + + // Capture imageData in closure to avoid re-reading clipboard + final pasteImageItem = ContextMenuButtonItem( + label: AppLocalizations.of(context)?.pasteImage ?? 'Paste Image', + onPressed: () { + // Close the context menu first + ContextMenuController.removeAny(); + // Use the captured imageData directly + _handleClipboardPasteWithData(imageData); + }, + ); + + // Insert after Paste if found, otherwise add at the end + if (pasteIndex >= 0) { + buttonItems.insert(pasteIndex + 1, pasteImageItem); + } else { + buttonItems.add(pasteImageItem); + } + } + + return AdaptiveTextSelectionToolbar.buttonItems( + anchors: editableTextState.contextMenuAnchors, + buttonItems: buttonItems, + ); + }, + ); + } + void _insertNewline() { final text = _controller.text; TextSelection sel = _controller.selection; @@ -1545,6 +1630,10 @@ class _ModernChatInputState extends ConsumerState .toList(), onContentInserted: _handleContentInserted, ), + // Custom context menu with "Paste Image" option for iOS + contextMenuBuilder: (context, editableTextState) { + return _buildContextMenu(context, editableTextState); + }, onSubmitted: (_) { if (sendOnEnter) { _sendMessage(); diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index cd74f40..8e1bf87 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -128,6 +128,8 @@ "file": "Datei", "photo": "Foto", "camera": "Kamera", + "pasteFromClipboard": "Einfügen", + "pasteImage": "Bild einfügen", "apiUnavailable": "API-Dienst nicht verfügbar", "unableToLoadImage": "Bild kann nicht geladen werden", "notAnImageFile": "Keine Bilddatei: {fileName}", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 30001dd..5ad189b 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -606,6 +606,14 @@ "@camera": { "description": "Camera source label." }, + "pasteFromClipboard": "Paste", + "@pasteFromClipboard": { + "description": "Action label to paste images or files from the clipboard." + }, + "pasteImage": "Paste Image", + "@pasteImage": { + "description": "Context menu action to paste an image from the clipboard." + }, "apiUnavailable": "API service not available", "@apiUnavailable": { "description": "Shown when backend API service is unavailable." diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 530b240..9d55839 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -128,6 +128,8 @@ "file": "Archivo", "photo": "Foto", "camera": "Cámara", + "pasteFromClipboard": "Pegar", + "pasteImage": "Pegar imagen", "apiUnavailable": "Servicio de API no disponible", "unableToLoadImage": "No se puede cargar la imagen", "notAnImageFile": "No es un archivo de imagen: {fileName}", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index fe657b1..4210baf 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -128,6 +128,8 @@ "file": "Fichier", "photo": "Photo", "camera": "Appareil photo", + "pasteFromClipboard": "Coller", + "pasteImage": "Coller l'image", "apiUnavailable": "Service API indisponible", "unableToLoadImage": "Impossible de charger l'image", "notAnImageFile": "Ce n'est pas un fichier image : {fileName}", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index abf359e..1dc20d2 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -128,6 +128,8 @@ "file": "File", "photo": "Foto", "camera": "Fotocamera", + "pasteFromClipboard": "Incolla", + "pasteImage": "Incolla immagine", "apiUnavailable": "Servizio API non disponibile", "unableToLoadImage": "Impossibile caricare l'immagine", "notAnImageFile": "Non è un file immagine: {fileName}", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 9b07977..49436cd 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -192,6 +192,8 @@ "chooseDifferentFile": "다른 파일 선택", "photo": "사진", "camera": "카메라", + "pasteFromClipboard": "붙여넣기", + "pasteImage": "이미지 붙여넣기", "apiUnavailable": "API 서비스를 사용할 수 없습니다", "unableToLoadImage": "이미지를 불러올 수 없습니다", "notAnImageFile": "이미지 파일이 아닙니다: {fileName}", diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 754d04a..d26423e 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -128,6 +128,8 @@ "file": "Bestand", "photo": "Foto", "camera": "Camera", + "pasteFromClipboard": "Plakken", + "pasteImage": "Afbeelding plakken", "apiUnavailable": "API-service niet beschikbaar", "unableToLoadImage": "Kan afbeelding niet laden", "notAnImageFile": "Geen afbeeldingsbestand: {fileName}", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 2719705..643037c 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -128,6 +128,8 @@ "file": "Файл", "photo": "Фото", "camera": "Камера", + "pasteFromClipboard": "Вставить", + "pasteImage": "Вставить изображение", "apiUnavailable": "Служба API недоступна", "unableToLoadImage": "Не удалось загрузить изображение", "notAnImageFile": "Не является файлом изображения: {fileName}", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 7bc07ba..35d6eeb 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -128,6 +128,8 @@ "file": "文件", "photo": "照片", "camera": "相机", + "pasteFromClipboard": "粘贴", + "pasteImage": "粘贴图片", "apiUnavailable": "API 服务不可用", "unableToLoadImage": "无法加载图像", "notAnImageFile": "不是图像文件:{fileName}", diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index 09e9662..4713d59 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -128,6 +128,8 @@ "file": "文件", "photo": "照片", "camera": "相機", + "pasteFromClipboard": "貼上", + "pasteImage": "貼上圖片", "apiUnavailable": "API 服務不可用", "unableToLoadImage": "無法加載圖像", "notAnImageFile": "不是圖像文件:{fileName}", diff --git a/pubspec.lock b/pubspec.lock index 627087a..06583da 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1061,6 +1061,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.1" + pasteboard: + dependency: "direct main" + description: + name: pasteboard + sha256: "9ff73ada33f79a59ff91f6c01881fd4ed0a0031cfc4ae2d86c0384471525fca1" + url: "https://pub.dev" + source: hosted + version: "0.4.0" path: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index fedaaf6..14320ea 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -79,6 +79,7 @@ dependencies: html_unescape: ^2.0.0 home_widget: ^0.8.1 flutter_highlight: ^0.7.0 + pasteboard: ^0.4.0 # Clipboard functionality is available through flutter/services (part of Flutter SDK)