diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml
index dba5dfc5..395becc0 100644
--- a/example/android/app/src/main/AndroidManifest.xml
+++ b/example/android/app/src/main/AndroidManifest.xml
@@ -25,6 +25,12 @@
+
+
2.4)
- camera_avfoundation (0.0.1):
- Flutter
+ - device_info_plus (0.0.1):
+ - Flutter
- file_selector_ios (0.0.1):
- Flutter
- Firebase/Auth (12.4.0):
@@ -77,6 +79,8 @@ PODS:
- GTMSessionFetcher/Core (5.0.0)
- image_picker_ios (0.0.1):
- Flutter
+ - irondash_engine_context (0.0.1):
+ - Flutter
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
@@ -87,20 +91,25 @@ PODS:
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
+ - super_native_extensions (0.0.1):
+ - Flutter
- url_launcher_ios (0.0.1):
- Flutter
DEPENDENCIES:
- camera_avfoundation (from `.symlinks/plugins/camera_avfoundation/ios`)
+ - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- file_selector_ios (from `.symlinks/plugins/file_selector_ios/ios`)
- firebase_app_check (from `.symlinks/plugins/firebase_app_check/ios`)
- firebase_auth (from `.symlinks/plugins/firebase_auth/ios`)
- firebase_core (from `.symlinks/plugins/firebase_core/ios`)
- Flutter (from `Flutter`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
+ - irondash_engine_context (from `.symlinks/plugins/irondash_engine_context/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- record_ios (from `.symlinks/plugins/record_ios/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
+ - super_native_extensions (from `.symlinks/plugins/super_native_extensions/ios`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
SPEC REPOS:
@@ -122,6 +131,8 @@ SPEC REPOS:
EXTERNAL SOURCES:
camera_avfoundation:
:path: ".symlinks/plugins/camera_avfoundation/ios"
+ device_info_plus:
+ :path: ".symlinks/plugins/device_info_plus/ios"
file_selector_ios:
:path: ".symlinks/plugins/file_selector_ios/ios"
firebase_app_check:
@@ -134,18 +145,23 @@ EXTERNAL SOURCES:
:path: Flutter
image_picker_ios:
:path: ".symlinks/plugins/image_picker_ios/ios"
+ irondash_engine_context:
+ :path: ".symlinks/plugins/irondash_engine_context/ios"
path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/darwin"
record_ios:
:path: ".symlinks/plugins/record_ios/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
+ super_native_extensions:
+ :path: ".symlinks/plugins/super_native_extensions/ios"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
SPEC CHECKSUMS:
AppCheckCore: cc8fd0a3a230ddd401f326489c99990b013f0c4f
camera_avfoundation: 281867ff09f1da66f031a184ecfbc6f2e625c9f5
+ device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
file_selector_ios: 80c12e90ad3f2045ed6819d03742f1a4c5ec3f93
Firebase: f07b15ae5a6ec0f93713e30b923d9970d144af3e
firebase_app_check: 53a9efd793edad49230d8d49b19cb8d47b8450ed
@@ -162,11 +178,13 @@ SPEC CHECKSUMS:
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
GTMSessionFetcher: 02d6e866e90bc236f48a703a041dfe43e6221a29
image_picker_ios: 4f2f91b01abdb52842a8e277617df877e40f905b
+ irondash_engine_context: 3458bf979b90d616ffb8ae03a150bafe2e860cc9
path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
RecaptchaInterop: 11e0b637842dfb48308d242afc3f448062325aba
record_ios: 840d21cce013c5a3b2168b74a54ebdb4136359e2
shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6
+ super_native_extensions: 4916b3c627a9c7fffdc48a23a9eca0b1ac228fa7
url_launcher_ios: bb13df5870e8c4234ca12609d04010a21be43dfa
PODFILE CHECKSUM: 7773a3d1e948b3cef227c6713241e4fcfe42cda9
diff --git a/example/macos/Flutter/GeneratedPluginRegistrant.swift b/example/macos/Flutter/GeneratedPluginRegistrant.swift
index 20c23bad..bac66446 100644
--- a/example/macos/Flutter/GeneratedPluginRegistrant.swift
+++ b/example/macos/Flutter/GeneratedPluginRegistrant.swift
@@ -5,22 +5,28 @@
import FlutterMacOS
import Foundation
+import device_info_plus
import file_selector_macos
import firebase_app_check
import firebase_auth
import firebase_core
+import irondash_engine_context
import path_provider_foundation
import record_macos
import shared_preferences_foundation
+import super_native_extensions
import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
+ DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FLTFirebaseAppCheckPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAppCheckPlugin"))
FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin"))
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
+ IrondashEngineContextPlugin.register(with: registry.registrar(forPlugin: "IrondashEngineContextPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
+ SuperNativeExtensionsPlugin.register(with: registry.registrar(forPlugin: "SuperNativeExtensionsPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
}
diff --git a/example/macos/Podfile.lock b/example/macos/Podfile.lock
index f5abe519..b3e95270 100644
--- a/example/macos/Podfile.lock
+++ b/example/macos/Podfile.lock
@@ -3,6 +3,8 @@ PODS:
- GoogleUtilities/Environment (~> 8.0)
- GoogleUtilities/UserDefaults (~> 8.0)
- PromisesObjC (~> 2.4)
+ - device_info_plus (0.0.1):
+ - FlutterMacOS
- file_selector_macos (0.0.1):
- FlutterMacOS
- Firebase/AppCheck (12.4.0):
@@ -77,6 +79,8 @@ PODS:
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- GTMSessionFetcher/Core (5.0.0)
+ - irondash_engine_context (0.0.1):
+ - FlutterMacOS
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
@@ -86,18 +90,23 @@ PODS:
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
+ - super_native_extensions (0.0.1):
+ - FlutterMacOS
- url_launcher_macos (0.0.1):
- FlutterMacOS
DEPENDENCIES:
+ - device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`)
- file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`)
- firebase_app_check (from `Flutter/ephemeral/.symlinks/plugins/firebase_app_check/macos`)
- firebase_auth (from `Flutter/ephemeral/.symlinks/plugins/firebase_auth/macos`)
- firebase_core (from `Flutter/ephemeral/.symlinks/plugins/firebase_core/macos`)
- FlutterMacOS (from `Flutter/ephemeral`)
+ - irondash_engine_context (from `Flutter/ephemeral/.symlinks/plugins/irondash_engine_context/macos`)
- path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`)
- record_macos (from `Flutter/ephemeral/.symlinks/plugins/record_macos/macos`)
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
+ - super_native_extensions (from `Flutter/ephemeral/.symlinks/plugins/super_native_extensions/macos`)
- url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`)
SPEC REPOS:
@@ -116,6 +125,8 @@ SPEC REPOS:
- PromisesObjC
EXTERNAL SOURCES:
+ device_info_plus:
+ :path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos
file_selector_macos:
:path: Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos
firebase_app_check:
@@ -126,17 +137,22 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/firebase_core/macos
FlutterMacOS:
:path: Flutter/ephemeral
+ irondash_engine_context:
+ :path: Flutter/ephemeral/.symlinks/plugins/irondash_engine_context/macos
path_provider_foundation:
:path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin
record_macos:
:path: Flutter/ephemeral/.symlinks/plugins/record_macos/macos
shared_preferences_foundation:
:path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin
+ super_native_extensions:
+ :path: Flutter/ephemeral/.symlinks/plugins/super_native_extensions/macos
url_launcher_macos:
:path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos
SPEC CHECKSUMS:
AppCheckCore: cc8fd0a3a230ddd401f326489c99990b013f0c4f
+ device_info_plus: 1b14eed9bf95428983aed283a8d51cce3d8c4215
file_selector_macos: 3e56eaea051180007b900eacb006686fd54da150
Firebase: f07b15ae5a6ec0f93713e30b923d9970d144af3e
firebase_app_check: 87116ccdfe0f153231af37b0431e96b0d5a76b9c
@@ -152,10 +168,12 @@ SPEC CHECKSUMS:
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
GTMSessionFetcher: 02d6e866e90bc236f48a703a041dfe43e6221a29
+ irondash_engine_context: da62996ee25616d2f01bbeb85dc115d813359478
path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
record_macos: 4440ca269ad3b870ebb1965297a365d558f0c520
shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6
+ super_native_extensions: 85efee3a7495b46b04befcfc86ed12069264ebf3
url_launcher_macos: 175a54c831f4375a6cf895875f716ee5af3888ce
PODFILE CHECKSUM: abc7d4662afc18f3dac224359a4bbdfd943487c9
diff --git a/lib/src/helpers/paste_helper/paste_handler.dart b/lib/src/helpers/paste_helper/paste_handler.dart
new file mode 100644
index 00000000..8555e182
--- /dev/null
+++ b/lib/src/helpers/paste_helper/paste_handler.dart
@@ -0,0 +1,172 @@
+// Copyright 2025 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'dart:typed_data';
+
+import 'package:cross_file/cross_file.dart';
+import 'package:flutter/widgets.dart'
+ show TextEditingController, debugPrint, debugPrintStack;
+import 'package:mime/mime.dart';
+import 'package:super_clipboard/super_clipboard.dart';
+
+import '../../providers/interface/attachments.dart';
+
+/// Handles paste operations, supporting both text and image pasting.
+///
+/// This function processes the clipboard contents and either:
+/// - Extracts and handles image data if images are present in the clipboard
+/// - Inserts plain text into the provided text controller if no images are found
+///
+/// On web, it delegates to [handlePasteWeb] for more comprehensive handling
+/// of web-specific clipboard APIs.
+///
+/// Parameters:
+/// - [controller]: The text editing controller to insert text into
+/// - [onAttachments]: Callback that receives a list of attachments when images are pasted.
+/// If null, image pasting will be skipped even if images are available.
+///
+/// Returns:
+/// A [Future] that completes when the paste operation is finished
+Future handlePaste({
+ required TextEditingController controller,
+ required void Function(List attachments)? onAttachments,
+ required void Function({
+ required TextEditingController controller,
+ required String text,
+ })
+ insertText,
+}) async {
+ try {
+ final clipboard = SystemClipboard.instance;
+ if (clipboard == null) return;
+ final reader = await clipboard.read();
+
+ if (onAttachments != null) {
+ final imageFormats = [
+ Formats.png,
+ Formats.jpeg,
+ Formats.bmp,
+ Formats.gif,
+ Formats.tiff,
+ Formats.webp,
+ ];
+
+ final fileFormats = [
+ Formats.pdf,
+ Formats.doc,
+ Formats.docx,
+ Formats.xls,
+ Formats.xlsx,
+ Formats.ppt,
+ Formats.pptx,
+ Formats.epub,
+ ];
+
+ if (reader.canProvide(Formats.fileUri)) {
+ await reader.readValue(Formats.fileUri).then((val) async {
+ if (val != null) {
+ if (val.isScheme('file')) {
+ final path = val.toFilePath();
+ final file = XFile(path);
+ final attachment = await FileAttachment.fromFile(file);
+ onAttachments([attachment]);
+ }
+ }
+ });
+ return;
+ }
+
+ for (final format in fileFormats) {
+ if (reader.canProvide(format)) {
+ reader.getFile(format, (file) async {
+ final stream = file.getStream();
+
+ await stream.toList().then((chunks) {
+ final attachmentBytes = Uint8List.fromList(
+ chunks.expand((e) => e).toList(),
+ );
+ final mimeType =
+ lookupMimeType('', headerBytes: attachmentBytes) ??
+ 'application/octet-stream';
+ final attachment = FileAttachment.fileOrImage(
+ name:
+ 'pasted_file_${DateTime.now().millisecondsSinceEpoch}.${_getExtensionFromMime(mimeType)}',
+ mimeType: mimeType,
+ bytes: attachmentBytes,
+ );
+ onAttachments([attachment]);
+ return;
+ });
+ });
+ return;
+ }
+ }
+
+ for (final format in imageFormats) {
+ if (reader.canProvide(format)) {
+ reader.getFile(format, (file) async {
+ final stream = file.getStream();
+
+ await stream.toList().then((chunks) {
+ final attachmentBytes = Uint8List.fromList(
+ chunks.expand((e) => e).toList(),
+ );
+ final mimeType =
+ lookupMimeType('', headerBytes: attachmentBytes) ??
+ 'image/png';
+ final attachment = ImageFileAttachment(
+ name:
+ 'pasted_image_${DateTime.now().millisecondsSinceEpoch}.${_getExtensionFromMime(mimeType)}',
+ mimeType: mimeType,
+ bytes: attachmentBytes,
+ );
+ onAttachments([attachment]);
+ return;
+ });
+ });
+ return;
+ }
+ }
+ }
+
+ if (reader.canProvide(Formats.plainText)) {
+ final text = await reader.readValue(Formats.plainText);
+ if (text != null && text.isNotEmpty) {
+ insertText(controller: controller, text: text);
+ return;
+ }
+ }
+
+ if (reader.canProvide(Formats.htmlText)) {
+ final html = await reader.readValue(Formats.htmlText);
+ if (html != null && html.isNotEmpty) {
+ insertText(controller: controller, text: html);
+ return;
+ }
+ }
+ } catch (e, s) {
+ debugPrint('Error pasting image: $e');
+ debugPrintStack(stackTrace: s);
+ }
+}
+
+/// Determines the appropriate file extension for a given MIME type.
+///
+/// Parameters:
+/// - [mimeType]: The MIME type to get the extension for (e.g., 'image/png')
+///
+/// Returns:
+/// A string representing the file extension (without the dot), defaults to 'bin' if unknown
+String _getExtensionFromMime(String mimeType, [List? bytes]) {
+ String detectedMimeType = mimeType;
+ if (bytes != null &&
+ (mimeType.isEmpty || mimeType == 'application/octet-stream')) {
+ detectedMimeType = lookupMimeType('', headerBytes: bytes) ?? mimeType;
+ }
+ final extension = extensionFromMime(detectedMimeType);
+ if (extension == null || extension.isEmpty) {
+ return detectedMimeType.startsWith('image/') ? 'png' : 'bin';
+ }
+ return extension.startsWith('.') ? extension.substring(1) : extension;
+}
diff --git a/lib/src/helpers/paste_helper/paste_helper.dart b/lib/src/helpers/paste_helper/paste_helper.dart
new file mode 100644
index 00000000..4ceee934
--- /dev/null
+++ b/lib/src/helpers/paste_helper/paste_helper.dart
@@ -0,0 +1,2 @@
+export 'paste_helper_stub.dart'
+ if (dart.library.js_interop) 'paste_helper_web.dart';
\ No newline at end of file
diff --git a/lib/src/helpers/paste_helper/paste_helper_stub.dart b/lib/src/helpers/paste_helper/paste_helper_stub.dart
new file mode 100644
index 00000000..d987f8d3
--- /dev/null
+++ b/lib/src/helpers/paste_helper/paste_helper_stub.dart
@@ -0,0 +1,35 @@
+// Copyright 2025 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter/widgets.dart' show TextEditingController;
+
+import '../../providers/interface/attachments.dart';
+
+/// A no-op implementation of the web paste handler for non-web platforms.
+///
+/// This function is provided for API compatibility with web platforms but does nothing
+/// when called on non-web platforms. On web, this would handle paste events.
+///
+/// Parameters:
+/// - [controller]: The text editing controller (unused in stub)
+/// - [onAttachments]: Callback for handling attachments (unused in stub)
+/// - [insertText]: Function to handle text insertion (unused in stub)
+///
+/// Returns:
+/// A [Future] that completes immediately with no effect
+Future handlePasteWeb({
+ required TextEditingController controller,
+ required void Function(List attachments)? onAttachments,
+ required void Function({
+ required TextEditingController controller,
+ required String text,
+ })
+ insertText,
+}) async {}
+
+/// A no-op implementation of unregistering the web listener for non-web platforms.
+///
+/// This function is provided for API compatibility with web platforms but does nothing
+/// when called on non-web platforms. On web, this unregister the paste event listener.
+void unregisterPasteListener() {}
diff --git a/lib/src/helpers/paste_helper/paste_helper_web.dart b/lib/src/helpers/paste_helper/paste_helper_web.dart
new file mode 100644
index 00000000..4253c116
--- /dev/null
+++ b/lib/src/helpers/paste_helper/paste_helper_web.dart
@@ -0,0 +1,212 @@
+// Copyright 2025 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:cross_file/cross_file.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutter/widgets.dart' show TextEditingController;
+import 'package:mime/mime.dart';
+import 'package:super_clipboard/super_clipboard.dart';
+
+import '../../providers/interface/attachments.dart';
+
+bool _isListenerRegistered = false;
+final _events = ClipboardEvents.instance;
+
+/// Handles paste events in a web environment, supporting both text, file, and image pasting.
+///
+/// This function processes the clipboard contents, registers a paste event listener and either:
+/// - Extracts and handles image data if images are present in the clipboard
+/// - Inserts plain text into the provided text controller
+///
+/// Parameters:
+/// - [controller]: The text editing controller to insert text into
+/// - [onAttachments]: Callback that receives a list of attachments when images are pasted
+/// - [insertText]: Function to handle text insertion, allowing for custom text processing
+///
+/// Returns:
+/// A [Future] that completes when the paste operation is finished
+Future handlePasteWeb({
+ required TextEditingController controller,
+ required void Function(List attachments)? onAttachments,
+ required void Function({
+ required TextEditingController controller,
+ required String text,
+ })
+ insertText,
+}) async {
+ try {
+ if (_isListenerRegistered) return;
+
+ _isListenerRegistered = true;
+
+ if (_events == null) return;
+
+ _events!.registerPasteEventListener((event) async {
+ final reader = await event.getClipboardReader();
+ await _pasteOperation(
+ controller: controller,
+ onAttachments: onAttachments,
+ insertText: insertText,
+ reader: reader,
+ );
+ });
+ } catch (e, s) {
+ debugPrint('Error in handlePasteWeb: $e');
+ debugPrintStack(stackTrace: s);
+ }
+}
+
+/// Determines the appropriate file extension for a given MIME type.
+///
+/// Parameters:
+/// - [mimeType]: The MIME type to get the extension for (e.g., 'image/png')
+/// - [bytes]: Optional header bytes to detect the MIME type if the provided type is generic.
+///
+/// Returns:
+/// A string representing the file extension (without the dot), defaults to 'bin' if unknown
+String _getExtensionFromMime(String mimeType, [List? bytes]) {
+ String detectedMimeType = mimeType;
+ if (bytes != null &&
+ (mimeType.isEmpty || mimeType == 'application/octet-stream')) {
+ detectedMimeType = lookupMimeType('', headerBytes: bytes) ?? mimeType;
+ }
+ final extension = extensionFromMime(detectedMimeType);
+ if (extension == null || extension.isEmpty) {
+ return detectedMimeType.startsWith('image/') ? 'png' : 'bin';
+ }
+ return extension.startsWith('.') ? extension.substring(1) : extension;
+}
+
+/// Internal function to handle the actual clipboard reading and data processing.
+///
+/// It checks for various data formats (files, images, plain text, HTML) in a specific order
+/// and executes the appropriate action (calling [onAttachments] or [insertText]).
+///
+/// Parameters:
+/// - [controller]: The text editing controller.
+/// - [onAttachments]: Callback to handle file/image attachments.
+/// - [insertText]: Function to handle text insertion.
+/// - [reader]: The [ClipboardReader] containing the clipboard data.
+Future _pasteOperation({
+ required TextEditingController controller,
+ required void Function(List attachments)? onAttachments,
+ required void Function({
+ required TextEditingController controller,
+ required String text,
+ })
+ insertText,
+ required ClipboardReader reader,
+}) async {
+ if (onAttachments != null) {
+ final imageFormats = [
+ Formats.png,
+ Formats.jpeg,
+ Formats.bmp,
+ Formats.gif,
+ Formats.tiff,
+ Formats.webp,
+ ];
+
+ final fileFormats = [
+ Formats.pdf,
+ Formats.doc,
+ Formats.docx,
+ Formats.xls,
+ Formats.xlsx,
+ Formats.ppt,
+ Formats.pptx,
+ Formats.epub,
+ ];
+
+ for (final format in fileFormats) {
+ if (reader.canProvide(format)) {
+ reader.getFile(format, (file) async {
+ final stream = file.getStream();
+
+ await stream.toList().then((chunks) {
+ final attachmentBytes = Uint8List.fromList(
+ chunks.expand((e) => e).toList(),
+ );
+ final mimeType =
+ lookupMimeType('', headerBytes: attachmentBytes) ??
+ 'application/octet-stream';
+ final attachment = FileAttachment.fileOrImage(
+ name:
+ 'pasted_file_${DateTime.now().millisecondsSinceEpoch}.${_getExtensionFromMime(mimeType)}',
+ mimeType: mimeType,
+ bytes: attachmentBytes,
+ );
+ onAttachments([attachment]);
+ return;
+ });
+ });
+ return;
+ }
+ }
+
+ if (reader.canProvide(Formats.fileUri)) {
+ await reader.readValue(Formats.fileUri).then((val) async {
+ if (val != null) {
+ if (val.isScheme('file')) {
+ final path = val.toFilePath();
+ final file = XFile(path);
+ final attachment = await FileAttachment.fromFile(file);
+ onAttachments([attachment]);
+ }
+ }
+ });
+ return;
+ }
+
+ for (final format in imageFormats) {
+ if (reader.canProvide(format)) {
+ reader.getFile(format, (file) async {
+ final stream = file.getStream();
+ await stream.toList().then((chunks) {
+ final attachmentBytes = Uint8List.fromList(
+ chunks.expand((e) => e).toList(),
+ );
+ final mimeType =
+ lookupMimeType('', headerBytes: attachmentBytes) ?? 'image/png';
+ final attachment = ImageFileAttachment(
+ name:
+ 'pasted_image_${DateTime.now().millisecondsSinceEpoch}.${_getExtensionFromMime(mimeType)}',
+ mimeType: mimeType,
+ bytes: attachmentBytes,
+ );
+ onAttachments([attachment]);
+ return;
+ });
+ });
+ return;
+ }
+ }
+
+ if (reader.canProvide(Formats.plainText)) {
+ final text = await reader.readValue(Formats.plainText);
+ if (text != null && text.isNotEmpty) {
+ insertText(controller: controller, text: text);
+ return;
+ }
+ }
+
+ if (reader.canProvide(Formats.htmlText)) {
+ final html = await reader.readValue(Formats.htmlText);
+ if (html != null && html.isNotEmpty) {
+ insertText(controller: controller, text: html);
+ return;
+ }
+ }
+ }
+}
+
+/// Unregisters the paste event listener established in [handlePasteWeb].
+///
+/// This is necessary to stop processing paste events when they are no longer needed
+/// (e.g., when a widget is disposed).
+void unregisterPasteListener() {
+ if (_events != null) {
+ _events!.unregisterPasteEventListener;
+ }
+}
diff --git a/lib/src/views/chat_input/chat_input.dart b/lib/src/views/chat_input/chat_input.dart
index 45a886d5..e5498183 100644
--- a/lib/src/views/chat_input/chat_input.dart
+++ b/lib/src/views/chat_input/chat_input.dart
@@ -196,6 +196,7 @@ class _ChatInputState extends State {
cancelButtonStyle: _chatStyle!.cancelButtonStyle!,
voiceNoteRecorderStyle:
_chatStyle!.voiceNoteRecorderStyle!,
+ onAttachments: onAttachments,
),
),
Padding(
diff --git a/lib/src/views/chat_input/text_or_audio_input.dart b/lib/src/views/chat_input/text_or_audio_input.dart
index 05732397..3c0a8a70 100644
--- a/lib/src/views/chat_input/text_or_audio_input.dart
+++ b/lib/src/views/chat_input/text_or_audio_input.dart
@@ -1,5 +1,6 @@
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
+import 'package:flutter_ai_toolkit/flutter_ai_toolkit.dart';
import 'package:waveform_recorder/waveform_recorder.dart';
import '../../styles/styles.dart';
@@ -17,6 +18,7 @@ class TextOrAudioInput extends StatelessWidget {
/// The [TextOrAudioInput] widget requires several parameters:
/// - [inputStyle]: Defines the styling for the input field.
/// - [waveController]: Controls the waveform recorder.
+ /// - [onAttachments]: Callback for when attachments are pasted into the text field.
/// - [onCancelEdit]: Callback for when editing is canceled.
/// - [onRecordingStopped]: Callback for when audio recording is stopped.
/// - [onSubmitPrompt]: Callback for when the text input is submitted.
@@ -30,6 +32,7 @@ class TextOrAudioInput extends StatelessWidget {
super.key,
required ChatInputStyle inputStyle,
required WaveformRecorderController waveController,
+ required void Function(List attachments)? onAttachments,
required void Function()? onCancelEdit,
required void Function() onRecordingStopped,
required void Function() onSubmitPrompt,
@@ -45,6 +48,7 @@ class TextOrAudioInput extends StatelessWidget {
_focusNode = focusNode,
_textController = textController,
_onSubmitPrompt = onSubmitPrompt,
+ _onAttachments = onAttachments,
_onRecordingStopped = onRecordingStopped,
_onCancelEdit = onCancelEdit,
_waveController = waveController,
@@ -53,6 +57,7 @@ class TextOrAudioInput extends StatelessWidget {
final ChatInputStyle _inputStyle;
final WaveformRecorderController _waveController;
+ final void Function(List attachments)? _onAttachments;
final void Function()? _onCancelEdit;
final void Function() _onRecordingStopped;
final void Function() _onSubmitPrompt;
@@ -113,6 +118,7 @@ class TextOrAudioInput extends StatelessWidget {
horizontal: 12,
vertical: 8,
),
+ onAttachments: _onAttachments,
),
),
),
diff --git a/lib/src/views/chat_text_field.dart b/lib/src/views/chat_text_field.dart
index 551bc01e..0659bfb4 100644
--- a/lib/src/views/chat_text_field.dart
+++ b/lib/src/views/chat_text_field.dart
@@ -2,11 +2,27 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
-import 'package:flutter/cupertino.dart' show CupertinoTextField;
+import 'package:flutter/cupertino.dart'
+ show
+ CupertinoTextField,
+ CupertinoAdaptiveTextSelectionToolbar,
+ CupertinoLocalizations;
import 'package:flutter/material.dart'
- show InputBorder, InputDecoration, TextField, TextInputAction;
+ show
+ InputBorder,
+ InputDecoration,
+ TextField,
+ TextInputAction,
+ ContextMenuController,
+ AdaptiveTextSelectionToolbar,
+ MaterialLocalizations;
+
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
+import '../helpers/paste_helper/paste_handler.dart';
+import '../helpers/paste_helper/paste_helper.dart' as pst;
+import 'package:universal_platform/universal_platform.dart';
+import '../providers/interface/attachments.dart';
import '../styles/toolkit_colors.dart';
import '../utility.dart';
@@ -16,7 +32,7 @@ import '../utility.dart';
/// This widget will render either a [CupertinoTextField] or a [TextField]
/// depending on whether the app is using Cupertino or Material design.
@immutable
-class ChatTextField extends StatelessWidget {
+class ChatTextField extends StatefulWidget {
/// Creates an adaptive text field.
///
/// Many of the parameters are required to ensure consistent behavior
@@ -30,6 +46,7 @@ class ChatTextField extends StatelessWidget {
required this.controller,
required this.focusNode,
required this.onSubmitted,
+ this.onAttachments,
required this.hintText,
required this.hintStyle,
required this.hintPadding,
@@ -69,48 +86,173 @@ class ChatTextField extends StatelessWidget {
/// Called when the user submits editable content.
final void Function(String text) onSubmitted;
+ /// Called when attachments are pasted into the text field.
+ final void Function(List attachments)? onAttachments;
+
@override
- Widget build(BuildContext context) => CallbackShortcuts(
- bindings: {
- const SingleActivator(LogicalKeyboardKey.enter):
- () => onSubmitted(controller.text),
- },
- child:
- isCupertinoApp(context)
- ? CupertinoTextField(
- minLines: minLines,
- maxLines: maxLines,
- controller: controller,
- autofocus: autofocus,
- focusNode: focusNode,
- onSubmitted: onSubmitted,
- style: style,
- placeholder: hintText,
- placeholderStyle: hintStyle,
- padding: hintPadding ?? EdgeInsets.zero,
- decoration: BoxDecoration(
- border: Border.all(width: 0, color: ToolkitColors.transparent),
- ),
- keyboardType: TextInputType.multiline,
- textInputAction: TextInputAction.newline,
- )
- : TextField(
- minLines: minLines,
- maxLines: maxLines,
- controller: controller,
- autofocus: autofocus,
- focusNode: focusNode,
- keyboardType: TextInputType.multiline,
- textInputAction: TextInputAction.newline,
- onSubmitted: onSubmitted,
- style: style,
- decoration: InputDecoration(
- border: InputBorder.none,
- hintText: hintText,
- hintStyle: hintStyle,
- contentPadding: hintPadding,
- isDense: false,
+ State createState() => _ChatTextFieldState();
+}
+
+class _ChatTextFieldState extends State {
+ /// Inserts text at the current cursor position in the text controller.
+ ///
+ /// If there's a text selection, it will be replaced by the new text.
+ /// If there's no selection, the text will be inserted at the cursor position.
+ ///
+ /// Parameters:
+ /// - [controller]: The text editing controller to insert text into
+ /// - [text]: The text to insert
+ void _insertText({
+ required TextEditingController controller,
+ required String text,
+ }) {
+ final cursorPosition = controller.selection.base.offset;
+ if (cursorPosition == -1) {
+ controller.text = text;
+ } else {
+ final newText = controller.text.replaceRange(
+ controller.selection.start,
+ controller.selection.end,
+ text,
+ );
+ controller.value = controller.value.copyWith(
+ text: newText,
+ selection: TextSelection.collapsed(
+ offset: controller.selection.start + text.length,
+ ),
+ );
+ }
+ }
+
+ Future registerListeners() async {
+ return pst.handlePasteWeb(
+ controller: widget.controller,
+ onAttachments: widget.onAttachments,
+ insertText: _insertText,
+ );
+ }
+
+ Future _handlePaste() async {
+ return handlePaste(
+ controller: widget.controller,
+ onAttachments: widget.onAttachments,
+ insertText: _insertText,
+ );
+ }
+
+ @override
+ void initState() {
+ registerListeners();
+ super.initState();
+ }
+
+ @override
+ void dispose() {
+ pst.unregisterPasteListener();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return CallbackShortcuts(
+ bindings: {
+ const SingleActivator(LogicalKeyboardKey.enter):
+ () => widget.onSubmitted(widget.controller.text),
+ if (UniversalPlatform.isMacOS)
+ const SingleActivator(LogicalKeyboardKey.keyV, meta: true):
+ _handlePaste,
+ if (UniversalPlatform.isWindows || UniversalPlatform.isLinux)
+ const SingleActivator(LogicalKeyboardKey.keyV, control: true):
+ _handlePaste,
+ },
+ child:
+ isCupertinoApp(context)
+ ? CupertinoTextField(
+ minLines: widget.minLines,
+ maxLines: widget.maxLines,
+ autofocus: widget.autofocus,
+ style: widget.style,
+ textInputAction: widget.textInputAction,
+ controller: widget.controller,
+ focusNode: widget.focusNode,
+ onSubmitted: widget.onSubmitted,
+ placeholder: widget.hintText,
+ placeholderStyle: widget.hintStyle,
+ padding: widget.hintPadding ?? EdgeInsets.zero,
+ decoration: BoxDecoration(
+ border: Border.all(
+ width: 0,
+ color: ToolkitColors.transparent,
+ ),
+ ),
+ keyboardType: TextInputType.multiline,
+ contextMenuBuilder: (context, editable) {
+ final l10n = CupertinoLocalizations.of(context);
+ final defaultItems = editable.contextMenuButtonItems;
+
+ final filteredItems = defaultItems.where((item) {
+ return item.type.name != 'paste';
+ });
+
+ final customItems = [
+ ContextMenuButtonItem(
+ label: l10n.pasteButtonLabel,
+ onPressed: () async {
+ ContextMenuController.removeAny();
+ await _handlePaste();
+ },
+ ),
+ ...filteredItems,
+ ];
+
+ return CupertinoAdaptiveTextSelectionToolbar.buttonItems(
+ anchors: editable.contextMenuAnchors,
+ buttonItems: customItems,
+ );
+ },
+ )
+ : TextField(
+ minLines: widget.minLines,
+ maxLines: widget.maxLines,
+ autofocus: widget.autofocus,
+ style: widget.style,
+ textInputAction: widget.textInputAction,
+ controller: widget.controller,
+ focusNode: widget.focusNode,
+ onSubmitted: widget.onSubmitted,
+ decoration: InputDecoration(
+ hintText: widget.hintText,
+ hintStyle: widget.hintStyle,
+ border: InputBorder.none,
+ contentPadding: widget.hintPadding,
+ isDense: false,
+ ),
+ keyboardType: TextInputType.multiline,
+ contextMenuBuilder: (context, editable) {
+ final defaultItems = editable.contextMenuButtonItems;
+
+ final filteredItems = defaultItems.where((item) {
+ return item.type.name != 'paste';
+ });
+
+ final customItems = [
+ ContextMenuButtonItem(
+ label: MaterialLocalizations.of(context).pasteButtonLabel,
+ onPressed: () async {
+ ContextMenuController.removeAny();
+ WidgetsBinding.instance.addPostFrameCallback((_) async {
+ await _handlePaste();
+ });
+ },
+ ),
+ ...filteredItems,
+ ];
+ return AdaptiveTextSelectionToolbar.buttonItems(
+ anchors: editable.contextMenuAnchors,
+ buttonItems: customItems,
+ );
+ },
),
- ),
- );
+ );
+ }
}
diff --git a/pubspec.yaml b/pubspec.yaml
index 3ba0fbff..acc7d66c 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -26,6 +26,7 @@ dependencies:
google_fonts: ^6.2.1
image_picker: ^1.1.2
mime: ^2.0.0
+ super_clipboard: ^0.9.1
universal_platform: ^1.1.0
url_launcher: ^6.3.2
uuid: ^4.4.2