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