From cda5ac96c4802f7c33f8f0099a8c9c34423dde4a Mon Sep 17 00:00:00 2001
From: Matthias Nott <mnott@mnsoft.org>
Date: Sat, 11 Apr 2026 09:27:07 +0200
Subject: [PATCH] feat: PDF and document file viewing support

---
 lib/services/mqtt_service.dart  |   72 ++++++++++++++++--
 lib/widgets/message_bubble.dart |  138 ++++++++++++++++++++++++++++++++++
 lib/models/message.dart         |    9 ++
 lib/screens/chat_screen.dart    |    3 
 4 files changed, 214 insertions(+), 8 deletions(-)

diff --git a/lib/models/message.dart b/lib/models/message.dart
index 8a034ec..36c89f6 100644
--- a/lib/models/message.dart
+++ b/lib/models/message.dart
@@ -13,6 +13,7 @@
   final String content;
   final String? audioUri;
   final String? imageBase64;
+  final String? mimeType;
   final int timestamp;
   final MessageStatus? status;
   final int? duration;
@@ -25,6 +26,7 @@
     required this.timestamp,
     this.audioUri,
     this.imageBase64,
+    this.mimeType,
     this.status,
     this.duration,
   });
@@ -67,6 +69,7 @@
     required MessageRole role,
     required String imageBase64,
     String content = '',
+    String? mimeType,
     MessageStatus? status,
   }) {
     return Message(
@@ -75,6 +78,7 @@
       type: MessageType.image,
       content: content,
       imageBase64: imageBase64,
+      mimeType: mimeType,
       timestamp: DateTime.now().millisecondsSinceEpoch,
       status: status,
     );
@@ -84,6 +88,7 @@
     String? content,
     String? audioUri,
     String? imageBase64,
+    String? mimeType,
     MessageStatus? status,
     int? duration,
   }) {
@@ -94,6 +99,7 @@
       content: content ?? this.content,
       audioUri: audioUri ?? this.audioUri,
       imageBase64: imageBase64 ?? this.imageBase64,
+      mimeType: mimeType ?? this.mimeType,
       timestamp: timestamp,
       status: status ?? this.status,
       duration: duration ?? this.duration,
@@ -108,6 +114,7 @@
       'content': content,
       if (audioUri != null) 'audioUri': audioUri,
       if (imageBase64 != null) 'imageBase64': imageBase64,
+      if (mimeType != null) 'mimeType': mimeType,
       'timestamp': timestamp,
       if (status != null) 'status': status!.name,
       if (duration != null) 'duration': duration,
@@ -130,6 +137,7 @@
       if (duration != null) 'duration': duration,
       // Keep imageBase64 — images are typically 50-200 KB and must survive restart.
       if (imageBase64 != null) 'imageBase64': imageBase64,
+      if (mimeType != null) 'mimeType': mimeType,
     };
   }
 
@@ -141,6 +149,7 @@
       content: json['content'] as String? ?? '',
       audioUri: json['audioUri'] as String?,
       imageBase64: json['imageBase64'] as String?,
+      mimeType: json['mimeType'] as String?,
       timestamp: json['timestamp'] as int,
       status: json['status'] != null
           ? MessageStatus.values.byName(json['status'] as String)
diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart
index 1622283..20b3f6a 100644
--- a/lib/screens/chat_screen.dart
+++ b/lib/screens/chat_screen.dart
@@ -413,6 +413,7 @@
                 role: MessageRole.assistant,
                 imageBase64: imageData,
                 content: content,
+                mimeType: map['mimeType'] as String?,
                 status: MessageStatus.sent,
               );
             } else {
@@ -679,10 +680,12 @@
       _screenshotForChat = false;
     }
 
+    final mimeType = msg['mimeType'] as String?;
     final message = Message.image(
       role: MessageRole.assistant,
       imageBase64: imageData,
       content: content,
+      mimeType: mimeType,
       status: MessageStatus.sent,
     );
 
