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
| .. | .. |
|---|
| 13 | 13 | final String content; |
|---|
| 14 | 14 | final String? audioUri; |
|---|
| 15 | 15 | final String? imageBase64; |
|---|
| 16 | + final String? mimeType; |
|---|
| 16 | 17 | final int timestamp; |
|---|
| 17 | 18 | final MessageStatus? status; |
|---|
| 18 | 19 | final int? duration; |
|---|
| .. | .. |
|---|
| 25 | 26 | required this.timestamp, |
|---|
| 26 | 27 | this.audioUri, |
|---|
| 27 | 28 | this.imageBase64, |
|---|
| 29 | + this.mimeType, |
|---|
| 28 | 30 | this.status, |
|---|
| 29 | 31 | this.duration, |
|---|
| 30 | 32 | }); |
|---|
| .. | .. |
|---|
| 67 | 69 | required MessageRole role, |
|---|
| 68 | 70 | required String imageBase64, |
|---|
| 69 | 71 | String content = '', |
|---|
| 72 | + String? mimeType, |
|---|
| 70 | 73 | MessageStatus? status, |
|---|
| 71 | 74 | }) { |
|---|
| 72 | 75 | return Message( |
|---|
| .. | .. |
|---|
| 75 | 78 | type: MessageType.image, |
|---|
| 76 | 79 | content: content, |
|---|
| 77 | 80 | imageBase64: imageBase64, |
|---|
| 81 | + mimeType: mimeType, |
|---|
| 78 | 82 | timestamp: DateTime.now().millisecondsSinceEpoch, |
|---|
| 79 | 83 | status: status, |
|---|
| 80 | 84 | ); |
|---|
| .. | .. |
|---|
| 84 | 88 | String? content, |
|---|
| 85 | 89 | String? audioUri, |
|---|
| 86 | 90 | String? imageBase64, |
|---|
| 91 | + String? mimeType, |
|---|
| 87 | 92 | MessageStatus? status, |
|---|
| 88 | 93 | int? duration, |
|---|
| 89 | 94 | }) { |
|---|
| .. | .. |
|---|
| 94 | 99 | content: content ?? this.content, |
|---|
| 95 | 100 | audioUri: audioUri ?? this.audioUri, |
|---|
| 96 | 101 | imageBase64: imageBase64 ?? this.imageBase64, |
|---|
| 102 | + mimeType: mimeType ?? this.mimeType, |
|---|
| 97 | 103 | timestamp: timestamp, |
|---|
| 98 | 104 | status: status ?? this.status, |
|---|
| 99 | 105 | duration: duration ?? this.duration, |
|---|
| .. | .. |
|---|
| 108 | 114 | 'content': content, |
|---|
| 109 | 115 | if (audioUri != null) 'audioUri': audioUri, |
|---|
| 110 | 116 | if (imageBase64 != null) 'imageBase64': imageBase64, |
|---|
| 117 | + if (mimeType != null) 'mimeType': mimeType, |
|---|
| 111 | 118 | 'timestamp': timestamp, |
|---|
| 112 | 119 | if (status != null) 'status': status!.name, |
|---|
| 113 | 120 | if (duration != null) 'duration': duration, |
|---|
| .. | .. |
|---|
| 130 | 137 | if (duration != null) 'duration': duration, |
|---|
| 131 | 138 | // Keep imageBase64 — images are typically 50-200 KB and must survive restart. |
|---|
| 132 | 139 | if (imageBase64 != null) 'imageBase64': imageBase64, |
|---|
| 140 | + if (mimeType != null) 'mimeType': mimeType, |
|---|
| 133 | 141 | }; |
|---|
| 134 | 142 | } |
|---|
| 135 | 143 | |
|---|
| .. | .. |
|---|
| 141 | 149 | content: json['content'] as String? ?? '', |
|---|
| 142 | 150 | audioUri: json['audioUri'] as String?, |
|---|
| 143 | 151 | imageBase64: json['imageBase64'] as String?, |
|---|
| 152 | + mimeType: json['mimeType'] as String?, |
|---|
| 144 | 153 | timestamp: json['timestamp'] as int, |
|---|
| 145 | 154 | status: json['status'] != null |
|---|
| 146 | 155 | ? MessageStatus.values.byName(json['status'] as String) |
|---|
| .. | .. |
|---|
| 413 | 413 | role: MessageRole.assistant, |
|---|
| 414 | 414 | imageBase64: imageData, |
|---|
| 415 | 415 | content: content, |
|---|
| 416 | + mimeType: map['mimeType'] as String?, |
|---|
| 416 | 417 | status: MessageStatus.sent, |
|---|
| 417 | 418 | ); |
|---|
| 418 | 419 | } else { |
|---|
| .. | .. |
|---|
| 679 | 680 | _screenshotForChat = false; |
|---|
| 680 | 681 | } |
|---|
| 681 | 682 | |
|---|
| 683 | + final mimeType = msg['mimeType'] as String?; |
|---|
| 682 | 684 | final message = Message.image( |
|---|
| 683 | 685 | role: MessageRole.assistant, |
|---|
| 684 | 686 | imageBase64: imageData, |
|---|
| 685 | 687 | content: content, |
|---|
| 688 | + mimeType: mimeType, |
|---|
| 686 | 689 | status: MessageStatus.sent, |
|---|
| 687 | 690 | ); |
|---|
| 688 | 691 | |
|---|
| .. | .. |
|---|
| 68 | 68 | // (Per-session subscriptions removed — single pailot/out topic now) |
|---|
| 69 | 69 | static const int _maxSeenIds = 500; |
|---|
| 70 | 70 | |
|---|
| 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 | + |
|---|
| 71 | 78 | // Callbacks |
|---|
| 72 | 79 | void Function(ConnectionStatus status)? onStatusChanged; |
|---|
| 73 | 80 | void Function(String detail)? onStatusDetail; // "Probing local...", "Scanning network..." |
|---|
| .. | .. |
|---|
| 120 | 127 | } |
|---|
| 121 | 128 | |
|---|
| 122 | 129 | /// 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 { |
|---|
| 124 | 132 | _mqttLog('MQTT: fast reconnect to $host'); |
|---|
| 125 | 133 | final clientId = await _getClientId(); |
|---|
| 126 | 134 | if (await _tryConnect(host, clientId, timeout: 2000)) { |
|---|
| 127 | 135 | connectedHost = host; |
|---|
| 128 | | - return; |
|---|
| 136 | + return true; |
|---|
| 129 | 137 | } |
|---|
| 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; |
|---|
| 133 | 140 | } |
|---|
| 134 | 141 | |
|---|
| 135 | 142 | /// Connect to the MQTT broker. |
|---|
| .. | .. |
|---|
| 440 | 447 | ); |
|---|
| 441 | 448 | _mqttLog('MQTT: connect result=${result?.state}'); |
|---|
| 442 | 449 | 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; |
|---|
| 444 | 453 | return true; |
|---|
| 445 | 454 | } |
|---|
| 446 | 455 | _client = null; |
|---|
| .. | .. |
|---|
| 454 | 463 | |
|---|
| 455 | 464 | void _onConnected() { |
|---|
| 456 | 465 | _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 | + }); |
|---|
| 457 | 477 | _setStatus(ConnectionStatus.connected); |
|---|
| 458 | 478 | _subscribe(); |
|---|
| 459 | 479 | _listenMessages(); |
|---|
| .. | .. |
|---|
| 461 | 481 | } |
|---|
| 462 | 482 | |
|---|
| 463 | 483 | void _onDisconnected() { |
|---|
| 484 | + _stabilityTimer?.cancel(); |
|---|
| 464 | 485 | _updatesSub?.cancel(); |
|---|
| 465 | 486 | _updatesSub = null; |
|---|
| 466 | 487 | |
|---|
| .. | .. |
|---|
| 470 | 491 | } else { |
|---|
| 471 | 492 | _setStatus(ConnectionStatus.reconnecting); |
|---|
| 472 | 493 | onReconnecting?.call(); |
|---|
| 494 | + _scheduleReconnect(); |
|---|
| 473 | 495 | } |
|---|
| 474 | 496 | } |
|---|
| 475 | 497 | |
|---|
| 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 | + |
|---|
| 476 | 520 | void _onAutoReconnect() { |
|---|
| 521 | + // Unused — autoReconnect is disabled, but keep callback for safety |
|---|
| 477 | 522 | _setStatus(ConnectionStatus.reconnecting); |
|---|
| 478 | 523 | onReconnecting?.call(); |
|---|
| 479 | 524 | } |
|---|
| 480 | 525 | |
|---|
| 481 | 526 | void _onAutoReconnected() { |
|---|
| 527 | + // Unused — autoReconnect is disabled, but keep callback for safety |
|---|
| 528 | + _reconnectAttempt = 0; |
|---|
| 482 | 529 | _setStatus(ConnectionStatus.connected); |
|---|
| 483 | 530 | _subscribe(); |
|---|
| 484 | 531 | _listenMessages(); |
|---|
| .. | .. |
|---|
| 764 | 811 | /// Disconnect intentionally. |
|---|
| 765 | 812 | void disconnect() { |
|---|
| 766 | 813 | _intentionalClose = true; |
|---|
| 814 | + _reconnectTimer?.cancel(); |
|---|
| 815 | + _reconnectTimer = null; |
|---|
| 816 | + _stabilityTimer?.cancel(); |
|---|
| 817 | + _stabilityTimer = null; |
|---|
| 818 | + _reconnectAttempt = 0; |
|---|
| 767 | 819 | _updatesSub?.cancel(); |
|---|
| 768 | 820 | _updatesSub = null; |
|---|
| 769 | 821 | _connectivitySub?.cancel(); |
|---|
| .. | .. |
|---|
| 799 | 851 | case AppLifecycleState.resumed: |
|---|
| 800 | 852 | if (_intentionalClose) break; |
|---|
| 801 | 853 | _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. |
|---|
| 804 | 860 | onResume?.call(); |
|---|
| 805 | 861 | case AppLifecycleState.paused: |
|---|
| 806 | 862 | break; |
|---|
| .. | .. |
|---|
| 1 | 1 | import 'dart:convert'; |
|---|
| 2 | +import 'dart:io'; |
|---|
| 2 | 3 | import 'dart:math'; |
|---|
| 3 | 4 | import 'dart:typed_data'; |
|---|
| 4 | 5 | |
|---|
| 5 | 6 | import 'package:flutter/material.dart'; |
|---|
| 6 | 7 | import 'package:flutter/services.dart'; |
|---|
| 7 | 8 | import 'package:flutter_markdown/flutter_markdown.dart'; |
|---|
| 9 | +import 'package:path_provider/path_provider.dart'; |
|---|
| 10 | +import 'package:share_plus/share_plus.dart'; |
|---|
| 8 | 11 | import 'package:url_launcher/url_launcher.dart'; |
|---|
| 9 | 12 | import 'package:intl/intl.dart'; |
|---|
| 10 | 13 | |
|---|
| .. | .. |
|---|
| 263 | 266 | ); |
|---|
| 264 | 267 | } |
|---|
| 265 | 268 | |
|---|
| 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 | + |
|---|
| 266 | 275 | Widget _buildImageContent(BuildContext context) { |
|---|
| 267 | 276 | if (message.imageBase64 == null || message.imageBase64!.isEmpty) { |
|---|
| 268 | 277 | 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); |
|---|
| 269 | 283 | } |
|---|
| 270 | 284 | |
|---|
| 271 | 285 | // Cache decoded bytes to prevent flicker on rebuild; evict oldest if over 50 entries |
|---|
| .. | .. |
|---|
| 322 | 336 | ); |
|---|
| 323 | 337 | } |
|---|
| 324 | 338 | |
|---|
| 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 | + |
|---|
| 325 | 463 | Widget _buildFooter(BuildContext context) { |
|---|
| 326 | 464 | final isDark = Theme.of(context).brightness == Brightness.dark; |
|---|
| 327 | 465 | final dt = DateTime.fromMillisecondsSinceEpoch(message.timestamp); |
|---|