25119a9b148a291ba0af4f9f70801d12f2309147..28fe0bf975a47e009232b56dd10b6944cde70064
2026-03-22 Matthias Nott
fix: MQTT connect timeout wrapper, host list logging
28fe0b diff | tree
2026-03-22 Matthias Nott
fix: MQTT connect - disable autoReconnect during trial, retry on all-fail
b99c59 diff | tree
2026-03-22 Matthias Nott
fix: play/pause toggle - await stop before setting playing ID
15e79e diff | tree
2026-03-22 Matthias Nott
fix: play/pause toggle, recording captures session at start
ff79b0 diff | tree
2026-03-22 Matthias Nott
fix: voice transcript display, audio file persistence, debug logging
fee149 diff | tree
2026-03-22 Matthias Nott
fix: await cross-session store before toast for voice and text
45c33f diff | tree
2026-03-22 Matthias Nott
fix: save voice audio to file for cross-session persistence
c9ced2 diff | tree
2026-03-22 Matthias Nott
fix: await cross-session message storage before switch
16ffd8 diff | tree
2026-03-22 Matthias Nott
fix: suppress voice autoplay while recording
8a82e8 diff | tree
2026-03-22 Matthias Nott
fix: include messageId in MQTT voice payload for transcript reflection
400532 diff | tree
2026-03-22 Matthias Nott
fix: keyboard dismissal with HitTestBehavior.translucent
a6f42d diff | tree
2026-03-22 Matthias Nott
fix: pass sessionId with screenshot command
ec9256 diff | tree
2026-03-22 Matthias Nott
fix: set MQTT client before connect, debug logging for message flow
cb8020 diff | tree
2026-03-22 Matthias Nott
fix: MQTT port matches config, keyboard dismiss on drawer, debug logging
16893f diff | tree
2026-03-22 Matthias Nott
feat: MQTT client replaces WebSocket (Phase 2)
c4ce63 diff | tree
2026-03-22 Matthias Nott
docs: MQTT protocol migration plan as next major task
39c4ba diff | tree
2026-03-22 Matthias Nott
docs: add message send queue to high priority TODO
c69c4f diff | tree
2026-03-22 Matthias Nott
fix: smart catch_up merge - dedup by content, preserve user messages
6a336b diff | tree
2026-03-22 Matthias Nott
fix: disable catch_up replay to prevent message loss
1c57bb diff | tree
2026-03-22 Matthias Nott
fix: image flicker, screenshot indicator, cross-session message storage
69c37c diff | tree
2026-03-22 Matthias Nott
fix: seq-based dedup prevents catch_up from duplicating messages
798112 diff | tree
2026-03-22 Matthias Nott
fix: capture session ID before image picker to prevent mis-routing
619727 diff | tree
2026-03-22 Matthias Nott
fix: flush messages before session switch to prevent data loss
0b9d8a diff | tree
2026-03-22 Matthias Nott
fix: keyboard dismiss on tap outside, line breaks, session switch
ef7785 diff | tree
2 files added
9 files modified
changed files
TODO.md patch | view | blame | history
lib/models/message.dart patch | view | blame | history
lib/models/server_config.dart patch | view | blame | history
lib/providers/providers.dart patch | view | blame | history
lib/screens/chat_screen.dart patch | view | blame | history
lib/screens/settings_screen.dart patch | view | blame | history
lib/services/mqtt_service.dart patch | view | blame | history
lib/widgets/input_bar.dart patch | view | blame | history
lib/widgets/message_bubble.dart patch | view | blame | history
pubspec.lock patch | view | blame | history
pubspec.yaml patch | view | blame | history
TODO.md
....@@ -0,0 +1,71 @@
1
+# PAILot Flutter - TODO
2
+
3
+## High Priority
4
+
5
+### MQTT Protocol Migration — NEXT MAJOR TASK
6
+Replace ad-hoc WebSocket protocol with MQTT for reliable, ordered messaging.
7
+
8
+**Why:** Current protocol has no delivery guarantees, no message ordering, no offline queuing.
9
+Messages get lost on daemon restart, duplicated on catch_up, and arrive out of order.
10
+
11
+**Server (AIBroker):**
12
+- Embed MQTT broker (aedes) in daemon alongside existing WebSocket
13
+- Topics: `pailot/{sessionId}/out` (server→app), `pailot/{sessionId}/in` (app→server)
14
+- System topics: `pailot/sessions`, `pailot/status`, `pailot/typing/{sessionId}`
15
+- QoS 1 (at-least-once) for messages, QoS 0 for typing indicators
16
+- Retained messages for session list and last screenshot
17
+- Clean session=false so broker queues messages for offline clients
18
+- Bridge MQTT messages to/from existing AIBP routing
19
+
20
+**Flutter App:**
21
+- Replace WebSocket client with mqtt_client package
22
+- Subscribe to `pailot/+/out` for all session messages
23
+- Publish to `pailot/{sessionId}/in` for user messages
24
+- Message ID-based dedup (MQTT can deliver duplicates with QoS 1)
25
+- Ordered by broker — no client-side sorting needed
26
+- Offline messages delivered automatically on reconnect
27
+
28
+**Migration:**
29
+- Phase 1: Add MQTT alongside WebSocket, dual-publish
30
+- Phase 2: Flutter app switches to MQTT
31
+- Phase 3: Remove WebSocket from PAILot gateway
32
+
33
+## Pending Features
34
+
35
+### File Transfer (send/receive arbitrary files)
36
+- File picker in app (PDFs, Word docs, attachments, etc.)
37
+- New `file` message type in WebSocket protocol
38
+- Gateway handler to save received files and route to session
39
+- Session can send files back via `pailot_send_file`
40
+- Display file attachments in chat bubbles (icon + filename + tap to open)
41
+
42
+### Voice+Image Combined Message
43
+- When voice caption is recorded with images, hold images on server until voice transcript arrives
44
+- Deliver transcript + images together as one message to Claude
45
+- Ensures voice prefix sets reply channel correctly
46
+
47
+### Push Notifications (iOS APNs) — NEXT SESSION with user at computer
48
+- **Step 1**: User creates APNs key in Apple Developer Portal (needs login)
49
+- **Step 2**: Save `.p8` key to `~/.aibroker/apns-key.p8`, add env vars (key ID, team ID)
50
+- **Step 3**: Server-side APNs HTTP/2 sender (`src/daemon/apns.ts`) with JWT auth
51
+- **Step 4**: App sends device token on WebSocket connect
52
+- **Step 5**: Gateway buffers messages when no WS clients, sends push notification
53
+- **Step 6**: App receives push → user taps → opens app → catch_up drains messages
54
+- Optional: silent push to wake app briefly for critical messages
55
+
56
+### App Name Renaming (Runner → PAILot)
57
+- Rename Xcode target from Runner to PAILot (like Glidr did)
58
+- Update scheme names, bundle paths
59
+
60
+## Known Issues
61
+
62
+### Audio
63
+- Background audio may not survive full app termination (only screen lock)
64
+- Audio session category may conflict with phone calls
65
+
66
+### UI
67
+- Launch image still uses default Flutter placeholder
68
+- No app splash screen with PAILot branding
69
+
70
+### Navigation
71
+- vi keys (0, G, dd) are sent as literal text paste — works for Claude Code but may not for other terminals
lib/models/message.dart
....@@ -114,18 +114,21 @@
114114 };
115115 }
116116
117
- /// Lightweight JSON for persistence (strips temp audio paths, keeps images).
117
+ /// Lightweight JSON for persistence (strips base64 audio, keeps file paths and images).
118118 Map<String, dynamic> toJsonLight() {
119
+ // Keep audioUri if it's a file path (starts with '/') — these are saved audio files.
120
+ // Strip base64 audio data (large, temp) — those won't survive restart.
121
+ final keepAudio = audioUri != null && audioUri!.startsWith('/');
119122 return {
120123 'id': id,
121124 'role': role.name,
122125 'type': type.name,
123126 'content': content,
127
+ if (keepAudio) 'audioUri': audioUri,
124128 'timestamp': timestamp,
125129 if (status != null) 'status': status!.name,
126130 if (duration != null) 'duration': duration,
127131 // Keep imageBase64 — images are typically 50-200 KB and must survive restart.
128
- // audioUri is intentionally omitted: it is a temp file path that won't survive restart.
129132 if (imageBase64 != null) 'imageBase64': imageBase64,
130133 };
131134 }
lib/models/server_config.dart
....@@ -3,12 +3,14 @@
33 final int port;
44 final String? localHost;
55 final String? macAddress;
6
+ final String? mqttToken;
67
78 const ServerConfig({
89 required this.host,
910 this.port = 8765,
1011 this.localHost,
1112 this.macAddress,
13
+ this.mqttToken,
1214 });
1315
1416 /// Primary WebSocket URL (local network).
....@@ -34,6 +36,7 @@
3436 'port': port,
3537 if (localHost != null) 'localHost': localHost,
3638 if (macAddress != null) 'macAddress': macAddress,
39
+ if (mqttToken != null) 'mqttToken': mqttToken,
3740 };
3841 }
3942
....@@ -43,6 +46,7 @@
4346 port: json['port'] as int? ?? 8765,
4447 localHost: json['localHost'] as String?,
4548 macAddress: json['macAddress'] as String?,
49
+ mqttToken: json['mqttToken'] as String?,
4650 );
4751 }
4852
....@@ -51,12 +55,14 @@
5155 int? port,
5256 String? localHost,
5357 String? macAddress,
58
+ String? mqttToken,
5459 }) {
5560 return ServerConfig(
5661 host: host ?? this.host,
5762 port: port ?? this.port,
5863 localHost: localHost ?? this.localHost,
5964 macAddress: macAddress ?? this.macAddress,
65
+ mqttToken: mqttToken ?? this.mqttToken,
6066 );
6167 }
6268 }
lib/providers/providers.dart
....@@ -8,7 +8,7 @@
88 import '../models/server_config.dart';
99 import '../models/session.dart';
1010 import '../services/message_store.dart';
11
-import '../services/websocket_service.dart';
11
+import '../services/websocket_service.dart' show ConnectionStatus;
1212
1313 // --- Enums ---
1414
....@@ -92,9 +92,10 @@
9292
9393 /// Switch to a new session and load its messages.
9494 Future<void> switchSession(String sessionId) async {
95
- // Save current session before switching
95
+ // Force-flush current session to disk before switching
9696 if (_currentSessionId != null && state.isNotEmpty) {
9797 MessageStore.save(_currentSessionId!, state);
98
+ await MessageStore.flush();
9899 }
99100
100101 _currentSessionId = sessionId;
....@@ -196,9 +197,5 @@
196197
197198 final inputModeProvider = StateProvider<InputMode>((ref) => InputMode.voice);
198199
199
-// --- WebSocket Service (singleton) ---
200
-
201
-final webSocketServiceProvider = Provider<WebSocketService?>((ref) {
202
- // This is managed manually in the chat screen
203
- return null;
204
-});
200
+// --- MQTT Service (singleton) ---
201
+// The MqttService is managed manually in the chat screen.
lib/screens/chat_screen.dart
....@@ -1,6 +1,8 @@
11 import 'dart:convert';
22 import 'dart:io';
33
4
+import 'package:path_provider/path_provider.dart';
5
+
46 import 'package:flutter/material.dart';
57 import 'package:flutter_riverpod/flutter_riverpod.dart';
68 import 'package:go_router/go_router.dart';
....@@ -13,7 +15,8 @@
1315 import '../models/server_config.dart';
1416 import '../providers/providers.dart';
1517 import '../services/audio_service.dart';
16
-import '../services/websocket_service.dart';
18
+import '../services/message_store.dart';
19
+import '../services/mqtt_service.dart';
1720 import '../theme/app_theme.dart';
1821 import '../widgets/command_bar.dart';
1922 import '../widgets/input_bar.dart';
....@@ -31,9 +34,18 @@
3134 ConsumerState<ChatScreen> createState() => _ChatScreenState();
3235 }
3336
37
+Future<void> _chatLog(String msg) async {
38
+ try {
39
+ final dir = await getApplicationDocumentsDirectory();
40
+ final file = File('${dir.path}/mqtt_debug.log');
41
+ final ts = DateTime.now().toIso8601String().substring(11, 19);
42
+ await file.writeAsString('[$ts] $msg\n', mode: FileMode.append);
43
+ } catch (_) {}
44
+}
45
+
3446 class _ChatScreenState extends ConsumerState<ChatScreen>
3547 with WidgetsBindingObserver {
36
- WebSocketService? _ws;
48
+ MqttService? _ws;
3749 final TextEditingController _textController = TextEditingController();
3850 final ScrollController _scrollController = ScrollController();
3951 final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
....@@ -42,6 +54,7 @@
4254 int _lastSeq = 0;
4355 bool _isCatchingUp = false;
4456 bool _screenshotForChat = false;
57
+ final Set<int> _seenSeqs = {};
4558
4659 @override
4760 void initState() {
....@@ -58,11 +71,12 @@
5871 if (!mounted) return;
5972
6073 // Listen for playback state changes to reset play button UI
74
+ // Use a brief delay to avoid race between queue transitions
6175 AudioService.onPlaybackStateChanged = () {
62
- if (mounted) {
63
- setState(() {
64
- if (!AudioService.isPlaying) {
65
- _playingMessageId = null;
76
+ if (mounted && !AudioService.isPlaying) {
77
+ Future.delayed(const Duration(milliseconds: 100), () {
78
+ if (mounted && !AudioService.isPlaying) {
79
+ setState(() => _playingMessageId = null);
6680 }
6781 });
6882 }
....@@ -71,10 +85,11 @@
7185 _initConnection();
7286 }
7387
74
- void _saveLastSeq() {
75
- SharedPreferences.getInstance().then((prefs) {
76
- prefs.setInt('lastSeq', _lastSeq);
77
- });
88
+ SharedPreferences? _prefs;
89
+
90
+ Future<void> _saveLastSeq() async {
91
+ _prefs ??= await SharedPreferences.getInstance();
92
+ await _prefs!.setInt('lastSeq', _lastSeq);
7893 }
7994
8095 @override
....@@ -122,7 +137,7 @@
122137 if (config == null) return;
123138 }
124139
125
- _ws = WebSocketService(config: config);
140
+ _ws = MqttService(config: config);
126141 _ws!.onStatusChanged = (status) {
127142 if (mounted) {
128143 ref.read(wsStatusProvider.notifier).state = status;
....@@ -132,10 +147,11 @@
132147 _ws!.onOpen = () {
133148 final activeId = ref.read(activeSessionIdProvider);
134149 _sendCommand('sync', activeId != null ? {'activeSessionId': activeId} : null);
150
+ // catch_up is still available during the transition period
135151 _sendCommand('catch_up', {'lastSeq': _lastSeq});
136152 };
137153 _ws!.onError = (error) {
138
- debugPrint('WS error: $error');
154
+ debugPrint('MQTT error: $error');
139155 };
140156
141157 NavigateNotifier.instance = NavigateNotifier(
....@@ -143,7 +159,7 @@
143159 _sendCommand('nav', {'key': key});
144160 },
145161 requestScreenshot: (sessionId) {
146
- _sendCommand('screenshot');
162
+ _sendCommand('screenshot', {'sessionId': sessionId ?? ref.read(activeSessionIdProvider)});
147163 },
148164 );
149165
....@@ -153,9 +169,19 @@
153169 void _handleMessage(Map<String, dynamic> msg) {
154170 // Track sequence numbers for catch_up protocol
155171 final seq = msg['seq'] as int?;
156
- if (seq != null && seq > _lastSeq) {
157
- _lastSeq = seq;
158
- _saveLastSeq();
172
+ if (seq != null) {
173
+ // Dedup: skip messages we've already processed
174
+ if (_seenSeqs.contains(seq)) return;
175
+ _seenSeqs.add(seq);
176
+ // Keep set bounded
177
+ if (_seenSeqs.length > 500) {
178
+ final sorted = _seenSeqs.toList()..sort();
179
+ _seenSeqs.removeAll(sorted.sublist(0, sorted.length - 300));
180
+ }
181
+ if (seq > _lastSeq) {
182
+ _lastSeq = seq;
183
+ _saveLastSeq();
184
+ }
159185 }
160186
161187 final type = msg['type'] as String?;
....@@ -199,11 +225,23 @@
199225 _lastSeq = serverSeq;
200226 _saveLastSeq();
201227 }
202
- final messages = msg['messages'] as List<dynamic>?;
203
- if (messages != null && messages.isNotEmpty) {
228
+ // Merge catch_up messages: only add messages not already in local storage.
229
+ // We check by content match against existing messages to avoid duplicates
230
+ // while still picking up messages that arrived while the app was backgrounded.
231
+ final catchUpMsgs = msg['messages'] as List<dynamic>?;
232
+ if (catchUpMsgs != null && catchUpMsgs.isNotEmpty) {
204233 _isCatchingUp = true;
205
- for (final m in messages) {
206
- _handleMessage(m as Map<String, dynamic>);
234
+ final existing = ref.read(messagesProvider);
235
+ final existingContents = existing
236
+ .where((m) => m.role == MessageRole.assistant)
237
+ .map((m) => m.content)
238
+ .toSet();
239
+ for (final m in catchUpMsgs) {
240
+ final content = (m as Map<String, dynamic>)['content'] as String? ?? '';
241
+ // Skip if we already have this message locally
242
+ if (content.isNotEmpty && existingContents.contains(content)) continue;
243
+ _handleMessage(m);
244
+ if (content.isNotEmpty) existingContents.add(content);
207245 }
208246 _isCatchingUp = false;
209247 }
....@@ -239,7 +277,7 @@
239277 }
240278 }
241279
242
- void _handleIncomingMessage(Map<String, dynamic> msg) {
280
+ Future<void> _handleIncomingMessage(Map<String, dynamic> msg) async {
243281 final sessionId = msg['sessionId'] as String?;
244282 final content = msg['content'] as String? ??
245283 msg['text'] as String? ??
....@@ -253,6 +291,8 @@
253291
254292 final activeId = ref.read(activeSessionIdProvider);
255293 if (sessionId != null && sessionId != activeId) {
294
+ // Store message for the other session so it's there when user switches
295
+ await _storeForSession(sessionId, message);
256296 _incrementUnread(sessionId);
257297 final sessions = ref.read(sessionsProvider);
258298 final session = sessions.firstWhere(
....@@ -274,10 +314,10 @@
274314 }
275315 }
276316
277
- void _handleIncomingVoice(Map<String, dynamic> msg) {
317
+ Future<void> _handleIncomingVoice(Map<String, dynamic> msg) async {
278318 final sessionId = msg['sessionId'] as String?;
279319 final audioData = msg['audioBase64'] as String? ?? msg['audio'] as String? ?? msg['data'] as String?;
280
- final content = msg['content'] as String? ?? msg['text'] as String? ?? '';
320
+ final content = msg['content'] as String? ?? msg['transcript'] as String? ?? msg['text'] as String? ?? '';
281321 final duration = msg['duration'] as int?;
282322
283323 final message = Message(
....@@ -291,8 +331,36 @@
291331 duration: duration,
292332 );
293333
334
+ // Save audio to file so it survives persistence (base64 gets stripped)
335
+ String? savedAudioPath;
336
+ if (audioData != null) {
337
+ try {
338
+ final dir = await getTemporaryDirectory();
339
+ savedAudioPath = '${dir.path}/voice_${message.id}.m4a';
340
+ final bytes = base64Decode(audioData.contains(',') ? audioData.split(',').last : audioData);
341
+ await File(savedAudioPath).writeAsBytes(bytes);
342
+ } catch (_) {
343
+ savedAudioPath = null;
344
+ }
345
+ }
346
+
347
+ final storedMessage = Message(
348
+ id: message.id,
349
+ role: message.role,
350
+ type: message.type,
351
+ content: content,
352
+ audioUri: savedAudioPath ?? audioData,
353
+ timestamp: message.timestamp,
354
+ status: message.status,
355
+ duration: duration,
356
+ );
357
+
294358 final activeId = ref.read(activeSessionIdProvider);
359
+ _chatLog('voice: sessionId=$sessionId activeId=$activeId audioPath=$savedAudioPath content="${content.substring(0, content.length.clamp(0, 30))}"');
295360 if (sessionId != null && sessionId != activeId) {
361
+ _chatLog('voice: cross-session, storing for $sessionId');
362
+ await _storeForSession(sessionId, storedMessage);
363
+ _chatLog('voice: stored, incrementing unread');
296364 _incrementUnread(sessionId);
297365 final sessions = ref.read(sessionsProvider);
298366 final session = sessions.firstWhere(
....@@ -310,12 +378,12 @@
310378 return;
311379 }
312380
313
- ref.read(messagesProvider.notifier).addMessage(message);
381
+ ref.read(messagesProvider.notifier).addMessage(storedMessage);
314382 ref.read(isTypingProvider.notifier).state = false;
315383 _scrollToBottom();
316384
317
- if (audioData != null && !AudioService.isBackgrounded && !_isCatchingUp) {
318
- // Queue incoming voice chunks — don't cancel what's already playing
385
+ if (audioData != null && !AudioService.isBackgrounded && !_isCatchingUp && !_isRecording) {
386
+ setState(() => _playingMessageId = storedMessage.id);
319387 AudioService.queueBase64(audioData);
320388 }
321389 }
....@@ -357,6 +425,17 @@
357425 _scrollToBottom();
358426 }
359427
428
+ /// Store a message for a non-active session so it persists when the user switches to it.
429
+ Future<void> _storeForSession(String sessionId, Message message) async {
430
+ final existing = await MessageStore.loadAll(sessionId);
431
+ _chatLog('storeForSession: $sessionId existing=${existing.length} adding type=${message.type.name} content="${message.content.substring(0, message.content.length.clamp(0, 30))}" audioUri=${message.audioUri != null ? "set(${message.audioUri!.length})" : "null"}');
432
+ MessageStore.save(sessionId, [...existing, message]);
433
+ await MessageStore.flush();
434
+ // Verify
435
+ final verify = await MessageStore.loadAll(sessionId);
436
+ _chatLog('storeForSession: verified ${verify.length} messages after save');
437
+ }
438
+
360439 void _incrementUnread(String sessionId) {
361440 final counts = Map<String, int>.from(ref.read(unreadCountsProvider));
362441 counts[sessionId] = (counts[sessionId] ?? 0) + 1;
....@@ -364,9 +443,10 @@
364443 }
365444
366445 Future<void> _switchSession(String sessionId) async {
367
- // Stop any playing audio when switching sessions
446
+ // Stop any playing audio and dismiss keyboard when switching sessions
368447 await AudioService.stopPlayback();
369448 setState(() => _playingMessageId = null);
449
+ if (mounted) FocusScope.of(context).unfocus();
370450
371451 ref.read(activeSessionIdProvider.notifier).state = sessionId;
372452 await ref.read(messagesProvider.notifier).switchSession(sessionId);
....@@ -391,6 +471,7 @@
391471
392472 ref.read(messagesProvider.notifier).addMessage(message);
393473 _textController.clear();
474
+ FocusScope.of(context).unfocus(); // dismiss keyboard
394475
395476 // Send as plain text (not command) — gateway handles plain messages
396477 _ws?.send({
....@@ -401,14 +482,26 @@
401482 _scrollToBottom();
402483 }
403484
485
+ String? _recordingSessionId; // Capture session at recording start
486
+
404487 Future<void> _startRecording() async {
488
+ // Stop any playing audio before recording
489
+ if (AudioService.isPlaying) {
490
+ await AudioService.stopPlayback();
491
+ setState(() => _playingMessageId = null);
492
+ }
493
+ _recordingSessionId = ref.read(activeSessionIdProvider);
405494 final path = await AudioService.startRecording();
406495 if (path != null) {
407496 setState(() => _isRecording = true);
497
+ } else {
498
+ _recordingSessionId = null;
408499 }
409500 }
410501
411502 Future<void> _stopRecording() async {
503
+ final targetSession = _recordingSessionId;
504
+ _recordingSessionId = null;
412505 final path = await AudioService.stopRecording();
413506 setState(() => _isRecording = false);
414507
....@@ -433,7 +526,7 @@
433526 'audioBase64': b64,
434527 'content': '',
435528 'messageId': message.id,
436
- 'sessionId': ref.read(activeSessionIdProvider),
529
+ 'sessionId': targetSession,
437530 });
438531
439532 _scrollToBottom();
....@@ -468,16 +561,21 @@
468561 }
469562 }
470563
471
- void _playMessage(Message message) {
564
+ void _playMessage(Message message) async {
472565 if (message.audioUri == null) return;
473566
474567 // Toggle: if this message is already playing, stop it
475
- if (_playingMessageId == message.id && AudioService.isPlaying) {
476
- AudioService.stopPlayback();
568
+ if (_playingMessageId == message.id) {
569
+ await AudioService.stopPlayback();
477570 setState(() => _playingMessageId = null);
478571 return;
479572 }
480573
574
+ // Stop any current playback first, then set playing ID AFTER stop completes
575
+ // (stopPlayback triggers onPlaybackStateChanged which clears _playingMessageId)
576
+ await AudioService.stopPlayback();
577
+
578
+ if (!mounted) return;
481579 setState(() => _playingMessageId = message.id);
482580
483581 if (message.audioUri!.startsWith('/')) {
....@@ -511,7 +609,16 @@
511609
512610 void _requestScreenshot() {
513611 _screenshotForChat = true;
514
- _sendCommand('screenshot');
612
+ _sendCommand('screenshot', {'sessionId': ref.read(activeSessionIdProvider)});
613
+ if (mounted) {
614
+ ScaffoldMessenger.of(context).showSnackBar(
615
+ const SnackBar(
616
+ content: Text('Capturing screenshot...'),
617
+ duration: Duration(seconds: 2),
618
+ behavior: SnackBarBehavior.floating,
619
+ ),
620
+ );
621
+ }
515622 }
516623
517624 void _navigateToTerminal() {
....@@ -520,6 +627,9 @@
520627 }
521628
522629 Future<void> _pickPhoto() async {
630
+ // Capture session ID now — before any async gaps (dialog, encoding)
631
+ final targetSessionId = ref.read(activeSessionIdProvider);
632
+
523633 final picker = ImagePicker();
524634 final images = await picker.pickMultiImage(
525635 maxWidth: 1920,
....@@ -566,7 +676,7 @@
566676 'audioBase64': voiceB64,
567677 'content': '',
568678 'messageId': voiceMsg.id,
569
- 'sessionId': ref.read(activeSessionIdProvider),
679
+ 'sessionId': targetSessionId,
570680 });
571681 }
572682
....@@ -580,7 +690,7 @@
580690 'imageBase64': encodedImages[i],
581691 'mimeType': 'image/jpeg',
582692 'caption': msgCaption,
583
- 'sessionId': ref.read(activeSessionIdProvider),
693
+ 'sessionId': targetSessionId,
584694 });
585695 }
586696
....@@ -822,12 +932,18 @@
822932 final unreadCounts = ref.watch(unreadCountsProvider);
823933 final inputMode = ref.watch(inputModeProvider);
824934
825
- return Scaffold(
935
+ return GestureDetector(
936
+ behavior: HitTestBehavior.translucent,
937
+ onTap: () => FocusScope.of(context).unfocus(),
938
+ child: Scaffold(
826939 key: _scaffoldKey,
827940 appBar: AppBar(
828941 leading: IconButton(
829942 icon: const Icon(Icons.menu),
830
- onPressed: () => _scaffoldKey.currentState?.openDrawer(),
943
+ onPressed: () {
944
+ FocusScope.of(context).unfocus();
945
+ _scaffoldKey.currentState?.openDrawer();
946
+ },
831947 ),
832948 title: Text(
833949 activeSession?.name ?? 'PAILot',
....@@ -927,6 +1043,7 @@
9271043 ),
9281044 ],
9291045 ),
1046
+ ),
9301047 );
9311048 }
9321049 }
lib/screens/settings_screen.dart
....@@ -3,7 +3,7 @@
33
44 import '../models/server_config.dart';
55 import '../providers/providers.dart';
6
-import '../services/websocket_service.dart';
6
+import '../services/websocket_service.dart' show ConnectionStatus;
77 import '../services/wol_service.dart';
88 import '../theme/app_theme.dart';
99 import '../widgets/status_dot.dart';
....@@ -21,6 +21,7 @@
2121 late final TextEditingController _remoteHostController;
2222 late final TextEditingController _portController;
2323 late final TextEditingController _macController;
24
+ late final TextEditingController _mqttTokenController;
2425 bool _isWaking = false;
2526
2627 @override
....@@ -35,6 +36,8 @@
3536 TextEditingController(text: '${config?.port ?? 8765}');
3637 _macController =
3738 TextEditingController(text: config?.macAddress ?? '');
39
+ _mqttTokenController =
40
+ TextEditingController(text: config?.mqttToken ?? '');
3841 }
3942
4043 @override
....@@ -43,6 +46,7 @@
4346 _remoteHostController.dispose();
4447 _portController.dispose();
4548 _macController.dispose();
49
+ _mqttTokenController.dispose();
4650 super.dispose();
4751 }
4852
....@@ -58,6 +62,9 @@
5862 macAddress: _macController.text.trim().isEmpty
5963 ? null
6064 : _macController.text.trim(),
65
+ mqttToken: _mqttTokenController.text.trim().isEmpty
66
+ ? null
67
+ : _mqttTokenController.text.trim(),
6168 );
6269
6370 await ref.read(serverConfigProvider.notifier).save(config);
....@@ -183,6 +190,19 @@
183190 hintText: 'AA:BB:CC:DD:EE:FF',
184191 ),
185192 ),
193
+ const SizedBox(height: 16),
194
+
195
+ // MQTT Token
196
+ Text('MQTT Token',
197
+ style: Theme.of(context).textTheme.bodyMedium),
198
+ const SizedBox(height: 4),
199
+ TextFormField(
200
+ controller: _mqttTokenController,
201
+ decoration: const InputDecoration(
202
+ hintText: 'Shared secret for MQTT auth',
203
+ ),
204
+ obscureText: true,
205
+ ),
186206 const SizedBox(height: 24),
187207
188208 // Save button
lib/services/mqtt_service.dart
....@@ -0,0 +1,495 @@
1
+import 'dart:async';
2
+import 'dart:convert';
3
+import 'dart:io';
4
+
5
+import 'package:flutter/widgets.dart';
6
+import 'package:path_provider/path_provider.dart' as pp;
7
+import 'package:mqtt_client/mqtt_client.dart';
8
+import 'package:mqtt_client/mqtt_server_client.dart';
9
+import 'package:shared_preferences/shared_preferences.dart';
10
+import 'package:uuid/uuid.dart';
11
+
12
+import '../models/server_config.dart';
13
+import 'websocket_service.dart' show ConnectionStatus;
14
+import 'wol_service.dart';
15
+
16
+// Debug log to file (survives release builds)
17
+Future<void> _mqttLog(String msg) async {
18
+ try {
19
+ final dir = await pp.getApplicationDocumentsDirectory();
20
+ final file = File('${dir.path}/mqtt_debug.log');
21
+ final ts = DateTime.now().toIso8601String().substring(11, 19);
22
+ await file.writeAsString('[$ts] $msg\n', mode: FileMode.append);
23
+ } catch (_) {}
24
+}
25
+
26
+/// MQTT client for PAILot, replacing WebSocketService.
27
+///
28
+/// Connects to the AIBroker daemon's embedded aedes broker.
29
+/// Subscribes to all pailot/ topics and dispatches messages
30
+/// through the same callback interface as WebSocketService.
31
+class MqttService with WidgetsBindingObserver {
32
+ MqttService({required this.config});
33
+
34
+ ServerConfig config;
35
+ MqttServerClient? _client;
36
+ ConnectionStatus _status = ConnectionStatus.disconnected;
37
+ bool _intentionalClose = false;
38
+ String? _clientId;
39
+ StreamSubscription? _updatesSub;
40
+
41
+ // Message deduplication
42
+ final Set<String> _seenMsgIds = {};
43
+ final List<String> _seenMsgIdOrder = [];
44
+ static const int _maxSeenIds = 500;
45
+
46
+ // Callbacks — same interface as WebSocketService
47
+ void Function(ConnectionStatus status)? onStatusChanged;
48
+ void Function(Map<String, dynamic> message)? onMessage;
49
+ void Function()? onOpen;
50
+ void Function()? onClose;
51
+ void Function()? onReconnecting;
52
+ void Function(String error)? onError;
53
+
54
+ ConnectionStatus get status => _status;
55
+ bool get isConnected => _status == ConnectionStatus.connected;
56
+
57
+ void _setStatus(ConnectionStatus newStatus) {
58
+ if (_status == newStatus) return;
59
+ _status = newStatus;
60
+ onStatusChanged?.call(newStatus);
61
+ }
62
+
63
+ /// Get or create a persistent client ID for this device.
64
+ Future<String> _getClientId() async {
65
+ if (_clientId != null) return _clientId!;
66
+ final prefs = await SharedPreferences.getInstance();
67
+ var id = prefs.getString('mqtt_client_id');
68
+ // Regenerate if old format (too long for MQTT 3.1.1)
69
+ if (id == null || id.length > 23) {
70
+ // MQTT 3.1.1 client IDs: max 23 chars, alphanumeric
71
+ id = 'pailot${const Uuid().v4().replaceAll('-', '').substring(0, 16)}';
72
+ await prefs.setString('mqtt_client_id', id);
73
+ }
74
+ _clientId = id;
75
+ return id;
76
+ }
77
+
78
+ /// Connect to the MQTT broker.
79
+ /// Tries local host first (2.5s timeout), then remote host.
80
+ Future<void> connect() async {
81
+ if (_status == ConnectionStatus.connected ||
82
+ _status == ConnectionStatus.connecting) {
83
+ return;
84
+ }
85
+
86
+ _intentionalClose = false;
87
+ _setStatus(ConnectionStatus.connecting);
88
+
89
+ // Send Wake-on-LAN if MAC configured
90
+ if (config.macAddress != null && config.macAddress!.isNotEmpty) {
91
+ try {
92
+ await WolService.wake(config.macAddress!, localHost: config.localHost);
93
+ } catch (_) {}
94
+ }
95
+
96
+ final clientId = await _getClientId();
97
+ final hosts = _getHosts();
98
+ _mqttLog('MQTT: hosts=${hosts.join(", ")} port=${config.port}');
99
+
100
+ for (final host in hosts) {
101
+ if (_intentionalClose) return;
102
+
103
+ _mqttLog('MQTT: trying $host:${config.port}');
104
+ try {
105
+ final connected = await _tryConnect(
106
+ host,
107
+ clientId,
108
+ timeout: host == hosts.first && hosts.length > 1 ? 2500 : 5000,
109
+ );
110
+ _mqttLog('MQTT: $host result=$connected');
111
+ if (connected) return;
112
+ } catch (e) {
113
+ _mqttLog('MQTT: $host error=$e');
114
+ continue;
115
+ }
116
+ }
117
+
118
+ // All hosts failed — retry after delay
119
+ _mqttLog('MQTT: all hosts failed, retrying in 5s');
120
+ _setStatus(ConnectionStatus.reconnecting);
121
+ Future.delayed(const Duration(seconds: 5), () {
122
+ if (!_intentionalClose && _status != ConnectionStatus.connected) {
123
+ connect();
124
+ }
125
+ });
126
+ }
127
+
128
+ /// Returns [localHost, remoteHost] for dual-connect attempts.
129
+ List<String> _getHosts() {
130
+ if (config.localHost != null &&
131
+ config.localHost!.isNotEmpty &&
132
+ config.localHost != config.host) {
133
+ return [config.localHost!, config.host];
134
+ }
135
+ return [config.host];
136
+ }
137
+
138
+ Future<bool> _tryConnect(String host, String clientId, {int timeout = 5000}) async {
139
+ try {
140
+ final client = MqttServerClient.withPort(host, clientId, config.port);
141
+ client.keepAlivePeriod = 30;
142
+ client.autoReconnect = false; // Don't auto-reconnect during trial — enable after success
143
+ client.connectTimeoutPeriod = timeout;
144
+ client.logging(on: true);
145
+
146
+ client.onConnected = _onConnected;
147
+ client.onDisconnected = _onDisconnected;
148
+ client.onAutoReconnect = _onAutoReconnect;
149
+ client.onAutoReconnected = _onAutoReconnected;
150
+
151
+ // Persistent session (cleanSession = false) for offline message queuing
152
+ final connMessage = MqttConnectMessage()
153
+ .withClientIdentifier(clientId)
154
+ .authenticateAs('pailot', config.mqttToken ?? '')
155
+ .startClean(); // Use clean session for now; persistent sessions require broker support
156
+
157
+ // For persistent sessions, replace startClean() with:
158
+ // .withWillQos(MqttQos.atLeastOnce);
159
+ // and remove startClean()
160
+
161
+ client.connectionMessage = connMessage;
162
+
163
+ // Set _client BEFORE connect() so _onConnected can subscribe
164
+ _client = client;
165
+
166
+ _mqttLog('MQTT: connecting to $host:${config.port} as $clientId (timeout=${timeout}ms)');
167
+ final result = await client.connect().timeout(
168
+ Duration(milliseconds: timeout + 1000),
169
+ onTimeout: () {
170
+ _mqttLog('MQTT: connect timed out for $host');
171
+ return null;
172
+ },
173
+ );
174
+ _mqttLog('MQTT: connect result=${result?.state}');
175
+ if (result?.state == MqttConnectionState.connected) {
176
+ client.autoReconnect = true; // Now enable auto-reconnect for the live connection
177
+ return true;
178
+ }
179
+ _client = null;
180
+ client.disconnect();
181
+ return false;
182
+ } catch (e) {
183
+ _mqttLog('MQTT: connect exception=$e');
184
+ return false;
185
+ }
186
+ }
187
+
188
+ void _onConnected() {
189
+ _mqttLog('MQTT: _onConnected fired');
190
+ _setStatus(ConnectionStatus.connected);
191
+ _subscribe();
192
+ _listenMessages();
193
+ onOpen?.call();
194
+ }
195
+
196
+ void _onDisconnected() {
197
+ _updatesSub?.cancel();
198
+ _updatesSub = null;
199
+
200
+ if (_intentionalClose) {
201
+ _setStatus(ConnectionStatus.disconnected);
202
+ onClose?.call();
203
+ } else {
204
+ _setStatus(ConnectionStatus.reconnecting);
205
+ onReconnecting?.call();
206
+ }
207
+ }
208
+
209
+ void _onAutoReconnect() {
210
+ _setStatus(ConnectionStatus.reconnecting);
211
+ onReconnecting?.call();
212
+ }
213
+
214
+ void _onAutoReconnected() {
215
+ _setStatus(ConnectionStatus.connected);
216
+ _subscribe();
217
+ _listenMessages();
218
+ onOpen?.call();
219
+ }
220
+
221
+ void _subscribe() {
222
+ final client = _client;
223
+ if (client == null) {
224
+ _mqttLog('MQTT: _subscribe called but client is null');
225
+ return;
226
+ }
227
+ _mqttLog('MQTT: subscribing to topics...');
228
+ client.subscribe('pailot/sessions', MqttQos.atLeastOnce);
229
+ client.subscribe('pailot/status', MqttQos.atLeastOnce);
230
+ client.subscribe('pailot/projects', MqttQos.atLeastOnce);
231
+ client.subscribe('pailot/+/out', MqttQos.atLeastOnce);
232
+ client.subscribe('pailot/+/typing', MqttQos.atMostOnce);
233
+ client.subscribe('pailot/+/screenshot', MqttQos.atLeastOnce);
234
+ client.subscribe('pailot/control/out', MqttQos.atLeastOnce);
235
+ client.subscribe('pailot/voice/transcript', MqttQos.atLeastOnce);
236
+ }
237
+
238
+ void _listenMessages() {
239
+ _updatesSub?.cancel();
240
+ _updatesSub = _client?.updates?.listen(_onMqttMessage);
241
+ }
242
+
243
+ void _onMqttMessage(List<MqttReceivedMessage<MqttMessage>> messages) {
244
+ _mqttLog('MQTT: received ${messages.length} message(s)');
245
+ for (final msg in messages) {
246
+ _mqttLog('MQTT: topic=${msg.topic}');
247
+ final pubMsg = msg.payload as MqttPublishMessage;
248
+ final payload = MqttPublishPayload.bytesToStringAsString(
249
+ pubMsg.payload.message,
250
+ );
251
+
252
+ Map<String, dynamic> json;
253
+ try {
254
+ json = jsonDecode(payload) as Map<String, dynamic>;
255
+ } catch (_) {
256
+ continue; // Skip non-JSON
257
+ }
258
+
259
+ // Dedup by msgId
260
+ final msgId = json['msgId'] as String?;
261
+ if (msgId != null) {
262
+ if (_seenMsgIds.contains(msgId)) continue;
263
+ _seenMsgIds.add(msgId);
264
+ _seenMsgIdOrder.add(msgId);
265
+ _evictOldIds();
266
+ }
267
+
268
+ // Dispatch: parse topic to enrich the message with routing info
269
+ _dispatchMessage(msg.topic, json);
270
+ }
271
+ }
272
+
273
+ /// Route incoming MQTT messages to the onMessage callback.
274
+ /// Translates MQTT topic structure into the flat message format
275
+ /// that chat_screen expects (same as WebSocket messages).
276
+ void _dispatchMessage(String topic, Map<String, dynamic> json) {
277
+ final parts = topic.split('/');
278
+
279
+ // pailot/sessions
280
+ if (topic == 'pailot/sessions') {
281
+ json['type'] = 'sessions';
282
+ onMessage?.call(json);
283
+ return;
284
+ }
285
+
286
+ // pailot/status
287
+ if (topic == 'pailot/status') {
288
+ json['type'] = 'status';
289
+ onMessage?.call(json);
290
+ return;
291
+ }
292
+
293
+ // pailot/projects
294
+ if (topic == 'pailot/projects') {
295
+ json['type'] = 'projects';
296
+ onMessage?.call(json);
297
+ return;
298
+ }
299
+
300
+ // pailot/control/out — command responses (session_switched, session_renamed, error, unread)
301
+ if (topic == 'pailot/control/out') {
302
+ onMessage?.call(json);
303
+ return;
304
+ }
305
+
306
+ // pailot/voice/transcript
307
+ if (topic == 'pailot/voice/transcript') {
308
+ json['type'] = 'transcript';
309
+ onMessage?.call(json);
310
+ return;
311
+ }
312
+
313
+ // pailot/<sessionId>/out — text, voice, image messages
314
+ if (parts.length == 3 && parts[2] == 'out') {
315
+ final sessionId = parts[1];
316
+ json['sessionId'] ??= sessionId;
317
+ onMessage?.call(json);
318
+ return;
319
+ }
320
+
321
+ // pailot/<sessionId>/typing
322
+ if (parts.length == 3 && parts[2] == 'typing') {
323
+ final sessionId = parts[1];
324
+ json['type'] = 'typing';
325
+ json['sessionId'] ??= sessionId;
326
+ // Map 'active' field to the 'typing'/'isTyping' fields chat_screen expects
327
+ final active = json['active'] as bool? ?? true;
328
+ json['typing'] = active;
329
+ onMessage?.call(json);
330
+ return;
331
+ }
332
+
333
+ // pailot/<sessionId>/screenshot
334
+ if (parts.length == 3 && parts[2] == 'screenshot') {
335
+ final sessionId = parts[1];
336
+ json['type'] = 'screenshot';
337
+ json['sessionId'] ??= sessionId;
338
+ // Map imageBase64 to 'data' for compatibility with chat_screen handler
339
+ json['data'] ??= json['imageBase64'];
340
+ onMessage?.call(json);
341
+ return;
342
+ }
343
+ }
344
+
345
+ void _evictOldIds() {
346
+ while (_seenMsgIdOrder.length > _maxSeenIds) {
347
+ final oldest = _seenMsgIdOrder.removeAt(0);
348
+ _seenMsgIds.remove(oldest);
349
+ }
350
+ }
351
+
352
+ /// Generate a UUID v4 for message IDs.
353
+ String _uuid() => const Uuid().v4();
354
+
355
+ /// Current timestamp in milliseconds.
356
+ int _now() => DateTime.now().millisecondsSinceEpoch;
357
+
358
+ /// Publish a JSON payload to an MQTT topic.
359
+ void _publish(String topic, Map<String, dynamic> payload, MqttQos qos) {
360
+ final client = _client;
361
+ if (client == null || client.connectionStatus?.state != MqttConnectionState.connected) {
362
+ onError?.call('Not connected');
363
+ return;
364
+ }
365
+
366
+ try {
367
+ final builder = MqttClientPayloadBuilder();
368
+ builder.addString(jsonEncode(payload));
369
+ client.publishMessage(topic, qos, builder.payload!);
370
+ } catch (e) {
371
+ onError?.call('Send failed: $e');
372
+ }
373
+ }
374
+
375
+ /// Send a message — routes to the appropriate MQTT topic based on content.
376
+ /// Accepts the same message format as WebSocketService.send().
377
+ void send(Map<String, dynamic> message) {
378
+ final type = message['type'] as String?;
379
+ final sessionId = message['sessionId'] as String?;
380
+
381
+ if (type == 'command' || (message.containsKey('command') && type == null)) {
382
+ // Command messages go to pailot/control/in
383
+ final command = message['command'] as String? ?? '';
384
+ final args = message['args'] as Map<String, dynamic>? ?? {};
385
+ final payload = <String, dynamic>{
386
+ 'msgId': _uuid(),
387
+ 'type': 'command',
388
+ 'command': command,
389
+ 'ts': _now(),
390
+ ...args,
391
+ };
392
+ _publish('pailot/control/in', payload, MqttQos.atLeastOnce);
393
+ return;
394
+ }
395
+
396
+ if (type == 'voice' && sessionId != null) {
397
+ // Voice message
398
+ _publish('pailot/$sessionId/in', {
399
+ 'msgId': _uuid(),
400
+ 'type': 'voice',
401
+ 'sessionId': sessionId,
402
+ 'audioBase64': message['audioBase64'] ?? '',
403
+ 'messageId': message['messageId'] ?? '',
404
+ 'ts': _now(),
405
+ }, MqttQos.atLeastOnce);
406
+ return;
407
+ }
408
+
409
+ if (type == 'image' && sessionId != null) {
410
+ // Image message
411
+ _publish('pailot/$sessionId/in', {
412
+ 'msgId': _uuid(),
413
+ 'type': 'image',
414
+ 'sessionId': sessionId,
415
+ 'imageBase64': message['imageBase64'] ?? '',
416
+ 'mimeType': message['mimeType'] ?? 'image/jpeg',
417
+ 'caption': message['caption'] ?? '',
418
+ 'ts': _now(),
419
+ }, MqttQos.atLeastOnce);
420
+ return;
421
+ }
422
+
423
+ if (type == 'tts' && sessionId != null) {
424
+ // TTS request — route as command
425
+ _publish('pailot/control/in', {
426
+ 'msgId': _uuid(),
427
+ 'type': 'command',
428
+ 'command': 'tts',
429
+ 'text': message['text'] ?? '',
430
+ 'sessionId': sessionId,
431
+ 'ts': _now(),
432
+ }, MqttQos.atLeastOnce);
433
+ return;
434
+ }
435
+
436
+ // Default: plain text message (content + sessionId)
437
+ if (sessionId != null) {
438
+ final content = message['content'] as String? ?? '';
439
+ _publish('pailot/$sessionId/in', {
440
+ 'msgId': _uuid(),
441
+ 'type': 'text',
442
+ 'sessionId': sessionId,
443
+ 'content': content,
444
+ 'ts': _now(),
445
+ }, MqttQos.atLeastOnce);
446
+ return;
447
+ }
448
+
449
+ onError?.call('Cannot send message: missing sessionId');
450
+ }
451
+
452
+ /// Disconnect intentionally.
453
+ void disconnect() {
454
+ _intentionalClose = true;
455
+ _updatesSub?.cancel();
456
+ _updatesSub = null;
457
+
458
+ try {
459
+ _client?.disconnect();
460
+ } catch (_) {}
461
+ _client = null;
462
+
463
+ _setStatus(ConnectionStatus.disconnected);
464
+ onClose?.call();
465
+ }
466
+
467
+ /// Update config and reconnect.
468
+ Future<void> updateConfig(ServerConfig newConfig) async {
469
+ config = newConfig;
470
+ disconnect();
471
+ await Future.delayed(const Duration(milliseconds: 100));
472
+ await connect();
473
+ }
474
+
475
+ /// Dispose all resources.
476
+ void dispose() {
477
+ disconnect();
478
+ }
479
+
480
+ // App lifecycle integration
481
+ @override
482
+ void didChangeAppLifecycleState(AppLifecycleState state) {
483
+ switch (state) {
484
+ case AppLifecycleState.resumed:
485
+ if (_status != ConnectionStatus.connected && !_intentionalClose) {
486
+ connect();
487
+ }
488
+ case AppLifecycleState.paused:
489
+ // Keep connection alive — MQTT handles keepalive natively
490
+ break;
491
+ default:
492
+ break;
493
+ }
494
+ }
495
+}
lib/widgets/input_bar.dart
....@@ -111,8 +111,7 @@
111111 const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
112112 isDense: true,
113113 ),
114
- textInputAction: TextInputAction.send,
115
- onSubmitted: (_) => onSendText(),
114
+ textInputAction: TextInputAction.newline,
116115 maxLines: 4,
117116 minLines: 1,
118117 ),
lib/widgets/message_bubble.dart
....@@ -1,5 +1,6 @@
11 import 'dart:convert';
22 import 'dart:math';
3
+import 'dart:typed_data';
34
45 import 'package:flutter/material.dart';
56 import 'package:flutter/services.dart';
....@@ -8,6 +9,9 @@
89 import '../models/message.dart';
910 import '../theme/app_theme.dart';
1011 import 'image_viewer.dart';
12
+
13
+// Cache decoded image bytes to prevent flicker on widget rebuild
14
+final Map<String, Uint8List> _imageCache = {};
1115
1216 /// Chat message bubble with support for text, voice, and image types.
1317 class MessageBubble extends StatelessWidget {
....@@ -208,11 +212,13 @@
208212 return const Text('Image unavailable');
209213 }
210214
211
- final bytes = base64Decode(
212
- message.imageBase64!.contains(',')
213
- ? message.imageBase64!.split(',').last
214
- : message.imageBase64!,
215
- );
215
+ // Cache decoded bytes to prevent flicker on rebuild
216
+ final bytes = _imageCache.putIfAbsent(message.id, () {
217
+ final raw = message.imageBase64!;
218
+ return Uint8List.fromList(base64Decode(
219
+ raw.contains(',') ? raw.split(',').last : raw,
220
+ ));
221
+ });
216222
217223 return Column(
218224 crossAxisAlignment: CrossAxisAlignment.start,
....@@ -232,6 +238,7 @@
232238 width: 260,
233239 height: 180,
234240 fit: BoxFit.cover,
241
+ gaplessPlayback: true,
235242 errorBuilder: (_, e, st) => const SizedBox(
236243 width: 260,
237244 height: 60,
pubspec.lock
....@@ -161,6 +161,14 @@
161161 url: "https://pub.dev"
162162 source: hosted
163163 version: "7.0.3"
164
+ event_bus:
165
+ dependency: transitive
166
+ description:
167
+ name: event_bus
168
+ sha256: "1a55e97923769c286d295240048fc180e7b0768902c3c2e869fe059aafa15304"
169
+ url: "https://pub.dev"
170
+ source: hosted
171
+ version: "2.0.1"
164172 fake_async:
165173 dependency: transitive
166174 description:
....@@ -504,6 +512,14 @@
504512 url: "https://pub.dev"
505513 source: hosted
506514 version: "2.0.0"
515
+ mqtt_client:
516
+ dependency: "direct main"
517
+ description:
518
+ name: mqtt_client
519
+ sha256: fd22ea00a4c7b5623e01000a91a256d62a8bacba38e9812170458070c52affed
520
+ url: "https://pub.dev"
521
+ source: hosted
522
+ version: "10.11.9"
507523 native_toolchain_c:
508524 dependency: transitive
509525 description:
pubspec.yaml
....@@ -26,6 +26,7 @@
2626 share_plus: ^12.0.1
2727 udp: ^5.0.3
2828 intl: ^0.20.2
29
+ mqtt_client: ^10.6.0
2930 uuid: ^4.5.1
3031 collection: ^1.19.1
3132