From 78f101ac853aeea5d17067bec4215e070fc71fed Mon Sep 17 00:00:00 2001
From: Matthias Nott <mnott@mnsoft.org>
Date: Mon, 23 Mar 2026 12:01:07 +0100
Subject: [PATCH] feat: attach button with camera/gallery/files, atomic bundle send

---
 lib/services/mqtt_service.dart |   32 +++++++++++++++-
 lib/screens/chat_screen.dart   |   83 +++++++++++++++++++----------------------
 2 files changed, 68 insertions(+), 47 deletions(-)

diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart
index cec56f8..90fabf2 100644
--- a/lib/screens/chat_screen.dart
+++ b/lib/screens/chat_screen.dart
@@ -671,51 +671,45 @@
     );
     if (result == null || result.files.isEmpty) return;
 
+    // Build attachments list
+    final attachments = <Map<String, dynamic>>[];
     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(
+      attachments.add({
+        'data': b64,
+        'mimeType': mimeType,
+        'fileName': file.name,
+      });
+
+      // Show in chat
+      if (mimeType.startsWith('image/')) {
+        ref.read(messagesProvider.notifier).addMessage(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(
+        ref.read(messagesProvider.notifier).addMessage(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,
-        });
+        ));
       }
     }
+
+    // Send all files as one atomic bundle
+    _ws?.send({
+      'type': 'bundle',
+      'caption': '',
+      'attachments': attachments,
+      'sessionId': targetSessionId,
+    });
+
     _scrollToBottom();
   }
 
@@ -839,8 +833,21 @@
       textCaption = '';
     }
 
-    // Send voice FIRST so Whisper transcribes it and the [PAILot:voice] prefix
-    // sets the reply channel. Images follow — Claude sees transcript + images together.
+    // Send everything as a single atomic bundle — the server will compose
+    // all attachments + caption into one terminal input (like dropping files + text)
+    final attachments = encodedImages.map((b64) =>
+      <String, dynamic>{'data': b64, 'mimeType': 'image/jpeg'}
+    ).toList();
+
+    _ws?.send({
+      'type': 'bundle',
+      'caption': textCaption,
+      if (voiceB64 != null) 'audioBase64': voiceB64,
+      'attachments': attachments,
+      'sessionId': targetSessionId,
+    });
+
+    // If voice caption, also send separately for Whisper transcription
     if (voiceB64 != null) {
       final voiceMsg = Message.voice(
         role: MessageRole.user,
@@ -853,20 +860,6 @@
         'audioBase64': voiceB64,
         'content': '',
         'messageId': voiceMsg.id,
-        'sessionId': targetSessionId,
-      });
-    }
-
-    // Send images — first with text caption (if any), rest without
-    for (var i = 0; i < encodedImages.length; i++) {
-      final isFirst = i == 0;
-      final msgCaption = isFirst ? textCaption : '';
-
-      _ws?.send({
-        'type': 'image',
-        'imageBase64': encodedImages[i],
-        'mimeType': 'image/jpeg',
-        'caption': msgCaption,
         'sessionId': targetSessionId,
       });
     }
diff --git a/lib/services/mqtt_service.dart b/lib/services/mqtt_service.dart
index 412291f..1d4cfa9 100644
--- a/lib/services/mqtt_service.dart
+++ b/lib/services/mqtt_service.dart
@@ -141,7 +141,8 @@
       client.keepAlivePeriod = 30;
       client.autoReconnect = false; // Don't auto-reconnect during trial — enable after success
       client.connectTimeoutPeriod = timeout;
-      client.logging(on: true);
+      // client.maxConnectionAttempts is final — can't set it
+      client.logging(on: false);
 
       client.onConnected = _onConnected;
       client.onDisconnected = _onDisconnected;
@@ -407,7 +408,6 @@
     }
 
     if (type == 'image' && sessionId != null) {
-      // Image message
       _publish('pailot/$sessionId/in', {
         'msgId': _uuid(),
         'type': 'image',
@@ -420,6 +420,34 @@
       return;
     }
 
+    if (type == 'bundle' && sessionId != null) {
+      // Atomic multi-attachment message
+      _publish('pailot/$sessionId/in', {
+        'msgId': _uuid(),
+        'type': 'bundle',
+        'sessionId': sessionId,
+        'caption': message['caption'] ?? '',
+        if (message['audioBase64'] != null) 'audioBase64': message['audioBase64'],
+        'attachments': message['attachments'] ?? [],
+        'ts': _now(),
+      }, MqttQos.atLeastOnce);
+      return;
+    }
+
+    if (type == 'file' && sessionId != null) {
+      _publish('pailot/$sessionId/in', {
+        'msgId': _uuid(),
+        'type': 'file',
+        'sessionId': sessionId,
+        'fileBase64': message['fileBase64'] ?? '',
+        'fileName': message['fileName'] ?? 'file',
+        'mimeType': message['mimeType'] ?? 'application/octet-stream',
+        'fileSize': message['fileSize'] ?? 0,
+        'ts': _now(),
+      }, MqttQos.atLeastOnce);
+      return;
+    }
+
     if (type == 'tts' && sessionId != null) {
       // TTS request — route as command
       _publish('pailot/control/in', {

--
Gitblit v1.3.1