4086924ede995e3f5e99652ff6e969116164217b..96c8bb5db1a2e0ced999a366e3cf28f9895ec39f
2026-03-24 Matthias Nott
feat: Bonjour auto-discovery + VPN IP field in connection flow
96c8bb diff | tree
2026-03-24 Matthias Nott
fix: stop + 150ms delay between queue tracks for iOS audio player reset
5de43f diff | tree
2026-03-24 Matthias Nott
fix: audio queue debug logging, 200ms playback state delay
d17b63 diff | tree
2026-03-24 Matthias Nott
fix: only set playingMessageId on first auto-played chunk to prevent race
a8aa30 diff | tree
2026-03-24 Matthias Nott
fix: forward voiceMessageId in bundle MQTT publish for transcript reflection
08f92e diff | tree
2026-03-24 Matthias Nott
fix: reflect voice transcript into image caption via voiceMessageId
66a18b diff | tree
2026-03-24 Matthias Nott
fix: combine voice+image into single bubble, no separate voice message
780b01 diff | tree
2026-03-24 Matthias Nott
feat: markdown rendering in assistant message bubbles
5b8750 diff | tree
2026-03-24 Matthias Nott
fix: send catch_up on app resume to fetch messages missed during background
62fc48 diff | tree
2026-03-24 Matthias Nott
fix: load messages for restored session on startup
981c65 diff | tree
2026-03-24 Matthias Nott
fix: clear typing on session switch, add typing debug log
b80f84 diff | tree
2026-03-24 Matthias Nott
fix: strict per-session typing - no null fallthrough, explicit match only
f80bd3 diff | tree
2026-03-24 Matthias Nott
fix: per-session typing indicator - all typing clears check sessionId
4c7990 diff | tree
2026-03-24 Matthias Nott
fix: update cached session order on reorder so server updates preserve it
a948bc diff | tree
2026-03-24 Matthias Nott
feat: persistent session drawer order via SharedPreferences
e76a5e diff | tree
2026-03-24 Matthias Nott
fix: dismiss keyboard after image/file picker, per-session typing indicator
71f951 diff | tree
2026-03-24 Matthias Nott
fix: don't reset session state on resume - prevents drawer flicker
04347a diff | tree
2026-03-24 Matthias Nott
fix: gentle resume - refresh sessions instead of force-disconnect
45898e diff | tree
2026-03-24 Matthias Nott
fix: clean force-reconnect on resume with intentionalClose flag
deec1d diff | tree
2026-03-24 Matthias Nott
fix: use ping health check on resume instead of force-disconnect
25e6fc diff | tree
2026-03-24 Matthias Nott
fix: force MQTT reconnect on app resume to avoid stale connections
547ee7 diff | tree
2026-03-24 Matthias Nott
fix: dismiss keyboard when session drawer opens
1cbdb0 diff | tree
2026-03-24 Matthias Nott
feat: MQTT migration, offline catch_up, clean session, image support
cb470d diff | tree
12 files modified
1 files deleted
changed files
ios/Runner/Info.plist 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/navigate_screen.dart patch | view | blame | history
lib/screens/settings_screen.dart patch | view | blame | history
lib/services/audio_service.dart patch | view | blame | history
lib/services/mqtt_service.dart patch | view | blame | history
lib/services/websocket_service.dart patch | view | blame | history
lib/widgets/message_bubble.dart patch | view | blame | history
lib/widgets/status_dot.dart patch | view | blame | history
pubspec.lock patch | view | blame | history
pubspec.yaml patch | view | blame | history
ios/Runner/Info.plist
....@@ -67,10 +67,11 @@
6767 <true/>
6868 </dict>
6969 <key>NSLocalNetworkUsageDescription</key>
70
- <string>PAILot connects to your local AI server</string>
70
+ <string>PAILot needs local network access to discover and connect to AIBroker</string>
7171 <key>NSBonjourServices</key>
7272 <array>
7373 <string>_http._tcp</string>
74
+ <string>_mqtt._tcp</string>
7475 </array>
7576 <key>UISupportedInterfaceOrientations</key>
7677 <array>
lib/models/server_config.dart
....@@ -2,6 +2,7 @@
22 final String host;
33 final int port;
44 final String? localHost;
5
+ final String? vpnHost;
56 final String? macAddress;
67 final String? mqttToken;
78
....@@ -9,32 +10,17 @@
910 required this.host,
1011 this.port = 8765,
1112 this.localHost,
13
+ this.vpnHost,
1214 this.macAddress,
1315 this.mqttToken,
1416 });
15
-
16
- /// Primary WebSocket URL (local network).
17
- String get localUrl {
18
- final h = localHost ?? host;
19
- return 'ws://$h:$port';
20
- }
21
-
22
- /// Fallback WebSocket URL (remote / public).
23
- String get remoteUrl => 'ws://$host:$port';
24
-
25
- /// Returns [localUrl, remoteUrl] for dual-connect attempts.
26
- List<String> get urls {
27
- if (localHost != null && localHost!.isNotEmpty && localHost != host) {
28
- return [localUrl, remoteUrl];
29
- }
30
- return [remoteUrl];
31
- }
3217
3318 Map<String, dynamic> toJson() {
3419 return {
3520 'host': host,
3621 'port': port,
3722 if (localHost != null) 'localHost': localHost,
23
+ if (vpnHost != null) 'vpnHost': vpnHost,
3824 if (macAddress != null) 'macAddress': macAddress,
3925 if (mqttToken != null) 'mqttToken': mqttToken,
4026 };
....@@ -45,6 +31,7 @@
4531 host: json['host'] as String? ?? '',
4632 port: json['port'] as int? ?? 8765,
4733 localHost: json['localHost'] as String?,
34
+ vpnHost: json['vpnHost'] as String?,
4835 macAddress: json['macAddress'] as String?,
4936 mqttToken: json['mqttToken'] as String?,
5037 );
....@@ -54,6 +41,7 @@
5441 String? host,
5542 int? port,
5643 String? localHost,
44
+ String? vpnHost,
5745 String? macAddress,
5846 String? mqttToken,
5947 }) {
....@@ -61,6 +49,7 @@
6149 host: host ?? this.host,
6250 port: port ?? this.port,
6351 localHost: localHost ?? this.localHost,
52
+ vpnHost: vpnHost ?? this.vpnHost,
6453 macAddress: macAddress ?? this.macAddress,
6554 mqttToken: mqttToken ?? this.mqttToken,
6655 );
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' show ConnectionStatus;
11
+import '../services/mqtt_service.dart' show ConnectionStatus;
1212
1313 // --- Enums ---
1414
lib/screens/chat_screen.dart
....@@ -56,6 +56,10 @@
5656 bool _isCatchingUp = false;
5757 bool _screenshotForChat = false;
5858 final Set<int> _seenSeqs = {};
59
+ bool _sessionReady = false;
60
+ final List<Map<String, dynamic>> _pendingMessages = [];
61
+ final Map<String, List<Message>> _catchUpPending = {};
62
+ List<String>? _cachedSessionOrder;
5963
6064 @override
6165 void initState() {
....@@ -66,20 +70,34 @@
6670 }
6771
6872 Future<void> _initAll() async {
69
- // Load lastSeq BEFORE connecting so catch_up sends the right value
73
+ // Load persisted state BEFORE connecting
7074 final prefs = await SharedPreferences.getInstance();
7175 _lastSeq = prefs.getInt('lastSeq') ?? 0;
76
+ // Restore saved session order and active session
77
+ _cachedSessionOrder = prefs.getStringList('sessionOrder');
78
+ final savedSessionId = prefs.getString('activeSessionId');
79
+ if (savedSessionId != null && mounted) {
80
+ ref.read(activeSessionIdProvider.notifier).state = savedSessionId;
81
+ // Load messages for the restored session so chat isn't empty on startup
82
+ await ref.read(messagesProvider.notifier).switchSession(savedSessionId);
83
+ }
7284 if (!mounted) return;
7385
7486 // Listen for playback state changes to reset play button UI
7587 // Use a brief delay to avoid race between queue transitions
7688 AudioService.onPlaybackStateChanged = () {
77
- if (mounted && !AudioService.isPlaying) {
78
- Future.delayed(const Duration(milliseconds: 100), () {
79
- if (mounted && !AudioService.isPlaying) {
80
- setState(() => _playingMessageId = null);
81
- }
82
- });
89
+ if (mounted) {
90
+ if (AudioService.isPlaying) {
91
+ // Something started playing — keep the indicator as-is
92
+ } else {
93
+ // Playback stopped — clear indicator only if queue is truly empty.
94
+ // Use a short delay since the queue transition has a brief gap.
95
+ Future.delayed(const Duration(milliseconds: 200), () {
96
+ if (mounted && !AudioService.isPlaying) {
97
+ setState(() => _playingMessageId = null);
98
+ }
99
+ });
100
+ }
83101 }
84102 };
85103
....@@ -146,9 +164,16 @@
146164 };
147165 _ws!.onMessage = _handleMessage;
148166 _ws!.onOpen = () {
167
+ _sessionReady = false; // Gate messages until sessions arrive
168
+ _pendingMessages.clear();
149169 final activeId = ref.read(activeSessionIdProvider);
150170 _sendCommand('sync', activeId != null ? {'activeSessionId': activeId} : null);
151
- // catch_up is still available during the transition period
171
+ // catch_up is sent after sessions arrive (in _handleSessions)
172
+ };
173
+ _ws!.onResume = () {
174
+ // App came back from background with connection still alive.
175
+ // Send catch_up to fetch any messages missed during suspend.
176
+ _chatLog('onResume: sending catch_up with lastSeq=$_lastSeq');
152177 _sendCommand('catch_up', {'lastSeq': _lastSeq});
153178 };
154179 _ws!.onError = (error) {
....@@ -168,6 +193,14 @@
168193 }
169194
170195 void _handleMessage(Map<String, dynamic> msg) {
196
+ final type = msg['type'] as String?;
197
+ // Sessions and catch_up always process immediately
198
+ // Content messages (text, voice, image) wait until session is ready
199
+ if (!_sessionReady && type != 'sessions' && type != 'catch_up' && type != 'status' && type != 'typing') {
200
+ _pendingMessages.add(msg);
201
+ return;
202
+ }
203
+
171204 // Track sequence numbers for catch_up protocol
172205 final seq = msg['seq'] as int?;
173206 if (seq != null) {
....@@ -185,8 +218,6 @@
185218 }
186219 }
187220
188
- final type = msg['type'] as String?;
189
-
190221 switch (type) {
191222 case 'sessions':
192223 _handleSessions(msg);
....@@ -198,10 +229,20 @@
198229 case 'image':
199230 _handleIncomingImage(msg);
200231 case 'typing':
201
- final typing = msg['typing'] as bool? ?? msg['isTyping'] as bool? ?? true;
202
- ref.read(isTypingProvider.notifier).state = typing;
232
+ final typing = msg['typing'] as bool? ?? msg['isTyping'] as bool? ?? msg['active'] as bool? ?? true;
233
+ final typingSession = msg['sessionId'] as String?;
234
+ final activeId = ref.read(activeSessionIdProvider);
235
+ _chatLog('TYPING: session=${typingSession?.substring(0, 8)} active=${activeId?.substring(0, 8)} typing=$typing match=${typingSession == activeId}');
236
+ // Strict: only show typing for the ACTIVE session, ignore all others
237
+ if (activeId != null && typingSession == activeId) {
238
+ ref.read(isTypingProvider.notifier).state = typing;
239
+ }
203240 case 'typing_end':
204
- ref.read(isTypingProvider.notifier).state = false;
241
+ final endSession = msg['sessionId'] as String?;
242
+ final activeEndId = ref.read(activeSessionIdProvider);
243
+ if (activeEndId != null && endSession == activeEndId) {
244
+ ref.read(isTypingProvider.notifier).state = false;
245
+ }
205246 case 'screenshot':
206247 ref.read(latestScreenshotProvider.notifier).state =
207248 msg['data'] as String? ?? msg['imageBase64'] as String?;
....@@ -231,7 +272,8 @@
231272 if (sessionId != null) _incrementUnread(sessionId);
232273 case 'catch_up':
233274 final serverSeq = msg['serverSeq'] as int?;
234
- if (serverSeq != null && serverSeq > _lastSeq) {
275
+ if (serverSeq != null) {
276
+ // Always sync to server's seq — if server restarted, its seq may be lower
235277 _lastSeq = serverSeq;
236278 _saveLastSeq();
237279 }
....@@ -241,19 +283,91 @@
241283 final catchUpMsgs = msg['messages'] as List<dynamic>?;
242284 if (catchUpMsgs != null && catchUpMsgs.isNotEmpty) {
243285 _isCatchingUp = true;
286
+ final activeId = ref.read(activeSessionIdProvider);
244287 final existing = ref.read(messagesProvider);
245288 final existingContents = existing
246289 .where((m) => m.role == MessageRole.assistant)
247290 .map((m) => m.content)
248291 .toSet();
249292 for (final m in catchUpMsgs) {
250
- final content = (m as Map<String, dynamic>)['content'] as String? ?? '';
251
- // Skip if we already have this message locally
252
- if (content.isNotEmpty && existingContents.contains(content)) continue;
253
- _handleMessage(m);
254
- if (content.isNotEmpty) existingContents.add(content);
293
+ final map = m as Map<String, dynamic>;
294
+ final msgType = map['type'] as String? ?? 'text';
295
+ final content = map['content'] as String? ?? map['transcript'] as String? ?? map['caption'] as String? ?? '';
296
+ final msgSessionId = map['sessionId'] as String?;
297
+ final imageData = map['imageBase64'] as String?;
298
+
299
+ // Skip empty text messages (images with no caption are OK)
300
+ if (content.isEmpty && imageData == null) continue;
301
+ // Dedup by content (skip images from dedup — they have unique msgIds)
302
+ if (imageData == null && content.isNotEmpty && existingContents.contains(content)) continue;
303
+
304
+ final Message message;
305
+ if (msgType == 'image' && imageData != null) {
306
+ message = Message.image(
307
+ role: MessageRole.assistant,
308
+ imageBase64: imageData,
309
+ content: content,
310
+ status: MessageStatus.sent,
311
+ );
312
+ } else {
313
+ message = Message.text(
314
+ role: MessageRole.assistant,
315
+ content: content,
316
+ status: MessageStatus.sent,
317
+ );
318
+ }
319
+
320
+ if (msgSessionId == null || msgSessionId == activeId) {
321
+ // Active session or no session: add directly to chat
322
+ ref.read(messagesProvider.notifier).addMessage(message);
323
+ } else {
324
+ // Different session: store + unread badge + toast
325
+ // Collect for batch storage below to avoid race condition
326
+ _catchUpPending.putIfAbsent(msgSessionId, () => []).add(message);
327
+ _incrementUnread(msgSessionId);
328
+ }
329
+ existingContents.add(content);
255330 }
256331 _isCatchingUp = false;
332
+ _scrollToBottom();
333
+ // Batch-store cross-session messages (sequential to avoid race condition)
334
+ if (_catchUpPending.isNotEmpty) {
335
+ final pending = Map<String, List<Message>>.from(_catchUpPending);
336
+ _catchUpPending.clear();
337
+ // Show one toast per session with message count
338
+ if (mounted) {
339
+ final sessions = ref.read(sessionsProvider);
340
+ for (final entry in pending.entries) {
341
+ final session = sessions.firstWhere(
342
+ (s) => s.id == entry.key,
343
+ orElse: () => Session(id: entry.key, index: 0, name: 'Unknown', type: 'claude'),
344
+ );
345
+ final count = entry.value.length;
346
+ final preview = count == 1
347
+ ? entry.value.first.content
348
+ : '$count messages';
349
+ ToastManager.show(
350
+ context,
351
+ sessionName: session.name,
352
+ preview: preview.length > 100 ? '${preview.substring(0, 100)}...' : preview,
353
+ onTap: () => _switchSession(entry.key),
354
+ );
355
+ }
356
+ }
357
+ () async {
358
+ for (final entry in pending.entries) {
359
+ final existing = await MessageStore.loadAll(entry.key);
360
+ MessageStore.save(entry.key, [...existing, ...entry.value]);
361
+ await MessageStore.flush();
362
+ }
363
+ }();
364
+ }
365
+ // Clear unread for active session
366
+ if (activeId != null) {
367
+ final counts = Map<String, int>.from(ref.read(unreadCountsProvider));
368
+ counts.remove(activeId);
369
+ ref.read(unreadCountsProvider.notifier).state = counts;
370
+ }
257371 }
258372 case 'pong':
259373 break; // heartbeat response, ignore
....@@ -271,9 +385,11 @@
271385 final sessionsJson = msg['sessions'] as List<dynamic>?;
272386 if (sessionsJson == null) return;
273387
274
- final sessions = sessionsJson
388
+ var sessions = sessionsJson
275389 .map((s) => Session.fromJson(s as Map<String, dynamic>))
276390 .toList();
391
+ // Apply saved custom order (reordered sessions persist across updates)
392
+ sessions = _applyCustomOrder(sessions);
277393 ref.read(sessionsProvider.notifier).state = sessions;
278394
279395 final activeId = ref.read(activeSessionIdProvider);
....@@ -284,6 +400,22 @@
284400 );
285401 ref.read(activeSessionIdProvider.notifier).state = active.id;
286402 ref.read(messagesProvider.notifier).switchSession(active.id);
403
+ SharedPreferences.getInstance().then((p) => p.setString('activeSessionId', active.id));
404
+ }
405
+
406
+ // Session is ready — process any pending messages that arrived before sessions list
407
+ if (!_sessionReady) {
408
+ _sessionReady = true;
409
+ // Request catch_up now that session is set
410
+ _sendCommand('catch_up', {'lastSeq': _lastSeq});
411
+ // Drain messages that arrived before sessions list
412
+ if (_pendingMessages.isNotEmpty) {
413
+ final pending = List<Map<String, dynamic>>.from(_pendingMessages);
414
+ _pendingMessages.clear();
415
+ for (final m in pending) {
416
+ _handleMessage(m);
417
+ }
418
+ }
287419 }
288420 }
289421
....@@ -420,7 +552,10 @@
420552
421553 // Only add to chat if the Screen button explicitly requested it
422554 if (!_screenshotForChat) {
423
- ref.read(isTypingProvider.notifier).state = false;
555
+ final activeId = ref.read(activeSessionIdProvider);
556
+ if (sessionId == null || sessionId == activeId) {
557
+ ref.read(isTypingProvider.notifier).state = false;
558
+ }
424559 return;
425560 }
426561 _screenshotForChat = false;
....@@ -500,13 +635,15 @@
500635 }
501636
502637 Future<void> _switchSession(String sessionId) async {
503
- // Stop any playing audio and dismiss keyboard when switching sessions
638
+ // Stop any playing audio, dismiss keyboard, and clear typing indicator
504639 await AudioService.stopPlayback();
505640 setState(() => _playingMessageId = null);
506641 if (mounted) FocusScope.of(context).unfocus();
642
+ ref.read(isTypingProvider.notifier).state = false;
507643
508644 ref.read(activeSessionIdProvider.notifier).state = sessionId;
509645 await ref.read(messagesProvider.notifier).switchSession(sessionId);
646
+ SharedPreferences.getInstance().then((p) => p.setString('activeSessionId', sessionId));
510647
511648 final counts = Map<String, int>.from(ref.read(unreadCountsProvider));
512649 counts.remove(sessionId);
....@@ -689,7 +826,10 @@
689826 // Show caption dialog
690827 final fileNames = result.files.map((f) => f.name).join(', ');
691828 final caption = await _showCaptionDialog(result.files.length);
692
- if (caption == null) return;
829
+ if (caption == null) {
830
+ if (mounted) FocusManager.instance.primaryFocus?.unfocus();
831
+ return;
832
+ }
693833
694834 // Handle voice caption
695835 String textCaption = caption;
....@@ -751,6 +891,8 @@
751891 }
752892 }
753893
894
+ // Dismiss keyboard after file flow completes
895
+ if (mounted) FocusManager.instance.primaryFocus?.unfocus();
754896 _scrollToBottom();
755897 }
756898
....@@ -859,7 +1001,10 @@
8591001 }
8601002
8611003 final caption = await _showCaptionDialog(images.length);
862
- if (caption == null) return; // user cancelled
1004
+ if (caption == null) {
1005
+ if (mounted) FocusManager.instance.primaryFocus?.unfocus();
1006
+ return; // user cancelled
1007
+ }
8631008
8641009 // Handle voice caption
8651010 String textCaption = caption;
....@@ -878,39 +1023,38 @@
8781023 <String, dynamic>{'data': b64, 'mimeType': 'image/jpeg'}
8791024 ).toList();
8801025
881
- // Create voice bubble first to get messageId for transcript reflection
882
- String? voiceMessageId;
883
- if (voiceB64 != null) {
884
- final voiceMsg = Message.voice(
885
- role: MessageRole.user,
886
- audioUri: caption.substring('__voice__:'.length),
887
- status: MessageStatus.sent,
888
- );
889
- voiceMessageId = voiceMsg.id;
890
- ref.read(messagesProvider.notifier).addMessage(voiceMsg);
891
- }
1026
+ // Create the first image message early so we have its ID for transcript reflection
1027
+ final firstImageMsg = Message.image(
1028
+ role: MessageRole.user,
1029
+ imageBase64: encodedImages[0],
1030
+ content: textCaption.isNotEmpty ? textCaption : (voiceB64 != null ? '🎤 ...' : ''),
1031
+ status: MessageStatus.sent,
1032
+ );
8921033
8931034 // Send everything as a single atomic bundle
8941035 _ws?.send({
8951036 'type': 'bundle',
8961037 'caption': textCaption,
8971038 if (voiceB64 != null) 'audioBase64': voiceB64,
898
- if (voiceMessageId != null) 'voiceMessageId': voiceMessageId,
1039
+ if (voiceB64 != null) 'voiceMessageId': firstImageMsg.id,
8991040 'attachments': attachments,
9001041 'sessionId': targetSessionId,
9011042 });
9021043
903
- // Show images in chat locally
904
- for (var i = 0; i < encodedImages.length; i++) {
1044
+ // Show as combined image+caption bubbles
1045
+ ref.read(messagesProvider.notifier).addMessage(firstImageMsg);
1046
+ for (var i = 1; i < encodedImages.length; i++) {
9051047 final message = Message.image(
9061048 role: MessageRole.user,
9071049 imageBase64: encodedImages[i],
908
- content: i == 0 ? textCaption : '',
1050
+ content: '',
9091051 status: MessageStatus.sent,
9101052 );
9111053 ref.read(messagesProvider.notifier).addMessage(message);
9121054 }
9131055
1056
+ // Dismiss keyboard after image flow completes
1057
+ if (mounted) FocusManager.instance.primaryFocus?.unfocus();
9141058 _scrollToBottom();
9151059 }
9161060
....@@ -1122,6 +1266,30 @@
11221266 final item = sessions.removeAt(oldIndex);
11231267 sessions.insert(newIndex, item);
11241268 ref.read(sessionsProvider.notifier).state = sessions;
1269
+ // Persist custom order AND update cache so next server update preserves it
1270
+ final ids = sessions.map((s) => s.id).toList();
1271
+ _cachedSessionOrder = ids;
1272
+ _saveSessionOrder(ids);
1273
+ }
1274
+
1275
+ void _saveSessionOrder(List<String> ids) {
1276
+ SharedPreferences.getInstance().then((p) => p.setStringList('sessionOrder', ids));
1277
+ }
1278
+
1279
+ /// Apply saved custom order to a server-provided session list.
1280
+ /// New sessions (not in saved order) are appended at the end.
1281
+ List<Session> _applyCustomOrder(List<Session> sessions) {
1282
+ if (_cachedSessionOrder == null || _cachedSessionOrder!.isEmpty) return sessions;
1283
+ final order = _cachedSessionOrder!;
1284
+ final byId = {for (final s in sessions) s.id: s};
1285
+ final ordered = <Session>[];
1286
+ for (final id in order) {
1287
+ final s = byId.remove(id);
1288
+ if (s != null) ordered.add(s);
1289
+ }
1290
+ // Append any new sessions not in saved order
1291
+ ordered.addAll(byId.values);
1292
+ return ordered;
11251293 }
11261294
11271295 void _refreshSessions() {
....@@ -1173,6 +1341,9 @@
11731341 ),
11741342 ],
11751343 ),
1344
+ onDrawerChanged: (isOpened) {
1345
+ if (isOpened) FocusManager.instance.primaryFocus?.unfocus();
1346
+ },
11761347 drawer: SessionDrawer(
11771348 sessions: sessions,
11781349 activeSessionId: activeSession?.id,
lib/screens/navigate_screen.dart
....@@ -192,20 +192,11 @@
192192 void _sendKey(String key) {
193193 _haptic();
194194
195
- // Send via WebSocket - the chat screen's WS is in the provider
196
- // We need to access the WS through the provider system
197
- // For now, send a nav command message
195
+ // Send via MQTT - the chat screen's MQTT service is in the provider
198196 final activeSessionId = ref.read(activeSessionIdProvider);
199197
200
- // Build the navigate command
201
- // This sends a key press to the AIBroker daemon
202
- // which forwards it to the active terminal session
203
- // The WS is managed by ChatScreen, so we'll use a message approach
204
-
205
- // Since we can't directly access the WS from here,
206
- // we send through the provider approach - the message will be picked up
207
- // by the WS service in ChatScreen via a shared notification mechanism.
208
- // For simplicity, we use a global event bus pattern.
198
+ // Send a key press to the AIBroker daemon via the MQTT service.
199
+ // NavigateNotifier bridges the navigate screen to the chat screen's MQTT service.
209200
210201 NavigateNotifier.instance?.sendKey(key, activeSessionId);
211202
....@@ -228,8 +219,8 @@
228219 }
229220 }
230221
231
-/// Global notifier to bridge navigate screen to WebSocket.
232
-/// Set by ChatScreen when WS is initialized.
222
+/// Global notifier to bridge navigate screen to MQTT service.
223
+/// Set by ChatScreen when MQTT is initialized.
233224 class NavigateNotifier {
234225 static NavigateNotifier? instance;
235226
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' show ConnectionStatus;
6
+import '../services/mqtt_service.dart' show ConnectionStatus;
77 import '../services/wol_service.dart';
88 import '../theme/app_theme.dart';
99 import '../widgets/status_dot.dart';
....@@ -18,6 +18,7 @@
1818 class _SettingsScreenState extends ConsumerState<SettingsScreen> {
1919 final _formKey = GlobalKey<FormState>();
2020 late final TextEditingController _localHostController;
21
+ late final TextEditingController _vpnHostController;
2122 late final TextEditingController _remoteHostController;
2223 late final TextEditingController _portController;
2324 late final TextEditingController _macController;
....@@ -30,6 +31,8 @@
3031 final config = ref.read(serverConfigProvider);
3132 _localHostController =
3233 TextEditingController(text: config?.localHost ?? '');
34
+ _vpnHostController =
35
+ TextEditingController(text: config?.vpnHost ?? '');
3336 _remoteHostController =
3437 TextEditingController(text: config?.host ?? '');
3538 _portController =
....@@ -43,6 +46,7 @@
4346 @override
4447 void dispose() {
4548 _localHostController.dispose();
49
+ _vpnHostController.dispose();
4650 _remoteHostController.dispose();
4751 _portController.dispose();
4852 _macController.dispose();
....@@ -59,6 +63,9 @@
5963 localHost: _localHostController.text.trim().isEmpty
6064 ? null
6165 : _localHostController.text.trim(),
66
+ vpnHost: _vpnHostController.text.trim().isEmpty
67
+ ? null
68
+ : _vpnHostController.text.trim(),
6269 macAddress: _macController.text.trim().isEmpty
6370 ? null
6471 : _macController.text.trim(),
....@@ -153,6 +160,19 @@
153160 ),
154161 const SizedBox(height: 16),
155162
163
+ // VPN address
164
+ Text('VPN Address',
165
+ style: Theme.of(context).textTheme.bodyMedium),
166
+ const SizedBox(height: 4),
167
+ TextFormField(
168
+ controller: _vpnHostController,
169
+ decoration: const InputDecoration(
170
+ hintText: '10.8.0.1 (OpenVPN static IP)',
171
+ ),
172
+ keyboardType: TextInputType.url,
173
+ ),
174
+ const SizedBox(height: 16),
175
+
156176 // Remote address
157177 Text('Remote Address',
158178 style: Theme.of(context).textTheme.bodyMedium),
lib/services/audio_service.dart
....@@ -69,10 +69,15 @@
6969
7070 final path = _queue.removeAt(0);
7171 try {
72
+ // Brief pause between tracks — iOS audio player needs time to reset
73
+ await _player.stop();
74
+ await Future.delayed(const Duration(milliseconds: 150));
7275 await _player.play(DeviceFileSource(path));
7376 _isPlaying = true;
7477 onPlaybackStateChanged?.call();
75
- } catch (_) {
78
+ debugPrint('AudioService: playing next from queue (remaining: ${_queue.length})');
79
+ } catch (e) {
80
+ debugPrint('AudioService: queue play failed: $e');
7681 // Skip broken file, try next
7782 _onTrackComplete();
7883 }
....@@ -167,13 +172,20 @@
167172 if (path == null) return;
168173
169174 if (_isPlaying) {
170
- // Already playing — just add to queue, it will play when current finishes
175
+ // Already playing — add to queue, plays when current finishes
171176 _queue.add(path);
177
+ debugPrint('AudioService: queued (queue size: ${_queue.length})');
172178 } else {
173179 // Nothing playing — start immediately
174
- await _player.play(DeviceFileSource(path));
175
- _isPlaying = true;
176
- onPlaybackStateChanged?.call();
180
+ try {
181
+ await _player.play(DeviceFileSource(path));
182
+ _isPlaying = true;
183
+ onPlaybackStateChanged?.call();
184
+ debugPrint('AudioService: playing immediately');
185
+ } catch (e) {
186
+ debugPrint('AudioService: play failed: $e');
187
+ _onTrackComplete();
188
+ }
177189 }
178190 }
179191
lib/services/mqtt_service.dart
....@@ -2,6 +2,7 @@
22 import 'dart:convert';
33 import 'dart:io';
44
5
+import 'package:bonsoir/bonsoir.dart';
56 import 'package:flutter/widgets.dart';
67 import 'package:path_provider/path_provider.dart' as pp;
78 import 'package:mqtt_client/mqtt_client.dart';
....@@ -10,8 +11,15 @@
1011 import 'package:uuid/uuid.dart';
1112
1213 import '../models/server_config.dart';
13
-import 'websocket_service.dart' show ConnectionStatus;
1414 import 'wol_service.dart';
15
+
16
+/// Connection status for the MQTT client.
17
+enum ConnectionStatus {
18
+ disconnected,
19
+ connecting,
20
+ connected,
21
+ reconnecting,
22
+}
1523
1624 // Debug log to file (survives release builds)
1725 Future<void> _mqttLog(String msg) async {
....@@ -23,11 +31,11 @@
2331 } catch (_) {}
2432 }
2533
26
-/// MQTT client for PAILot, replacing WebSocketService.
34
+/// MQTT client for PAILot.
2735 ///
2836 /// Connects to the AIBroker daemon's embedded aedes broker.
2937 /// Subscribes to all pailot/ topics and dispatches messages
30
-/// through the same callback interface as WebSocketService.
38
+/// through the onMessage callback interface.
3139 class MqttService with WidgetsBindingObserver {
3240 MqttService({required this.config});
3341
....@@ -43,12 +51,13 @@
4351 final List<String> _seenMsgIdOrder = [];
4452 static const int _maxSeenIds = 500;
4553
46
- // Callbacks — same interface as WebSocketService
54
+ // Callbacks
4755 void Function(ConnectionStatus status)? onStatusChanged;
4856 void Function(Map<String, dynamic> message)? onMessage;
4957 void Function()? onOpen;
5058 void Function()? onClose;
5159 void Function()? onReconnecting;
60
+ void Function()? onResume;
5261 void Function(String error)? onError;
5362
5463 ConnectionStatus get status => _status;
....@@ -94,29 +103,50 @@
94103 }
95104
96105 final clientId = await _getClientId();
97
- final hosts = _getHosts();
98
- _mqttLog('MQTT: hosts=${hosts.join(", ")} port=${config.port}');
99106
100
- for (final host in hosts) {
107
+ // Connection order: local → Bonjour → VPN → remote
108
+ final attempts = <MapEntry<String, int>>[]; // host → timeout ms
109
+ if (config.localHost != null && config.localHost!.isNotEmpty) {
110
+ attempts.add(MapEntry(config.localHost!, 2500));
111
+ }
112
+ // Bonjour placeholder — inserted dynamically below
113
+ if (config.vpnHost != null && config.vpnHost!.isNotEmpty) {
114
+ attempts.add(MapEntry(config.vpnHost!, 3000));
115
+ }
116
+ if (config.host.isNotEmpty) {
117
+ attempts.add(MapEntry(config.host, 5000));
118
+ }
119
+ _mqttLog('MQTT: attempts=${attempts.map((e) => e.key).join(", ")} port=${config.port}');
120
+
121
+ // Try configured local host first
122
+ for (final attempt in attempts) {
101123 if (_intentionalClose) return;
102
-
103
- _mqttLog('MQTT: trying $host:${config.port}');
124
+ _mqttLog('MQTT: trying ${attempt.key}:${config.port}');
104125 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;
126
+ if (await _tryConnect(attempt.key, clientId, timeout: attempt.value)) return;
112127 } catch (e) {
113
- _mqttLog('MQTT: $host error=$e');
114
- continue;
128
+ _mqttLog('MQTT: ${attempt.key} error=$e');
129
+ }
130
+
131
+ // After local host fails, try Bonjour discovery before VPN/remote
132
+ if (attempt.key == config.localHost && !_intentionalClose) {
133
+ _mqttLog('MQTT: trying Bonjour discovery...');
134
+ final bonjourHost = await _discoverViaMdns();
135
+ if (bonjourHost != null && !_intentionalClose) {
136
+ _mqttLog('MQTT: Bonjour found $bonjourHost');
137
+ try {
138
+ if (await _tryConnect(bonjourHost, clientId, timeout: 3000)) return;
139
+ } catch (e) {
140
+ _mqttLog('MQTT: Bonjour host $bonjourHost error=$e');
141
+ }
142
+ } else {
143
+ _mqttLog('MQTT: Bonjour discovery returned nothing');
144
+ }
115145 }
116146 }
117147
118148 // All hosts failed — retry after delay
119
- _mqttLog('MQTT: all hosts failed, retrying in 5s');
149
+ _mqttLog('MQTT: all attempts failed, retrying in 5s');
120150 _setStatus(ConnectionStatus.reconnecting);
121151 Future.delayed(const Duration(seconds: 5), () {
122152 if (!_intentionalClose && _status != ConnectionStatus.connected) {
....@@ -125,14 +155,43 @@
125155 });
126156 }
127157
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];
158
+ /// Discover AIBroker on local network via Bonjour/mDNS.
159
+ /// Returns the IP address or null if not found within timeout.
160
+ Future<String?> _discoverViaMdns({Duration timeout = const Duration(seconds: 3)}) async {
161
+ try {
162
+ final discovery = BonsoirDiscovery(type: '_mqtt._tcp');
163
+ await discovery.initialize();
164
+
165
+ final completer = Completer<String?>();
166
+ StreamSubscription? sub;
167
+
168
+ sub = discovery.eventStream?.listen((event) {
169
+ switch (event) {
170
+ case BonsoirDiscoveryServiceResolvedEvent():
171
+ final ip = event.service.host;
172
+ _mqttLog('MQTT: Bonjour resolved: ${event.service.name} at $ip:${event.service.port}');
173
+ if (ip != null && ip.isNotEmpty && !completer.isCompleted) {
174
+ completer.complete(ip);
175
+ }
176
+ case BonsoirDiscoveryServiceFoundEvent():
177
+ _mqttLog('MQTT: Bonjour found: ${event.service.name}');
178
+ default:
179
+ break;
180
+ }
181
+ });
182
+
183
+ await discovery.start();
184
+
185
+ final ip = await completer.future.timeout(timeout, onTimeout: () => null);
186
+
187
+ await sub?.cancel();
188
+ await discovery.stop();
189
+
190
+ return ip;
191
+ } catch (e) {
192
+ _mqttLog('MQTT: Bonjour discovery error: $e');
193
+ return null;
134194 }
135
- return [config.host];
136195 }
137196
138197 Future<bool> _tryConnect(String host, String clientId, {int timeout = 5000}) async {
....@@ -149,9 +208,12 @@
149208 client.onAutoReconnect = _onAutoReconnect;
150209 client.onAutoReconnected = _onAutoReconnected;
151210
152
- // Persistent session: broker queues QoS 1 messages while client is offline
211
+ // Clean session: we handle offline delivery ourselves via catch_up protocol.
212
+ // Persistent sessions cause the broker to flood all queued QoS 1 messages
213
+ // on reconnect, which overwhelms the client with large voice payloads.
153214 final connMessage = MqttConnectMessage()
154215 .withClientIdentifier(clientId)
216
+ .startClean()
155217 .authenticateAs('pailot', config.mqttToken ?? '');
156218
157219 client.connectionMessage = connMessage;
....@@ -268,7 +330,7 @@
268330
269331 /// Route incoming MQTT messages to the onMessage callback.
270332 /// Translates MQTT topic structure into the flat message format
271
- /// that chat_screen expects (same as WebSocket messages).
333
+ /// that chat_screen expects.
272334 void _dispatchMessage(String topic, Map<String, dynamic> json) {
273335 final parts = topic.split('/');
274336
....@@ -369,7 +431,6 @@
369431 }
370432
371433 /// Send a message — routes to the appropriate MQTT topic based on content.
372
- /// Accepts the same message format as WebSocketService.send().
373434 void send(Map<String, dynamic> message) {
374435 final type = message['type'] as String?;
375436 final sessionId = message['sessionId'] as String?;
....@@ -423,6 +484,7 @@
423484 'sessionId': sessionId,
424485 'caption': message['caption'] ?? '',
425486 if (message['audioBase64'] != null) 'audioBase64': message['audioBase64'],
487
+ if (message['voiceMessageId'] != null) 'voiceMessageId': message['voiceMessageId'],
426488 'attachments': message['attachments'] ?? [],
427489 'ts': _now(),
428490 }, MqttQos.atLeastOnce);
....@@ -505,11 +567,22 @@
505567 void didChangeAppLifecycleState(AppLifecycleState state) {
506568 switch (state) {
507569 case AppLifecycleState.resumed:
508
- if (_status != ConnectionStatus.connected && !_intentionalClose) {
570
+ if (_intentionalClose) break;
571
+ _mqttLog('MQTT: app resumed, status=$_status client=${_client != null} mqttState=${_client?.connectionStatus?.state}');
572
+ final client = _client;
573
+ if (client == null || client.connectionStatus?.state != MqttConnectionState.connected) {
574
+ // Clearly disconnected — just reconnect
575
+ _mqttLog('MQTT: not connected on resume, reconnecting...');
576
+ _client = null;
577
+ _setStatus(ConnectionStatus.reconnecting);
509578 connect();
579
+ } else {
580
+ // Appears connected — notify listener to fetch missed messages
581
+ // via catch_up. Don't call onOpen (it resets sessionReady and causes flicker).
582
+ _mqttLog('MQTT: appears connected on resume, triggering catch_up');
583
+ onResume?.call();
510584 }
511585 case AppLifecycleState.paused:
512
- // Keep connection alive — MQTT handles keepalive natively
513586 break;
514587 default:
515588 break;
lib/services/websocket_service.dart
deleted file mode 100644
....@@ -1,288 +0,0 @@
1
-import 'dart:async';
2
-import 'dart:convert';
3
-
4
-import 'package:flutter/widgets.dart';
5
-import 'package:web_socket_channel/web_socket_channel.dart';
6
-
7
-import '../models/server_config.dart';
8
-import 'wol_service.dart';
9
-
10
-enum ConnectionStatus {
11
- disconnected,
12
- connecting,
13
- connected,
14
- reconnecting,
15
-}
16
-
17
-/// WebSocket client with dual-URL fallback, heartbeat, and auto-reconnect.
18
-class WebSocketService with WidgetsBindingObserver {
19
- WebSocketService({required this.config});
20
-
21
- ServerConfig config;
22
- WebSocketChannel? _channel;
23
- ConnectionStatus _status = ConnectionStatus.disconnected;
24
- Timer? _heartbeatTimer;
25
- Timer? _zombieTimer;
26
- Timer? _reconnectTimer;
27
- int _reconnectAttempt = 0;
28
- bool _intentionalClose = false;
29
- DateTime? _lastPong;
30
- StreamSubscription? _subscription;
31
-
32
- // Callbacks
33
- void Function()? onOpen;
34
- void Function()? onClose;
35
- void Function()? onReconnecting;
36
- void Function(Map<String, dynamic> message)? onMessage;
37
- void Function(String error)? onError;
38
- void Function(ConnectionStatus status)? onStatusChanged;
39
-
40
- ConnectionStatus get status => _status;
41
- bool get isConnected => _status == ConnectionStatus.connected;
42
-
43
- void _setStatus(ConnectionStatus newStatus) {
44
- if (_status == newStatus) return;
45
- _status = newStatus;
46
- onStatusChanged?.call(newStatus);
47
- }
48
-
49
- /// Connect to the WebSocket server.
50
- /// Tries local URL first (2.5s timeout), then remote URL.
51
- Future<void> connect() async {
52
- if (_status == ConnectionStatus.connected ||
53
- _status == ConnectionStatus.connecting) {
54
- return;
55
- }
56
-
57
- _intentionalClose = false;
58
- _setStatus(ConnectionStatus.connecting);
59
-
60
- // Send Wake-on-LAN if MAC configured
61
- if (config.macAddress != null && config.macAddress!.isNotEmpty) {
62
- try {
63
- await WolService.wake(config.macAddress!, localHost: config.localHost);
64
- } catch (_) {}
65
- }
66
-
67
- final urls = config.urls;
68
-
69
- for (final url in urls) {
70
- if (_intentionalClose) return;
71
-
72
- try {
73
- final connected = await _tryConnect(url,
74
- timeout: url == urls.first && urls.length > 1
75
- ? const Duration(milliseconds: 2500)
76
- : const Duration(seconds: 5));
77
- if (connected) return;
78
- } catch (_) {
79
- continue;
80
- }
81
- }
82
-
83
- // All URLs failed
84
- _setStatus(ConnectionStatus.disconnected);
85
- onError?.call('Failed to connect to server');
86
- _scheduleReconnect();
87
- }
88
-
89
- Future<bool> _tryConnect(String url, {Duration? timeout}) async {
90
- try {
91
- final uri = Uri.parse(url);
92
- final channel = WebSocketChannel.connect(uri);
93
-
94
- // Wait for connection with timeout
95
- await channel.ready.timeout(
96
- timeout ?? const Duration(seconds: 5),
97
- onTimeout: () {
98
- channel.sink.close();
99
- throw TimeoutException('Connection timeout');
100
- },
101
- );
102
-
103
- _channel = channel;
104
- _reconnectAttempt = 0;
105
- _setStatus(ConnectionStatus.connected);
106
- _startHeartbeat();
107
- _listenMessages();
108
- onOpen?.call();
109
- return true;
110
- } catch (e) {
111
- return false;
112
- }
113
- }
114
-
115
- void _listenMessages() {
116
- _subscription?.cancel();
117
- _subscription = _channel?.stream.listen(
118
- (data) {
119
- _lastPong = DateTime.now();
120
-
121
- if (data is String) {
122
- // Handle pong
123
- if (data == 'pong') return;
124
-
125
- try {
126
- final json = jsonDecode(data) as Map<String, dynamic>;
127
- onMessage?.call(json);
128
- } catch (_) {
129
- // Non-JSON message, ignore
130
- }
131
- }
132
- },
133
- onError: (error) {
134
- onError?.call(error.toString());
135
- _handleDisconnect();
136
- },
137
- onDone: () {
138
- _handleDisconnect();
139
- },
140
- );
141
- }
142
-
143
- void _startHeartbeat() {
144
- _heartbeatTimer?.cancel();
145
- _zombieTimer?.cancel();
146
- _lastPong = DateTime.now();
147
-
148
- // Send ping every 30 seconds
149
- _heartbeatTimer = Timer.periodic(const Duration(seconds: 30), (_) {
150
- if (_channel != null && _status == ConnectionStatus.connected) {
151
- try {
152
- _channel!.sink.add(jsonEncode({'type': 'ping'}));
153
- } catch (_) {
154
- _handleDisconnect();
155
- }
156
- }
157
- });
158
-
159
- // Check for zombie connection every 15 seconds
160
- _zombieTimer = Timer.periodic(const Duration(seconds: 15), (_) {
161
- if (_lastPong != null) {
162
- final elapsed = DateTime.now().difference(_lastPong!);
163
- if (elapsed.inSeconds > 60) {
164
- _handleDisconnect();
165
- }
166
- }
167
- });
168
- }
169
-
170
- void _handleDisconnect() {
171
- _stopHeartbeat();
172
- _subscription?.cancel();
173
-
174
- final wasConnected = _status == ConnectionStatus.connected;
175
-
176
- try {
177
- _channel?.sink.close();
178
- } catch (_) {}
179
- _channel = null;
180
-
181
- if (_intentionalClose) {
182
- _setStatus(ConnectionStatus.disconnected);
183
- onClose?.call();
184
- } else if (wasConnected) {
185
- _setStatus(ConnectionStatus.reconnecting);
186
- onReconnecting?.call();
187
- _scheduleReconnect();
188
- }
189
- }
190
-
191
- void _stopHeartbeat() {
192
- _heartbeatTimer?.cancel();
193
- _zombieTimer?.cancel();
194
- _heartbeatTimer = null;
195
- _zombieTimer = null;
196
- }
197
-
198
- void _scheduleReconnect() {
199
- if (_intentionalClose) return;
200
-
201
- _reconnectTimer?.cancel();
202
-
203
- // Exponential backoff: 1s, 2s, 4s, 8s, 16s, 30s max
204
- final delay = Duration(
205
- milliseconds: (1000 * (1 << _reconnectAttempt.clamp(0, 4)))
206
- .clamp(1000, 30000),
207
- );
208
-
209
- _reconnectAttempt++;
210
-
211
- _reconnectTimer = Timer(delay, () {
212
- if (!_intentionalClose) {
213
- _setStatus(ConnectionStatus.reconnecting);
214
- onReconnecting?.call();
215
- connect();
216
- }
217
- });
218
- }
219
-
220
- /// Send a JSON message.
221
- void send(Map<String, dynamic> message) {
222
- if (_channel == null || _status != ConnectionStatus.connected) {
223
- onError?.call('Not connected');
224
- return;
225
- }
226
-
227
- try {
228
- _channel!.sink.add(jsonEncode(message));
229
- } catch (e) {
230
- onError?.call('Send failed: $e');
231
- }
232
- }
233
-
234
- /// Send a raw string.
235
- void sendRaw(String data) {
236
- if (_channel == null || _status != ConnectionStatus.connected) return;
237
- try {
238
- _channel!.sink.add(data);
239
- } catch (_) {}
240
- }
241
-
242
- /// Disconnect intentionally.
243
- void disconnect() {
244
- _intentionalClose = true;
245
- _reconnectTimer?.cancel();
246
- _stopHeartbeat();
247
- _subscription?.cancel();
248
-
249
- try {
250
- _channel?.sink.close();
251
- } catch (_) {}
252
- _channel = null;
253
-
254
- _setStatus(ConnectionStatus.disconnected);
255
- onClose?.call();
256
- }
257
-
258
- /// Update config and reconnect.
259
- Future<void> updateConfig(ServerConfig newConfig) async {
260
- config = newConfig;
261
- disconnect();
262
- await Future.delayed(const Duration(milliseconds: 100));
263
- await connect();
264
- }
265
-
266
- /// Dispose all resources.
267
- void dispose() {
268
- disconnect();
269
- _reconnectTimer?.cancel();
270
- }
271
-
272
- // App lifecycle integration
273
- @override
274
- void didChangeAppLifecycleState(AppLifecycleState state) {
275
- switch (state) {
276
- case AppLifecycleState.resumed:
277
- if (_status != ConnectionStatus.connected && !_intentionalClose) {
278
- _reconnectAttempt = 0;
279
- connect();
280
- }
281
- case AppLifecycleState.paused:
282
- // Keep connection alive but don't reconnect aggressively
283
- break;
284
- default:
285
- break;
286
- }
287
- }
288
-}
lib/widgets/message_bubble.dart
....@@ -4,6 +4,7 @@
44
55 import 'package:flutter/material.dart';
66 import 'package:flutter/services.dart';
7
+import 'package:flutter_markdown/flutter_markdown.dart';
78 import 'package:intl/intl.dart';
89
910 import '../models/message.dart';
....@@ -105,14 +106,17 @@
105106 Widget _buildContent(BuildContext context) {
106107 switch (message.type) {
107108 case MessageType.text:
108
- return SelectableText(
109
- message.content,
110
- style: TextStyle(
111
- fontSize: 15,
112
- color: _isUser ? Colors.white : null,
113
- height: 1.4,
114
- ),
115
- );
109
+ if (_isUser) {
110
+ return SelectableText(
111
+ message.content,
112
+ style: const TextStyle(
113
+ fontSize: 15,
114
+ color: Colors.white,
115
+ height: 1.4,
116
+ ),
117
+ );
118
+ }
119
+ return _buildMarkdown(context);
116120
117121 case MessageType.voice:
118122 return _buildVoiceContent(context);
....@@ -120,6 +124,57 @@
120124 case MessageType.image:
121125 return _buildImageContent(context);
122126 }
127
+ }
128
+
129
+ Widget _buildMarkdown(BuildContext context) {
130
+ final isDark = Theme.of(context).brightness == Brightness.dark;
131
+ final textColor = isDark ? Colors.white : Colors.black87;
132
+ final codeBackground = isDark
133
+ ? Colors.white.withAlpha(20)
134
+ : Colors.black.withAlpha(15);
135
+
136
+ return MarkdownBody(
137
+ data: message.content,
138
+ selectable: true,
139
+ softLineBreak: true,
140
+ styleSheet: MarkdownStyleSheet(
141
+ p: TextStyle(fontSize: 15, height: 1.4, color: textColor),
142
+ h1: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: textColor),
143
+ h2: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: textColor),
144
+ h3: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: textColor),
145
+ strong: TextStyle(fontWeight: FontWeight.bold, color: textColor),
146
+ em: TextStyle(fontStyle: FontStyle.italic, color: textColor),
147
+ code: TextStyle(
148
+ fontSize: 13,
149
+ fontFamily: 'monospace',
150
+ color: textColor,
151
+ backgroundColor: codeBackground,
152
+ ),
153
+ codeblockDecoration: BoxDecoration(
154
+ color: codeBackground,
155
+ borderRadius: BorderRadius.circular(8),
156
+ ),
157
+ codeblockPadding: const EdgeInsets.all(10),
158
+ listBullet: TextStyle(fontSize: 15, color: textColor),
159
+ blockquoteDecoration: BoxDecoration(
160
+ border: Border(
161
+ left: BorderSide(color: AppColors.accent, width: 3),
162
+ ),
163
+ ),
164
+ blockquotePadding: const EdgeInsets.only(left: 12, top: 4, bottom: 4),
165
+ ),
166
+ onTapLink: (text, href, title) {
167
+ if (href != null) {
168
+ Clipboard.setData(ClipboardData(text: href));
169
+ ScaffoldMessenger.of(context).showSnackBar(
170
+ SnackBar(
171
+ content: Text('Link copied: $href'),
172
+ duration: const Duration(seconds: 2),
173
+ ),
174
+ );
175
+ }
176
+ },
177
+ );
123178 }
124179
125180 Widget _buildVoiceContent(BuildContext context) {
....@@ -194,14 +249,17 @@
194249 // Transcript
195250 if (message.content.isNotEmpty) ...[
196251 const SizedBox(height: 8),
197
- SelectableText(
198
- message.content,
199
- style: TextStyle(
200
- fontSize: 14,
201
- color: _isUser ? Colors.white.withAlpha(220) : null,
202
- height: 1.3,
203
- ),
204
- ),
252
+ if (_isUser)
253
+ SelectableText(
254
+ message.content,
255
+ style: TextStyle(
256
+ fontSize: 14,
257
+ color: Colors.white.withAlpha(220),
258
+ height: 1.3,
259
+ ),
260
+ )
261
+ else
262
+ _buildMarkdown(context),
205263 ],
206264 ],
207265 );
lib/widgets/status_dot.dart
....@@ -1,9 +1,9 @@
11 import 'package:flutter/material.dart';
22
3
-import '../services/websocket_service.dart';
3
+import '../services/mqtt_service.dart';
44 import '../theme/app_theme.dart';
55
6
-/// 10px circle indicating WebSocket connection status.
6
+/// 10px circle indicating MQTT connection status.
77 class StatusDot extends StatelessWidget {
88 final ConnectionStatus status;
99
pubspec.lock
....@@ -73,6 +73,54 @@
7373 url: "https://pub.dev"
7474 source: hosted
7575 version: "4.3.0"
76
+ bonsoir:
77
+ dependency: "direct main"
78
+ description:
79
+ name: bonsoir
80
+ sha256: "42f2c1eb55e833bcb541dfcb759851da0a703106646a0cf15a16c6de21f4a5a4"
81
+ url: "https://pub.dev"
82
+ source: hosted
83
+ version: "6.0.2"
84
+ bonsoir_android:
85
+ dependency: transitive
86
+ description:
87
+ name: bonsoir_android
88
+ sha256: e19728f94a0d9813abf9e2edf644fede008e58ef539865a1be86ac5d8994154e
89
+ url: "https://pub.dev"
90
+ source: hosted
91
+ version: "6.0.1"
92
+ bonsoir_darwin:
93
+ dependency: transitive
94
+ description:
95
+ name: bonsoir_darwin
96
+ sha256: e242a03a019fd474be657715826cfc13e43d02c88e46ec5611a20b9d4f72854d
97
+ url: "https://pub.dev"
98
+ source: hosted
99
+ version: "6.0.1"
100
+ bonsoir_linux:
101
+ dependency: transitive
102
+ description:
103
+ name: bonsoir_linux
104
+ sha256: "9c326c572c241c6a38ab7a8a5dba27c82917ec12504f84308ce3b5706619e8d3"
105
+ url: "https://pub.dev"
106
+ source: hosted
107
+ version: "6.0.2"
108
+ bonsoir_platform_interface:
109
+ dependency: transitive
110
+ description:
111
+ name: bonsoir_platform_interface
112
+ sha256: "3fa0c46b30eb2a2f48be6fa53591a5c0425bf00520be761b61763e58b51814ff"
113
+ url: "https://pub.dev"
114
+ source: hosted
115
+ version: "6.0.1"
116
+ bonsoir_windows:
117
+ dependency: transitive
118
+ description:
119
+ name: bonsoir_windows
120
+ sha256: "34c54802baaa2f00e3c4ab7ea46888f2a829876753778e2f40e3f273c3382d34"
121
+ url: "https://pub.dev"
122
+ source: hosted
123
+ version: "6.0.1"
76124 boolean_selector:
77125 dependency: transitive
78126 description:
....@@ -254,6 +302,14 @@
254302 url: "https://pub.dev"
255303 source: hosted
256304 version: "6.0.0"
305
+ flutter_markdown:
306
+ dependency: "direct main"
307
+ description:
308
+ name: flutter_markdown
309
+ sha256: "08fb8315236099ff8e90cb87bb2b935e0a724a3af1623000a9cec930468e0f27"
310
+ url: "https://pub.dev"
311
+ source: hosted
312
+ version: "0.7.7+1"
257313 flutter_plugin_android_lifecycle:
258314 dependency: transitive
259315 description:
....@@ -488,6 +544,14 @@
488544 url: "https://pub.dev"
489545 source: hosted
490546 version: "1.3.0"
547
+ markdown:
548
+ dependency: transitive
549
+ description:
550
+ name: markdown
551
+ sha256: ee85086ad7698b42522c6ad42fe195f1b9898e4d974a1af4576c1a3a176cada9
552
+ url: "https://pub.dev"
553
+ source: hosted
554
+ version: "7.3.1"
491555 matcher:
492556 dependency: transitive
493557 description:
pubspec.yaml
....@@ -30,6 +30,8 @@
3030 uuid: ^4.5.1
3131 collection: ^1.19.1
3232 file_picker: ^10.3.10
33
+ flutter_markdown: ^0.7.7+1
34
+ bonsoir: ^6.0.2
3335
3436 dev_dependencies:
3537 flutter_test: