From 07ad99d7c4f8c52930442a34d316e634435bd75a Mon Sep 17 00:00:00 2001
From: Matthias Nott <mnott@mnsoft.org>
Date: Mon, 23 Mar 2026 10:18:46 +0100
Subject: [PATCH] feat: attach button with camera, gallery, and file picker

---
 ios/Podfile.lock                              |   53 +++++++++++++
 lib/widgets/command_bar.dart                  |    2 
 macos/Flutter/GeneratedPluginRegistrant.swift |    2 
 pubspec.lock                                  |    8 ++
 lib/screens/chat_screen.dart                  |  133 +++++++++++++++++++++++++++++++-
 pubspec.yaml                                  |    1 
 6 files changed, 192 insertions(+), 7 deletions(-)

diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index 0815178..b24639a 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -4,6 +4,40 @@
     - FlutterMacOS
   - device_info_plus (0.0.1):
     - Flutter
+  - DKImagePickerController/Core (4.3.9):
+    - DKImagePickerController/ImageDataManager
+    - DKImagePickerController/Resource
+  - DKImagePickerController/ImageDataManager (4.3.9)
+  - DKImagePickerController/PhotoGallery (4.3.9):
+    - DKImagePickerController/Core
+    - DKPhotoGallery
+  - DKImagePickerController/Resource (4.3.9)
+  - DKPhotoGallery (0.0.19):
+    - DKPhotoGallery/Core (= 0.0.19)
+    - DKPhotoGallery/Model (= 0.0.19)
+    - DKPhotoGallery/Preview (= 0.0.19)
+    - DKPhotoGallery/Resource (= 0.0.19)
+    - SDWebImage
+    - SwiftyGif
+  - DKPhotoGallery/Core (0.0.19):
+    - DKPhotoGallery/Model
+    - DKPhotoGallery/Preview
+    - SDWebImage
+    - SwiftyGif
+  - DKPhotoGallery/Model (0.0.19):
+    - SDWebImage
+    - SwiftyGif
+  - DKPhotoGallery/Preview (0.0.19):
+    - DKPhotoGallery/Model
+    - DKPhotoGallery/Resource
+    - SDWebImage
+    - SwiftyGif
+  - DKPhotoGallery/Resource (0.0.19):
+    - SDWebImage
+    - SwiftyGif
+  - file_picker (0.0.1):
+    - DKImagePickerController/PhotoGallery
+    - Flutter
   - Flutter (1.0.0)
   - flutter_secure_storage (6.0.0):
     - Flutter
@@ -15,11 +49,15 @@
     - Flutter
   - record_ios (1.2.0):
     - Flutter
+  - SDWebImage (5.21.7):
+    - SDWebImage/Core (= 5.21.7)
+  - SDWebImage/Core (5.21.7)
   - share_plus (0.0.1):
     - Flutter
   - shared_preferences_foundation (0.0.1):
     - Flutter
     - FlutterMacOS
+  - SwiftyGif (5.4.5)
   - vibration (1.7.5):
     - Flutter
   - wakelock_plus (0.0.1):
@@ -28,6 +66,7 @@
 DEPENDENCIES:
   - audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/darwin`)
   - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
+  - file_picker (from `.symlinks/plugins/file_picker/ios`)
   - Flutter (from `Flutter`)
   - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
   - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
@@ -39,11 +78,20 @@
   - vibration (from `.symlinks/plugins/vibration/ios`)
   - wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
 
+SPEC REPOS:
+  trunk:
+    - DKImagePickerController
+    - DKPhotoGallery
+    - SDWebImage
+    - SwiftyGif
+
 EXTERNAL SOURCES:
   audioplayers_darwin:
     :path: ".symlinks/plugins/audioplayers_darwin/darwin"
   device_info_plus:
     :path: ".symlinks/plugins/device_info_plus/ios"
+  file_picker:
+    :path: ".symlinks/plugins/file_picker/ios"
   Flutter:
     :path: Flutter
   flutter_secure_storage:
@@ -68,14 +116,19 @@
 SPEC CHECKSUMS:
   audioplayers_darwin: 835ced6edd4c9fc8ebb0a7cc9e294a91d99917d5
   device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
+  DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
+  DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
+  file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be
   Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
   flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
   image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
   package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
   permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
   record_ios: 412daca2350b228e698fffcd08f1f94ceb1e3844
+  SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf
   share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
   shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
+  SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
   vibration: 8e2f50fc35bb736f9eecb7dd9f7047fbb6a6e888
   wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
 
diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart
index 23ce43d..cec56f8 100644
--- a/lib/screens/chat_screen.dart
+++ b/lib/screens/chat_screen.dart
@@ -7,6 +7,7 @@
 import 'package:flutter_riverpod/flutter_riverpod.dart';
 import 'package:go_router/go_router.dart';
 import 'package:image_picker/image_picker.dart';
+import 'package:file_picker/file_picker.dart';
 import 'package:shared_preferences/shared_preferences.dart';
 
 import '../models/message.dart';
@@ -663,6 +664,82 @@
     }
   }
 
+  Future<void> _pickFiles(String? targetSessionId) async {
+    final result = await FilePicker.platform.pickFiles(
+      allowMultiple: true,
+      type: FileType.any,
+    );
+    if (result == null || result.files.isEmpty) return;
+
+    for (final file in result.files) {
+      if (file.path == null) continue;
+      final bytes = await File(file.path!).readAsBytes();
+      final b64 = base64Encode(bytes);
+      final mimeType = _guessMimeType(file.name);
+      final isImage = mimeType.startsWith('image/');
+
+      if (isImage) {
+        final message = Message.image(
+          role: MessageRole.user,
+          imageBase64: b64,
+          content: file.name,
+          status: MessageStatus.sent,
+        );
+        ref.read(messagesProvider.notifier).addMessage(message);
+        _ws?.send({
+          'type': 'image',
+          'imageBase64': b64,
+          'mimeType': mimeType,
+          'caption': file.name,
+          'sessionId': targetSessionId,
+        });
+      } else {
+        // Non-image file: send as text with file info, save file to temp for session
+        final tmpDir = await getTemporaryDirectory();
+        final tmpPath = '${tmpDir.path}/${file.name}';
+        await File(tmpPath).writeAsBytes(bytes);
+
+        final message = Message.text(
+          role: MessageRole.user,
+          content: '📎 ${file.name} (${_formatSize(bytes.length)})',
+          status: MessageStatus.sent,
+        );
+        ref.read(messagesProvider.notifier).addMessage(message);
+        // Send file as base64 with metadata
+        _ws?.send({
+          'type': 'file',
+          'fileBase64': b64,
+          'fileName': file.name,
+          'mimeType': mimeType,
+          'fileSize': bytes.length,
+          'sessionId': targetSessionId,
+        });
+      }
+    }
+    _scrollToBottom();
+  }
+
+  String _guessMimeType(String name) {
+    final ext = name.split('.').last.toLowerCase();
+    const map = {
+      'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'png': 'image/png',
+      'gif': 'image/gif', 'webp': 'image/webp', 'heic': 'image/heic',
+      'pdf': 'application/pdf', 'doc': 'application/msword',
+      'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+      'xls': 'application/vnd.ms-excel',
+      'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+      'txt': 'text/plain', 'csv': 'text/csv', 'json': 'application/json',
+      'zip': 'application/zip', 'mp3': 'audio/mpeg', 'mp4': 'video/mp4',
+    };
+    return map[ext] ?? 'application/octet-stream';
+  }
+
+  String _formatSize(int bytes) {
+    if (bytes < 1024) return '$bytes B';
+    if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
+    return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
+  }
+
   void _requestScreenshot() {
     _screenshotForChat = true;
     _sendCommand('screenshot', {'sessionId': ref.read(activeSessionIdProvider)});
@@ -683,15 +760,59 @@
   }
 
   Future<void> _pickPhoto() async {
-    // Capture session ID now — before any async gaps (dialog, encoding)
     final targetSessionId = ref.read(activeSessionIdProvider);
 
-    final picker = ImagePicker();
-    final images = await picker.pickMultiImage(
-      maxWidth: 1920,
-      maxHeight: 1080,
-      imageQuality: 85,
+    // Show picker options
+    final source = await showModalBottomSheet<String>(
+      context: context,
+      builder: (ctx) => SafeArea(
+        child: Column(
+          mainAxisSize: MainAxisSize.min,
+          children: [
+            ListTile(
+              leading: const Icon(Icons.camera_alt),
+              title: const Text('Take Photo'),
+              onTap: () => Navigator.pop(ctx, 'camera'),
+            ),
+            ListTile(
+              leading: const Icon(Icons.photo_library),
+              title: const Text('Photo Library'),
+              onTap: () => Navigator.pop(ctx, 'gallery'),
+            ),
+            ListTile(
+              leading: const Icon(Icons.attach_file),
+              title: const Text('Files'),
+              onTap: () => Navigator.pop(ctx, 'files'),
+            ),
+          ],
+        ),
+      ),
     );
+    if (source == null) return;
+
+    if (source == 'files') {
+      await _pickFiles(targetSessionId);
+      return;
+    }
+
+    List<XFile> images;
+    final picker = ImagePicker();
+    if (source == 'camera') {
+      final photo = await picker.pickImage(
+        source: ImageSource.camera,
+        maxWidth: 1920,
+        maxHeight: 1080,
+        imageQuality: 85,
+      );
+      if (photo == null) return;
+      images = [photo];
+    } else {
+      images = await picker.pickMultiImage(
+        maxWidth: 1920,
+        maxHeight: 1080,
+        imageQuality: 85,
+      );
+    }
 
     if (images.isEmpty) return;
 
diff --git a/lib/widgets/command_bar.dart b/lib/widgets/command_bar.dart
index 8d5e52b..583978c 100644
--- a/lib/widgets/command_bar.dart
+++ b/lib/widgets/command_bar.dart
@@ -45,7 +45,7 @@
           ),
           _CommandButton(
             icon: Icons.attach_file,
-            label: 'Photo',
+            label: 'Attach',
             onTap: onPhoto,
           ),
           _CommandButton(
diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift
index 51772ed..67a6ae9 100644
--- a/macos/Flutter/GeneratedPluginRegistrant.swift
+++ b/macos/Flutter/GeneratedPluginRegistrant.swift
@@ -7,6 +7,7 @@
 
 import audioplayers_darwin
 import device_info_plus
+import file_picker
 import file_selector_macos
 import flutter_secure_storage_macos
 import package_info_plus
@@ -18,6 +19,7 @@
 func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
   AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin"))
   DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
+  FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
   FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
   FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
   FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
diff --git a/pubspec.lock b/pubspec.lock
index 61cba3b..c246b00 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -193,6 +193,14 @@
       url: "https://pub.dev"
     source: hosted
     version: "7.0.1"
+  file_picker:
+    dependency: "direct main"
+    description:
+      name: file_picker
+      sha256: "57d9a1dd5063f85fa3107fb42d1faffda52fdc948cefd5fe5ea85267a5fc7343"
+      url: "https://pub.dev"
+    source: hosted
+    version: "10.3.10"
   file_selector_linux:
     dependency: transitive
     description:
diff --git a/pubspec.yaml b/pubspec.yaml
index 7ad8705..a892e29 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -29,6 +29,7 @@
   mqtt_client: ^10.6.0
   uuid: ^4.5.1
   collection: ^1.19.1
+  file_picker: ^10.3.10
 
 dev_dependencies:
   flutter_test:

--
Gitblit v1.3.1