diff --git a/lib/services/mqtt_service.dart b/lib/services/mqtt_service.dart
index 0c0502e..0718063 100644
--- a/lib/services/mqtt_service.dart
+++ b/lib/services/mqtt_service.dart
@@ -68,6 +68,13 @@
   // (Per-session subscriptions removed — single pailot/out topic now)
   static const int _maxSeenIds = 500;
 
+  // Reconnect backoff
+  Timer? _reconnectTimer;
+  Timer? _stabilityTimer;
+  int _reconnectAttempt = 0;
+  static const int _maxReconnectDelay = 30000; // 30s cap
+  static const int _stabilityThresholdMs = 10000; // 10s stable = reset backoff
+
   // Callbacks
   void Function(ConnectionStatus status)? onStatusChanged;
   void Function(String detail)? onStatusDetail; // "Probing local...", "Scanning network..."
@@ -120,16 +127,16 @@
   }
 
   /// Fast reconnect to a known host — skips discovery, short timeout.
-  Future<void> _fastReconnect(String host) async {
+  /// Returns true if connected, false if failed.
+  Future<bool> _fastReconnect(String host) async {
     _mqttLog('MQTT: fast reconnect to $host');
     final clientId = await _getClientId();
     if (await _tryConnect(host, clientId, timeout: 2000)) {
       connectedHost = host;
-      return;
+      return true;
     }
-    // Fast path failed — fall back to full connect
-    _mqttLog('MQTT: fast reconnect failed, full connect...');
-    connect();
+    _mqttLog('MQTT: fast reconnect failed');
+    return false;
   }
 
   /// Connect to the MQTT broker.
@@ -440,7 +447,9 @@
       );
       _mqttLog('MQTT: connect result=${result?.state}');
       if (result?.state == MqttConnectionState.connected) {
-        client.autoReconnect = true;
+        // Don't use autoReconnect — it has no backoff and causes tight reconnect loops.
+        // We handle reconnection manually in _onDisconnected with exponential backoff.
+        _reconnectAttempt = 0;
         return true;
       }
       _client = null;
@@ -454,6 +463,17 @@
 
   void _onConnected() {
     _mqttLog('MQTT: _onConnected fired');
+    _reconnectTimer?.cancel();
+    // Don't reset _reconnectAttempt here — only after the connection has been
+    // STABLE for 10+ seconds. This prevents flap loops where each brief connect
+    // resets the backoff and we hammer the server every 5s forever.
+    _stabilityTimer?.cancel();
+    _stabilityTimer = Timer(const Duration(milliseconds: _stabilityThresholdMs), () {
+      if (_status == ConnectionStatus.connected) {
+        _mqttLog('MQTT: connection stable for ${_stabilityThresholdMs}ms — resetting backoff');
+        _reconnectAttempt = 0;
+      }
+    });
     _setStatus(ConnectionStatus.connected);
     _subscribe();
     _listenMessages();
@@ -461,6 +481,7 @@
   }
 
   void _onDisconnected() {
+    _stabilityTimer?.cancel();
     _updatesSub?.cancel();
     _updatesSub = null;
 
@@ -470,15 +491,41 @@
     } else {
       _setStatus(ConnectionStatus.reconnecting);
       onReconnecting?.call();
+      _scheduleReconnect();
     }
   }
 
