Matthias Nott
3 days ago cda5ac96c4802f7c33f8f0099a8c9c34423dde4a
feat: PDF and document file viewing support

- Add mimeType field to Message model, persisted through JSON
- Pass mimeType from MQTT handler through to message bubbles
- Non-image files (PDF, CSV, Office docs) render as file cards
with type icon, size info, and tap-to-share via iOS share sheet
- Fix "image decode error" when receiving PDFs via pailot_send_file
- Reconnect backoff: only reset attempt counter after 10s stability
4 files modified
changed files
lib/models/message.dart patch | view | blame | history
lib/screens/chat_screen.dart patch | view | blame | history
lib/services/mqtt_service.dart patch | view | blame | history
lib/widgets/message_bubble.dart patch | view | blame | history
lib/models/message.dart
....@@ -13,6 +13,7 @@
1313 final String content;
1414 final String? audioUri;
1515 final String? imageBase64;
16
+ final String? mimeType;
1617 final int timestamp;
1718 final MessageStatus? status;
1819 final int? duration;
....@@ -25,6 +26,7 @@
2526 required this.timestamp,
2627 this.audioUri,
2728 this.imageBase64,
29
+ this.mimeType,
2830 this.status,
2931 this.duration,
3032 });
....@@ -67,6 +69,7 @@
6769 required MessageRole role,
6870 required String imageBase64,
6971 String content = '',
72
+ String? mimeType,
7073 MessageStatus? status,
7174 }) {
7275 return Message(
....@@ -75,6 +78,7 @@
7578 type: MessageType.image,
7679 content: content,
7780 imageBase64: imageBase64,
81
+ mimeType: mimeType,
7882 timestamp: DateTime.now().millisecondsSinceEpoch,
7983 status: status,
8084 );
....@@ -84,6 +88,7 @@
8488 String? content,
8589 String? audioUri,
8690 String? imageBase64,
91
+ String? mimeType,
8792 MessageStatus? status,
8893 int? duration,
8994 }) {
....@@ -94,6 +99,7 @@
9499 content: content ?? this.content,
95100 audioUri: audioUri ?? this.audioUri,
96101 imageBase64: imageBase64 ?? this.imageBase64,
102
+ mimeType: mimeType ?? this.mimeType,
97103 timestamp: timestamp,
98104 status: status ?? this.status,
99105 duration: duration ?? this.duration,
....@@ -108,6 +114,7 @@
108114 'content': content,
109115 if (audioUri != null) 'audioUri': audioUri,
110116 if (imageBase64 != null) 'imageBase64': imageBase64,
117
+ if (mimeType != null) 'mimeType': mimeType,
111118 'timestamp': timestamp,
112119 if (status != null) 'status': status!.name,
113120 if (duration != null) 'duration': duration,
....@@ -130,6 +137,7 @@
130137 if (duration != null) 'duration': duration,
131138 // Keep imageBase64 — images are typically 50-200 KB and must survive restart.
132139 if (imageBase64 != null) 'imageBase64': imageBase64,
140
+ if (mimeType != null) 'mimeType': mimeType,
133141 };
134142 }
135143
....@@ -141,6 +149,7 @@
141149 content: json['content'] as String? ?? '',
142150 audioUri: json['audioUri'] as String?,
143151 imageBase64: json['imageBase64'] as String?,
152
+ mimeType: json['mimeType'] as String?,
144153 timestamp: json['timestamp'] as int,
145154 status: json['status'] != null
146155 ? MessageStatus.values.byName(json['status'] as String)
lib/screens/chat_screen.dart
....@@ -413,6 +413,7 @@
413413 role: MessageRole.assistant,
414414 imageBase64: imageData,
415415 content: content,
416
+ mimeType: map['mimeType'] as String?,
416417 status: MessageStatus.sent,
417418 );
418419 } else {
....@@ -679,10 +680,12 @@
679680 _screenshotForChat = false;
680681 }
681682
683
+ final mimeType = msg['mimeType'] as String?;
682684 final message = Message.image(
683685 role: MessageRole.assistant,
684686 imageBase64: imageData,
685687 content: content,
688
+ mimeType: mimeType,
686689 status: MessageStatus.sent,
687690 );
688691
lib/services/mqtt_service.dart
....@@ -68,6 +68,13 @@
6868 // (Per-session subscriptions removed — single pailot/out topic now)
6969 static const int _maxSeenIds = 500;
7070
71
+ // Reconnect backoff
72
+ Timer? _reconnectTimer;
73
+ Timer? _stabilityTimer;
74
+ int _reconnectAttempt = 0;
75
+ static const int _maxReconnectDelay = 30000; // 30s cap
76
+ static const int _stabilityThresholdMs = 10000; // 10s stable = reset backoff
77
+
7178 // Callbacks
7279 void Function(ConnectionStatus status)? onStatusChanged;
7380 void Function(String detail)? onStatusDetail; // "Probing local...", "Scanning network..."
....@@ -120,16 +127,16 @@
120127 }
121128
122129 /// Fast reconnect to a known host — skips discovery, short timeout.
123
- Future<void> _fastReconnect(String host) async {
130
+ /// Returns true if connected, false if failed.
131
+ Future<bool> _fastReconnect(String host) async {
124132 _mqttLog('MQTT: fast reconnect to $host');
125133 final clientId = await _getClientId();
126134 if (await _tryConnect(host, clientId, timeout: 2000)) {
127135 connectedHost = host;
128
- return;
136
+ return true;
129137 }
130
- // Fast path failed — fall back to full connect
131
- _mqttLog('MQTT: fast reconnect failed, full connect...');
132
- connect();
138
+ _mqttLog('MQTT: fast reconnect failed');
139
+ return false;
133140 }
134141
135142 /// Connect to the MQTT broker.
....@@ -440,7 +447,9 @@
440447 );
441448 _mqttLog('MQTT: connect result=${result?.state}');
442449 if (result?.state == MqttConnectionState.connected) {
443
- client.autoReconnect = true;
450
+ // Don't use autoReconnect — it has no backoff and causes tight reconnect loops.
451
+ // We handle reconnection manually in _onDisconnected with exponential backoff.
452
+ _reconnectAttempt = 0;
444453 return true;
445454 }
446455 _client = null;
....@@ -454,6 +463,17 @@
454463
455464 void _onConnected() {
456465 _mqttLog('MQTT: _onConnected fired');
466
+ _reconnectTimer?.cancel();
467
+ // Don't reset _reconnectAttempt here — only after the connection has been
468
+ // STABLE for 10+ seconds. This prevents flap loops where each brief connect
469
+ // resets the backoff and we hammer the server every 5s forever.
470
+ _stabilityTimer?.cancel();
471
+ _stabilityTimer = Timer(const Duration(milliseconds: _stabilityThresholdMs), () {
472
+ if (_status == ConnectionStatus.connected) {
473
+ _mqttLog('MQTT: connection stable for ${_stabilityThresholdMs}ms — resetting backoff');
474
+ _reconnectAttempt = 0;
475
+ }
476
+ });
457477 _setStatus(ConnectionStatus.connected);
458478 _subscribe();
459479 _listenMessages();
....@@ -461,6 +481,7 @@
461481 }
462482
463483 void _onDisconnected() {
484
+ _stabilityTimer?.cancel();
464485 _updatesSub?.cancel();
465486 _updatesSub = null;
466487
....@@ -470,15 +491,41 @@
470491 } else {
471492 _setStatus(ConnectionStatus.reconnecting);
472493 onReconnecting?.call();
494
+ _scheduleReconnect();
473495 }
474496 }
475497
498
+ void _scheduleReconnect() {
499
+ _reconnectTimer?.cancel();
500
+ // Exponential backoff: 1s, 2s, 4s, 8s, 16s, 30s cap
501
+ final delayMs = (1000 * (1 << _reconnectAttempt)).clamp(1000, _maxReconnectDelay);
502
+ _reconnectAttempt++;
503
+ _mqttLog('MQTT: scheduling reconnect in ${delayMs}ms (attempt $_reconnectAttempt)');
504
+ _reconnectTimer = Timer(Duration(milliseconds: delayMs), () async {
505
+ if (_intentionalClose || _status == ConnectionStatus.connected) return;
506
+ final host = connectedHost ?? _lastDiscoveredHost;
507
+ if (host != null) {
508
+ _mqttLog('MQTT: reconnect attempt $_reconnectAttempt to $host');
509
+ final ok = await _fastReconnect(host);
510
+ if (!ok && !_intentionalClose) {
511
+ _scheduleReconnect(); // Try again with increased backoff
512
+ }
513
+ } else {
514
+ _mqttLog('MQTT: no known host, running full connect');
515
+ await connect();
516
+ }
517
+ });
518
+ }
519
+
476520 void _onAutoReconnect() {
521
+ // Unused — autoReconnect is disabled, but keep callback for safety
477522 _setStatus(ConnectionStatus.reconnecting);
478523 onReconnecting?.call();
479524 }
480525
481526 void _onAutoReconnected() {
527
+ // Unused — autoReconnect is disabled, but keep callback for safety
528
+ _reconnectAttempt = 0;
482529 _setStatus(ConnectionStatus.connected);
483530 _subscribe();
484531 _listenMessages();
....@@ -764,6 +811,11 @@
764811 /// Disconnect intentionally.
765812 void disconnect() {
766813 _intentionalClose = true;
814
+ _reconnectTimer?.cancel();
815
+ _reconnectTimer = null;
816
+ _stabilityTimer?.cancel();
817
+ _stabilityTimer = null;
818
+ _reconnectAttempt = 0;
767819 _updatesSub?.cancel();
768820 _updatesSub = null;
769821 _connectivitySub?.cancel();
....@@ -799,8 +851,12 @@
799851 case AppLifecycleState.resumed:
800852 if (_intentionalClose) break;
801853 _mqttLog('MQTT: app resumed');
802
- // Let autoReconnect handle dead connections (keepalive timeout).
803
- // Just trigger catch_up to fetch missed messages and rebuild UI.
854
+ // If disconnected, trigger immediate reconnect (reset backoff).
855
+ if (_status != ConnectionStatus.connected) {
856
+ _reconnectAttempt = 0;
857
+ _scheduleReconnect();
858
+ }
859
+ // Trigger catch_up to fetch missed messages and rebuild UI.
804860 onResume?.call();
805861 case AppLifecycleState.paused:
806862 break;
lib/widgets/message_bubble.dart
....@@ -1,10 +1,13 @@
11 import 'dart:convert';
2
+import 'dart:io';
23 import 'dart:math';
34 import 'dart:typed_data';
45
56 import 'package:flutter/material.dart';
67 import 'package:flutter/services.dart';
78 import 'package:flutter_markdown/flutter_markdown.dart';
9
+import 'package:path_provider/path_provider.dart';
10
+import 'package:share_plus/share_plus.dart';
811 import 'package:url_launcher/url_launcher.dart';
912 import 'package:intl/intl.dart';
1013
....@@ -263,9 +266,20 @@
263266 );
264267 }
265268
269
+ /// True if the mimeType is a renderable image format.
270
+ bool get _isImageMime {
271
+ final mime = message.mimeType?.toLowerCase() ?? 'image/jpeg';
272
+ return mime.startsWith('image/');
273
+ }
274
+
266275 Widget _buildImageContent(BuildContext context) {
267276 if (message.imageBase64 == null || message.imageBase64!.isEmpty) {
268277 return const Text('Image unavailable');
278
+ }
279
+
280
+ // Non-image files (PDF, CSV, etc.) — show file card instead of Image.memory
281
+ if (!_isImageMime) {
282
+ return _buildFileCard(context);
269283 }
270284
271285 // Cache decoded bytes to prevent flicker on rebuild; evict oldest if over 50 entries
....@@ -322,6 +336,130 @@
322336 );
323337 }
324338
339
+ Widget _buildFileCard(BuildContext context) {
340
+ final mime = message.mimeType ?? 'application/octet-stream';
341
+ final caption = message.content.isNotEmpty ? message.content : 'File';
342
+ final isPdf = mime == 'application/pdf';
343
+
344
+ // File type icon
345
+ IconData icon;
346
+ Color iconColor;
347
+ if (isPdf) {
348
+ icon = Icons.picture_as_pdf;
349
+ iconColor = Colors.red;
350
+ } else if (mime.contains('spreadsheet') || mime.contains('excel') || mime == 'text/csv') {
351
+ icon = Icons.table_chart;
352
+ iconColor = Colors.green;
353
+ } else if (mime.contains('word') || mime.contains('document')) {
354
+ icon = Icons.description;
355
+ iconColor = Colors.blue;
356
+ } else if (mime == 'text/plain' || mime == 'application/json') {
357
+ icon = Icons.text_snippet;
358
+ iconColor = Colors.grey;
359
+ } else {
360
+ icon = Icons.insert_drive_file;
361
+ iconColor = Colors.blueGrey;
362
+ }
363
+
364
+ final sizeKB = ((message.imageBase64?.length ?? 0) * 3 / 4 / 1024).round();
365
+
366
+ return GestureDetector(
367
+ onTap: () => _openFile(context),
368
+ child: Container(
369
+ width: 260,
370
+ padding: const EdgeInsets.all(12),
371
+ decoration: BoxDecoration(
372
+ color: (_isUser ? Colors.white : Theme.of(context).colorScheme.primary).withAlpha(25),
373
+ borderRadius: BorderRadius.circular(8),
374
+ border: Border.all(
375
+ color: (_isUser ? Colors.white : Colors.grey).withAlpha(50),
376
+ ),
377
+ ),
378
+ child: Column(
379
+ crossAxisAlignment: CrossAxisAlignment.start,
380
+ children: [
381
+ Row(
382
+ children: [
383
+ Icon(icon, size: 32, color: iconColor),
384
+ const SizedBox(width: 10),
385
+ Expanded(
386
+ child: Column(
387
+ crossAxisAlignment: CrossAxisAlignment.start,
388
+ children: [
389
+ Text(
390
+ caption,
391
+ style: TextStyle(
392
+ fontSize: 14,
393
+ fontWeight: FontWeight.w600,
394
+ color: _isUser ? Colors.white : null,
395
+ ),
396
+ maxLines: 2,
397
+ overflow: TextOverflow.ellipsis,
398
+ ),
399
+ const SizedBox(height: 2),
400
+ Text(
401
+ '${mime.split('/').last.toUpperCase()} - ${sizeKB} KB',
402
+ style: TextStyle(
403
+ fontSize: 11,
404
+ color: (_isUser ? Colors.white : Colors.grey).withAlpha(180),
405
+ ),
406
+ ),
407
+ ],
408
+ ),
409
+ ),
410
+ Icon(
411
+ Icons.open_in_new,
412
+ size: 20,
413
+ color: (_isUser ? Colors.white : Colors.grey).withAlpha(150),
414
+ ),
415
+ ],
416
+ ),
417
+ ],
418
+ ),
419
+ ),
420
+ );
421
+ }
422
+
423
+ Future<void> _openFile(BuildContext context) async {
424
+ final data = message.imageBase64;
425
+ if (data == null || data.isEmpty) return;
426
+
427
+ try {
428
+ final bytes = base64Decode(data.contains(',') ? data.split(',').last : data);
429
+ final mime = message.mimeType ?? 'application/octet-stream';
430
+ final ext = _mimeToExt(mime);
431
+ final dir = await getTemporaryDirectory();
432
+ final fileName = '${message.content.isNotEmpty ? message.content.replaceAll(RegExp(r'[^\w\s.-]'), '').trim() : 'file'}.$ext';
433
+ final file = File('${dir.path}/$fileName');
434
+ await file.writeAsBytes(bytes);
435
+
436
+ // Use share sheet — works for PDFs, Office docs, and all file types on iOS
437
+ await SharePlus.instance.share(
438
+ ShareParams(files: [XFile(file.path, mimeType: mime)]),
439
+ );
440
+ } catch (e) {
441
+ if (context.mounted) {
442
+ ScaffoldMessenger.of(context).showSnackBar(
443
+ SnackBar(content: Text('Could not open file: $e')),
444
+ );
445
+ }
446
+ }
447
+ }
448
+
449
+ String _mimeToExt(String mime) {
450
+ const map = {
451
+ 'application/pdf': 'pdf',
452
+ 'application/msword': 'doc',
453
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
454
+ 'application/vnd.ms-excel': 'xls',
455
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx',
456
+ 'text/plain': 'txt',
457
+ 'text/csv': 'csv',
458
+ 'application/json': 'json',
459
+ };
460
+ return map[mime] ?? 'bin';
461
+ }
462
+
325463 Widget _buildFooter(BuildContext context) {
326464 final isDark = Theme.of(context).brightness == Brightness.dark;
327465 final dt = DateTime.fromMillisecondsSinceEpoch(message.timestamp);