+  void _scheduleReconnect() {
+    _reconnectTimer?.cancel();
+    // Exponential backoff: 1s, 2s, 4s, 8s, 16s, 30s cap
+    final delayMs = (1000 * (1 << _reconnectAttempt)).clamp(1000, _maxReconnectDelay);
+    _reconnectAttempt++;
+    _mqttLog('MQTT: scheduling reconnect in ${delayMs}ms (attempt $_reconnectAttempt)');
+    _reconnectTimer = Timer(Duration(milliseconds: delayMs), () async {
+      if (_intentionalClose || _status == ConnectionStatus.connected) return;
+      final host = connectedHost ?? _lastDiscoveredHost;
+      if (host != null) {
+        _mqttLog('MQTT: reconnect attempt $_reconnectAttempt to $host');
+        final ok = await _fastReconnect(host);
+        if (!ok && !_intentionalClose) {
+          _scheduleReconnect(); // Try again with increased backoff
+        }
+      } else {
+        _mqttLog('MQTT: no known host, running full connect');
+        await connect();
+      }
+    });
+  }
+
   void _onAutoReconnect() {
+    // Unused — autoReconnect is disabled, but keep callback for safety
     _setStatus(ConnectionStatus.reconnecting);
     onReconnecting?.call();
   }
 
   void _onAutoReconnected() {
+    // Unused — autoReconnect is disabled, but keep callback for safety
+    _reconnectAttempt = 0;
     _setStatus(ConnectionStatus.connected);
     _subscribe();
     _listenMessages();
@@ -764,6 +811,11 @@
   /// Disconnect intentionally.
   void disconnect() {
     _intentionalClose = true;
+    _reconnectTimer?.cancel();
+    _reconnectTimer = null;
+    _stabilityTimer?.cancel();
+    _stabilityTimer = null;
+    _reconnectAttempt = 0;
     _updatesSub?.cancel();
     _updatesSub = null;
     _connectivitySub?.cancel();
@@ -799,8 +851,12 @@
       case AppLifecycleState.resumed:
         if (_intentionalClose) break;
         _mqttLog('MQTT: app resumed');
-        // Let autoReconnect handle dead connections (keepalive timeout).
-        // Just trigger catch_up to fetch missed messages and rebuild UI.
+        // If disconnected, trigger immediate reconnect (reset backoff).
+        if (_status != ConnectionStatus.connected) {
+          _reconnectAttempt = 0;
+          _scheduleReconnect();
+        }
+        // Trigger catch_up to fetch missed messages and rebuild UI.
         onResume?.call();
       case AppLifecycleState.paused:
         break;
diff --git a/lib/widgets/message_bubble.dart b/lib/widgets/message_bubble.dart
index da6238a..df9e238 100644
--- a/lib/widgets/message_bubble.dart
+++ b/lib/widgets/message_bubble.dart
@@ -1,10 +1,13 @@
 import 'dart:convert';
+import 'dart:io';
 import 'dart:math';
 import 'dart:typed_data';
 
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
 import 'package:flutter_markdown/flutter_markdown.dart';
+import 'package:path_provider/path_provider.dart';
+import 'package:share_plus/share_plus.dart';
 import 'package:url_launcher/url_launcher.dart';
 import 'package:intl/intl.dart';
 
@@ -263,9 +266,20 @@
     );
   }
 
+  /// True if the mimeType is a renderable image format.
+  bool get _isImageMime {
+    final mime = message.mimeType?.toLowerCase() ?? 'image/jpeg';
+    return mime.startsWith('image/');
+  }
+
   Widget _buildImageContent(BuildContext context) {
     if (message.imageBase64 == null || message.imageBase64!.isEmpty) {
       return const Text('Image unavailable');
+    }
+
+    // Non-image files (PDF, CSV, etc.) — show file card instead of Image.memory
+    if (!_isImageMime) {
+      return _buildFileCard(context);
     }
 
     // Cache decoded bytes to prevent flicker on rebuild; evict oldest if over 50 entries
@@ -322,6 +336,130 @@
     );
   }
 
+  Widget _buildFileCard(BuildContext context) {
+    final mime = message.mimeType ?? 'application/octet-stream';
+    final caption = message.content.isNotEmpty ? message.content : 'File';
+    final isPdf = mime == 'application/pdf';
+
+    // File type icon
+    IconData icon;
+    Color iconColor;
+    if (isPdf) {
+      icon = Icons.picture_as_pdf;
+      iconColor = Colors.red;
+    } else if (mime.contains('spreadsheet') || mime.contains('excel') || mime == 'text/csv') {
+      icon = Icons.table_chart;
+      iconColor = Colors.green;
+    } else if (mime.contains('word') || mime.contains('document')) {
+      icon = Icons.description;
+      iconColor = Colors.blue;
+    } else if (mime == 'text/plain' || mime == 'application/json') {
+      icon = Icons.text_snippet;
+      iconColor = Colors.grey;
+    } else {
+      icon = Icons.insert_drive_file;
+      iconColor = Colors.blueGrey;
+    }
+
+    final sizeKB = ((message.imageBase64?.length ?? 0) * 3 / 4 / 1024).round();
+
+    return GestureDetector(
+      onTap: () => _openFile(context),
+      child: Container(
+        width: 260,
+        padding: const EdgeInsets.all(12),
+        decoration: BoxDecoration(
+          color: (_isUser ? Colors.white : Theme.of(context).colorScheme.primary).withAlpha(25),
+          borderRadius: BorderRadius.circular(8),
+          border: Border.all(
+            color: (_isUser ? Colors.white : Colors.grey).withAlpha(50),
+          ),
+        ),
+        child: Column(
+          crossAxisAlignment: CrossAxisAlignment.start,
+          children: [
+            Row(
+              children: [
+                Icon(icon, size: 32, color: iconColor),
+                const SizedBox(width: 10),
+                Expanded(
+                  child: Column(
+                    crossAxisAlignment: CrossAxisAlignment.start,
+                    children: [
+                      Text(
+                        caption,
+                        style: TextStyle(
+                          fontSize: 14,
+                          fontWeight: FontWeight.w600,
+                          color: _isUser ? Colors.white : null,
+                        ),
+                        maxLines: 2,
+                        overflow: TextOverflow.ellipsis,
+                      ),
+                      const SizedBox(height: 2),
+                      Text(
+                        '${mime.split('/').last.toUpperCase()} - ${sizeKB} KB',
+                        style: TextStyle(
+                          fontSize: 11,
+                          color: (_isUser ? Colors.white : Colors.grey).withAlpha(180),
+                        ),
+                      ),
+                    ],
+                  ),
+                ),
+                Icon(
+                  Icons.open_in_new,
+                  size: 20,
+                  color: (_isUser ? Colors.white : Colors.grey).withAlpha(150),
+                ),
+              ],
+            ),
+          ],
+        ),
+      ),
+    );
+  }
+
+  Future<void> _openFile(BuildContext context) async {
+    final data = message.imageBase64;
+    if (data == null || data.isEmpty) return;
+
+    try {
+      final bytes = base64Decode(data.contains(',') ? data.split(',').last : data);
+      final mime = message.mimeType ?? 'application/octet-stream';
+      final ext = _mimeToExt(mime);
+      final dir = await getTemporaryDirectory();
+      final fileName = '${message.content.isNotEmpty ? message.content.replaceAll(RegExp(r'[^\w\s.-]'), '').trim() : 'file'}.$ext';
+      final file = File('${dir.path}/$fileName');
+      await file.writeAsBytes(bytes);
+
+      // Use share sheet — works for PDFs, Office docs, and all file types on iOS
+      await SharePlus.instance.share(
+        ShareParams(files: [XFile(file.path, mimeType: mime)]),
+      );
+    } catch (e) {
+      if (context.mounted) {
+        ScaffoldMessenger.of(context).showSnackBar(
+          SnackBar(content: Text('Could not open file: $e')),
+        );
+      }
+    }
+  }
+
+  String _mimeToExt(String mime) {
+    const map = {
+      'application/pdf': 'pdf',
+      'application/msword': 'doc',
+      'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
+      'application/vnd.ms-excel': 'xls',
+      'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx',
+      'text/plain': 'txt',
+      'text/csv': 'csv',
+      'application/json': 'json',
+    };
+    return map[mime] ?? 'bin';
+  }
+
   Widget _buildFooter(BuildContext context) {
     final isDark = Theme.of(context).brightness == Brightness.dark;
     final dt = DateTime.fromMillisecondsSinceEpoch(message.timestamp);

--
Gitblit v1.3.1