| 2026-03-24 | Matthias Nott | ![]() |
| 2026-03-24 | Matthias Nott | ![]() |
| 2026-03-24 | Matthias Nott | ![]() |
| 2026-03-24 | Matthias Nott | ![]() |
| 2026-03-24 | Matthias Nott | ![]() |
| 2026-03-24 | Matthias Nott | ![]() |
| 2026-03-24 | Matthias Nott | ![]() |
| 2026-03-24 | Matthias Nott | ![]() |
| 2026-03-24 | Matthias Nott | ![]() |
| 2026-03-24 | Matthias Nott | ![]() |
| 2026-03-24 | Matthias Nott | ![]() |
| 2026-03-24 | Matthias Nott | ![]() |
| 2026-03-24 | Matthias Nott | ![]() |
| 2026-03-24 | Matthias Nott | ![]() |
| 2026-03-24 | Matthias Nott | ![]() |
| 2026-03-24 | Matthias Nott | ![]() |
| 2026-03-24 | Matthias Nott | ![]() |
| 2026-03-24 | Matthias Nott | ![]() |
| 2026-03-24 | Matthias Nott | ![]() |
| 2026-03-24 | Matthias Nott | ![]() |
| 2026-03-24 | Matthias Nott | ![]() |
| 2026-03-24 | Matthias Nott | ![]() |
| 2026-03-24 | Matthias Nott | ![]() |
| 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 @@ 67 67 <true/> 68 68 </dict> 69 69 <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>71 71 <key>NSBonjourServices</key> 72 72 <array> 73 73 <string>_http._tcp</string> 74 + <string>_mqtt._tcp</string>74 75 </array> 75 76 <key>UISupportedInterfaceOrientations</key> 76 77 <array> lib/models/server_config.dart
.. .. @@ -2,6 +2,7 @@ 2 2 final String host; 3 3 final int port; 4 4 final String? localHost; 5 + final String? vpnHost;5 6 final String? macAddress; 6 7 final String? mqttToken; 7 8 .. .. @@ -9,32 +10,17 @@ 9 10 required this.host, 10 11 this.port = 8765, 11 12 this.localHost, 13 + this.vpnHost,12 14 this.macAddress, 13 15 this.mqttToken, 14 16 }); 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 - }32 17 33 18 Map<String, dynamic> toJson() { 34 19 return { 35 20 'host': host, 36 21 'port': port, 37 22 if (localHost != null) 'localHost': localHost, 23 + if (vpnHost != null) 'vpnHost': vpnHost,38 24 if (macAddress != null) 'macAddress': macAddress, 39 25 if (mqttToken != null) 'mqttToken': mqttToken, 40 26 }; .. .. @@ -45,6 +31,7 @@ 45 31 host: json['host'] as String? ?? '', 46 32 port: json['port'] as int? ?? 8765, 47 33 localHost: json['localHost'] as String?, 34 + vpnHost: json['vpnHost'] as String?,48 35 macAddress: json['macAddress'] as String?, 49 36 mqttToken: json['mqttToken'] as String?, 50 37 ); .. .. @@ -54,6 +41,7 @@ 54 41 String? host, 55 42 int? port, 56 43 String? localHost, 44 + String? vpnHost,57 45 String? macAddress, 58 46 String? mqttToken, 59 47 }) { .. .. @@ -61,6 +49,7 @@ 61 49 host: host ?? this.host, 62 50 port: port ?? this.port, 63 51 localHost: localHost ?? this.localHost, 52 + vpnHost: vpnHost ?? this.vpnHost,64 53 macAddress: macAddress ?? this.macAddress, 65 54 mqttToken: mqttToken ?? this.mqttToken, 66 55 ); lib/providers/providers.dart
.. .. @@ -8,7 +8,7 @@ 8 8 import '../models/server_config.dart'; 9 9 import '../models/session.dart'; 10 10 import '../services/message_store.dart'; 11 -import '../services/websocket_service.dart' show ConnectionStatus;11 +import '../services/mqtt_service.dart' show ConnectionStatus;12 12 13 13 // --- Enums --- 14 14 lib/screens/chat_screen.dart
.. .. @@ -56,6 +56,10 @@ 56 56 bool _isCatchingUp = false; 57 57 bool _screenshotForChat = false; 58 58 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;59 63 60 64 @override 61 65 void initState() { .. .. @@ -66,20 +70,34 @@ 66 70 } 67 71 68 72 Future<void> _initAll() async { 69 - // Load lastSeq BEFORE connecting so catch_up sends the right value73 + // Load persisted state BEFORE connecting70 74 final prefs = await SharedPreferences.getInstance(); 71 75 _lastSeq = prefs.getInt('lastSeq') ?? 0; 76 + // Restore saved session order and active session77 + _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 startup82 + await ref.read(messagesProvider.notifier).switchSession(savedSessionId);83 + }72 84 if (!mounted) return; 73 85 74 86 // Listen for playback state changes to reset play button UI 75 87 // Use a brief delay to avoid race between queue transitions 76 88 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-is92 + } 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 + }83 101 } 84 102 }; 85 103 .. .. @@ -146,9 +164,16 @@ 146 164 }; 147 165 _ws!.onMessage = _handleMessage; 148 166 _ws!.onOpen = () { 167 + _sessionReady = false; // Gate messages until sessions arrive168 + _pendingMessages.clear();149 169 final activeId = ref.read(activeSessionIdProvider); 150 170 _sendCommand('sync', activeId != null ? {'activeSessionId': activeId} : null); 151 - // catch_up is still available during the transition period171 + // 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');152 177 _sendCommand('catch_up', {'lastSeq': _lastSeq}); 153 178 }; 154 179 _ws!.onError = (error) { .. .. @@ -168,6 +193,14 @@ 168 193 } 169 194 170 195 void _handleMessage(Map<String, dynamic> msg) { 196 + final type = msg['type'] as String?;197 + // Sessions and catch_up always process immediately198 + // Content messages (text, voice, image) wait until session is ready199 + if (!_sessionReady && type != 'sessions' && type != 'catch_up' && type != 'status' && type != 'typing') {200 + _pendingMessages.add(msg);201 + return;202 + }203 +171 204 // Track sequence numbers for catch_up protocol 172 205 final seq = msg['seq'] as int?; 173 206 if (seq != null) { .. .. @@ -185,8 +218,6 @@ 185 218 } 186 219 } 187 220 188 - final type = msg['type'] as String?;189 -190 221 switch (type) { 191 222 case 'sessions': 192 223 _handleSessions(msg); .. .. @@ -198,10 +229,20 @@ 198 229 case 'image': 199 230 _handleIncomingImage(msg); 200 231 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 others237 + if (activeId != null && typingSession == activeId) {238 + ref.read(isTypingProvider.notifier).state = typing;239 + }203 240 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 + }205 246 case 'screenshot': 206 247 ref.read(latestScreenshotProvider.notifier).state = 207 248 msg['data'] as String? ?? msg['imageBase64'] as String?; .. .. @@ -231,7 +272,8 @@ 231 272 if (sessionId != null) _incrementUnread(sessionId); 232 273 case 'catch_up': 233 274 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 lower235 277 _lastSeq = serverSeq; 236 278 _saveLastSeq(); 237 279 } .. .. @@ -241,19 +283,91 @@ 241 283 final catchUpMsgs = msg['messages'] as List<dynamic>?; 242 284 if (catchUpMsgs != null && catchUpMsgs.isNotEmpty) { 243 285 _isCatchingUp = true; 286 + final activeId = ref.read(activeSessionIdProvider);244 287 final existing = ref.read(messagesProvider); 245 288 final existingContents = existing 246 289 .where((m) => m.role == MessageRole.assistant) 247 290 .map((m) => m.content) 248 291 .toSet(); 249 292 for (final m in catchUpMsgs) { 250 - final content = (m as Map<String, dynamic>)['content'] as String? ?? '';251 - // Skip if we already have this message locally252 - 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 chat322 + ref.read(messagesProvider.notifier).addMessage(message);323 + } else {324 + // Different session: store + unread badge + toast325 + // Collect for batch storage below to avoid race condition326 + _catchUpPending.putIfAbsent(msgSessionId, () => []).add(message);327 + _incrementUnread(msgSessionId);328 + }329 + existingContents.add(content);255 330 } 256 331 _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 count338 + 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 == 1347 + ? entry.value.first.content348 + : '$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 session366 + 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 + }257 371 } 258 372 case 'pong': 259 373 break; // heartbeat response, ignore .. .. @@ -271,9 +385,11 @@ 271 385 final sessionsJson = msg['sessions'] as List<dynamic>?; 272 386 if (sessionsJson == null) return; 273 387 274 - final sessions = sessionsJson388 + var sessions = sessionsJson275 389 .map((s) => Session.fromJson(s as Map<String, dynamic>)) 276 390 .toList(); 391 + // Apply saved custom order (reordered sessions persist across updates)392 + sessions = _applyCustomOrder(sessions);277 393 ref.read(sessionsProvider.notifier).state = sessions; 278 394 279 395 final activeId = ref.read(activeSessionIdProvider); .. .. @@ -284,6 +400,22 @@ 284 400 ); 285 401 ref.read(activeSessionIdProvider.notifier).state = active.id; 286 402 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 list407 + if (!_sessionReady) {408 + _sessionReady = true;409 + // Request catch_up now that session is set410 + _sendCommand('catch_up', {'lastSeq': _lastSeq});411 + // Drain messages that arrived before sessions list412 + 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 + }287 419 } 288 420 } 289 421 .. .. @@ -420,7 +552,10 @@ 420 552 421 553 // Only add to chat if the Screen button explicitly requested it 422 554 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 + }424 559 return; 425 560 } 426 561 _screenshotForChat = false; .. .. @@ -500,13 +635,15 @@ 500 635 } 501 636 502 637 Future<void> _switchSession(String sessionId) async { 503 - // Stop any playing audio and dismiss keyboard when switching sessions638 + // Stop any playing audio, dismiss keyboard, and clear typing indicator504 639 await AudioService.stopPlayback(); 505 640 setState(() => _playingMessageId = null); 506 641 if (mounted) FocusScope.of(context).unfocus(); 642 + ref.read(isTypingProvider.notifier).state = false;507 643 508 644 ref.read(activeSessionIdProvider.notifier).state = sessionId; 509 645 await ref.read(messagesProvider.notifier).switchSession(sessionId); 646 + SharedPreferences.getInstance().then((p) => p.setString('activeSessionId', sessionId));510 647 511 648 final counts = Map<String, int>.from(ref.read(unreadCountsProvider)); 512 649 counts.remove(sessionId); .. .. @@ -689,7 +826,10 @@ 689 826 // Show caption dialog 690 827 final fileNames = result.files.map((f) => f.name).join(', '); 691 828 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 + }693 833 694 834 // Handle voice caption 695 835 String textCaption = caption; .. .. @@ -751,6 +891,8 @@ 751 891 } 752 892 } 753 893 894 + // Dismiss keyboard after file flow completes895 + if (mounted) FocusManager.instance.primaryFocus?.unfocus();754 896 _scrollToBottom(); 755 897 } 756 898 .. .. @@ -859,7 +1001,10 @@ 859 1001 } 860 1002 861 1003 final caption = await _showCaptionDialog(images.length); 862 - if (caption == null) return; // user cancelled1004 + if (caption == null) {1005 + if (mounted) FocusManager.instance.primaryFocus?.unfocus();1006 + return; // user cancelled1007 + }863 1008 864 1009 // Handle voice caption 865 1010 String textCaption = caption; .. .. @@ -878,39 +1023,38 @@ 878 1023 <String, dynamic>{'data': b64, 'mimeType': 'image/jpeg'} 879 1024 ).toList(); 880 1025 881 - // Create voice bubble first to get messageId for transcript reflection882 - 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 reflection1027 + final firstImageMsg = Message.image(1028 + role: MessageRole.user,1029 + imageBase64: encodedImages[0],1030 + content: textCaption.isNotEmpty ? textCaption : (voiceB64 != null ? '🎤 ...' : ''),1031 + status: MessageStatus.sent,1032 + );892 1033 893 1034 // Send everything as a single atomic bundle 894 1035 _ws?.send({ 895 1036 'type': 'bundle', 896 1037 'caption': textCaption, 897 1038 if (voiceB64 != null) 'audioBase64': voiceB64, 898 - if (voiceMessageId != null) 'voiceMessageId': voiceMessageId,1039 + if (voiceB64 != null) 'voiceMessageId': firstImageMsg.id,899 1040 'attachments': attachments, 900 1041 'sessionId': targetSessionId, 901 1042 }); 902 1043 903 - // Show images in chat locally904 - for (var i = 0; i < encodedImages.length; i++) {1044 + // Show as combined image+caption bubbles1045 + ref.read(messagesProvider.notifier).addMessage(firstImageMsg);1046 + for (var i = 1; i < encodedImages.length; i++) {905 1047 final message = Message.image( 906 1048 role: MessageRole.user, 907 1049 imageBase64: encodedImages[i], 908 - content: i == 0 ? textCaption : '',1050 + content: '',909 1051 status: MessageStatus.sent, 910 1052 ); 911 1053 ref.read(messagesProvider.notifier).addMessage(message); 912 1054 } 913 1055 1056 + // Dismiss keyboard after image flow completes1057 + if (mounted) FocusManager.instance.primaryFocus?.unfocus();914 1058 _scrollToBottom(); 915 1059 } 916 1060 .. .. @@ -1122,6 +1266,30 @@ 1122 1266 final item = sessions.removeAt(oldIndex); 1123 1267 sessions.insert(newIndex, item); 1124 1268 ref.read(sessionsProvider.notifier).state = sessions; 1269 + // Persist custom order AND update cache so next server update preserves it1270 + 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 order1291 + ordered.addAll(byId.values);1292 + return ordered;1125 1293 } 1126 1294 1127 1295 void _refreshSessions() { .. .. @@ -1173,6 +1341,9 @@ 1173 1341 ), 1174 1342 ], 1175 1343 ), 1344 + onDrawerChanged: (isOpened) {1345 + if (isOpened) FocusManager.instance.primaryFocus?.unfocus();1346 + },1176 1347 drawer: SessionDrawer( 1177 1348 sessions: sessions, 1178 1349 activeSessionId: activeSession?.id,
.. .. @@ -192,20 +192,11 @@ 192 192 void _sendKey(String key) { 193 193 _haptic(); 194 194 195 - // Send via WebSocket - the chat screen's WS is in the provider196 - // We need to access the WS through the provider system197 - // For now, send a nav command message195 + // Send via MQTT - the chat screen's MQTT service is in the provider198 196 final activeSessionId = ref.read(activeSessionIdProvider); 199 197 200 - // Build the navigate command201 - // This sends a key press to the AIBroker daemon202 - // which forwards it to the active terminal session203 - // The WS is managed by ChatScreen, so we'll use a message approach204 -205 - // Since we can't directly access the WS from here,206 - // we send through the provider approach - the message will be picked up207 - // 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.209 200 210 201 NavigateNotifier.instance?.sendKey(key, activeSessionId); 211 202 .. .. @@ -228,8 +219,8 @@ 228 219 } 229 220 } 230 221 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.233 224 class NavigateNotifier { 234 225 static NavigateNotifier? instance; 235 226 lib/screens/settings_screen.dart
.. .. @@ -3,7 +3,7 @@ 3 3 4 4 import '../models/server_config.dart'; 5 5 import '../providers/providers.dart'; 6 -import '../services/websocket_service.dart' show ConnectionStatus;6 +import '../services/mqtt_service.dart' show ConnectionStatus;7 7 import '../services/wol_service.dart'; 8 8 import '../theme/app_theme.dart'; 9 9 import '../widgets/status_dot.dart'; .. .. @@ -18,6 +18,7 @@ 18 18 class _SettingsScreenState extends ConsumerState<SettingsScreen> { 19 19 final _formKey = GlobalKey<FormState>(); 20 20 late final TextEditingController _localHostController; 21 + late final TextEditingController _vpnHostController;21 22 late final TextEditingController _remoteHostController; 22 23 late final TextEditingController _portController; 23 24 late final TextEditingController _macController; .. .. @@ -30,6 +31,8 @@ 30 31 final config = ref.read(serverConfigProvider); 31 32 _localHostController = 32 33 TextEditingController(text: config?.localHost ?? ''); 34 + _vpnHostController =35 + TextEditingController(text: config?.vpnHost ?? '');33 36 _remoteHostController = 34 37 TextEditingController(text: config?.host ?? ''); 35 38 _portController = .. .. @@ -43,6 +46,7 @@ 43 46 @override 44 47 void dispose() { 45 48 _localHostController.dispose(); 49 + _vpnHostController.dispose();46 50 _remoteHostController.dispose(); 47 51 _portController.dispose(); 48 52 _macController.dispose(); .. .. @@ -59,6 +63,9 @@ 59 63 localHost: _localHostController.text.trim().isEmpty 60 64 ? null 61 65 : _localHostController.text.trim(), 66 + vpnHost: _vpnHostController.text.trim().isEmpty67 + ? null68 + : _vpnHostController.text.trim(),62 69 macAddress: _macController.text.trim().isEmpty 63 70 ? null 64 71 : _macController.text.trim(), .. .. @@ -153,6 +160,19 @@ 153 160 ), 154 161 const SizedBox(height: 16), 155 162 163 + // VPN address164 + 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 +156 176 // Remote address 157 177 Text('Remote Address', 158 178 style: Theme.of(context).textTheme.bodyMedium), lib/services/audio_service.dart
.. .. @@ -69,10 +69,15 @@ 69 69 70 70 final path = _queue.removeAt(0); 71 71 try { 72 + // Brief pause between tracks — iOS audio player needs time to reset73 + await _player.stop();74 + await Future.delayed(const Duration(milliseconds: 150));72 75 await _player.play(DeviceFileSource(path)); 73 76 _isPlaying = true; 74 77 onPlaybackStateChanged?.call(); 75 - } catch (_) {78 + debugPrint('AudioService: playing next from queue (remaining: ${_queue.length})');79 + } catch (e) {80 + debugPrint('AudioService: queue play failed: $e');76 81 // Skip broken file, try next 77 82 _onTrackComplete(); 78 83 } .. .. @@ -167,13 +172,20 @@ 167 172 if (path == null) return; 168 173 169 174 if (_isPlaying) { 170 - // Already playing — just add to queue, it will play when current finishes175 + // Already playing — add to queue, plays when current finishes171 176 _queue.add(path); 177 + debugPrint('AudioService: queued (queue size: ${_queue.length})');172 178 } else { 173 179 // 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 + }177 189 } 178 190 } 179 191 lib/services/mqtt_service.dart
.. .. @@ -2,6 +2,7 @@ 2 2 import 'dart:convert'; 3 3 import 'dart:io'; 4 4 5 +import 'package:bonsoir/bonsoir.dart';5 6 import 'package:flutter/widgets.dart'; 6 7 import 'package:path_provider/path_provider.dart' as pp; 7 8 import 'package:mqtt_client/mqtt_client.dart'; .. .. @@ -10,8 +11,15 @@ 10 11 import 'package:uuid/uuid.dart'; 11 12 12 13 import '../models/server_config.dart'; 13 -import 'websocket_service.dart' show ConnectionStatus;14 14 import 'wol_service.dart'; 15 +16 +/// Connection status for the MQTT client.17 +enum ConnectionStatus {18 + disconnected,19 + connecting,20 + connected,21 + reconnecting,22 +}15 23 16 24 // Debug log to file (survives release builds) 17 25 Future<void> _mqttLog(String msg) async { .. .. @@ -23,11 +31,11 @@ 23 31 } catch (_) {} 24 32 } 25 33 26 -/// MQTT client for PAILot, replacing WebSocketService.34 +/// MQTT client for PAILot.27 35 /// 28 36 /// Connects to the AIBroker daemon's embedded aedes broker. 29 37 /// Subscribes to all pailot/ topics and dispatches messages 30 -/// through the same callback interface as WebSocketService.38 +/// through the onMessage callback interface.31 39 class MqttService with WidgetsBindingObserver { 32 40 MqttService({required this.config}); 33 41 .. .. @@ -43,12 +51,13 @@ 43 51 final List<String> _seenMsgIdOrder = []; 44 52 static const int _maxSeenIds = 500; 45 53 46 - // Callbacks — same interface as WebSocketService54 + // Callbacks47 55 void Function(ConnectionStatus status)? onStatusChanged; 48 56 void Function(Map<String, dynamic> message)? onMessage; 49 57 void Function()? onOpen; 50 58 void Function()? onClose; 51 59 void Function()? onReconnecting; 60 + void Function()? onResume;52 61 void Function(String error)? onError; 53 62 54 63 ConnectionStatus get status => _status; .. .. @@ -94,29 +103,50 @@ 94 103 } 95 104 96 105 final clientId = await _getClientId(); 97 - final hosts = _getHosts();98 - _mqttLog('MQTT: hosts=${hosts.join(", ")} port=${config.port}');99 106 100 - for (final host in hosts) {107 + // Connection order: local → Bonjour → VPN → remote108 + final attempts = <MapEntry<String, int>>[]; // host → timeout ms109 + if (config.localHost != null && config.localHost!.isNotEmpty) {110 + attempts.add(MapEntry(config.localHost!, 2500));111 + }112 + // Bonjour placeholder — inserted dynamically below113 + 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 first122 + for (final attempt in attempts) {101 123 if (_intentionalClose) return; 102 -103 - _mqttLog('MQTT: trying $host:${config.port}');124 + _mqttLog('MQTT: trying ${attempt.key}:${config.port}');104 125 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;112 127 } 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/remote132 + 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 + }115 145 } 116 146 } 117 147 118 148 // All hosts failed — retry after delay 119 - _mqttLog('MQTT: all hosts failed, retrying in 5s');149 + _mqttLog('MQTT: all attempts failed, retrying in 5s');120 150 _setStatus(ConnectionStatus.reconnecting); 121 151 Future.delayed(const Duration(seconds: 5), () { 122 152 if (!_intentionalClose && _status != ConnectionStatus.connected) { .. .. @@ -125,14 +155,43 @@ 125 155 }); 126 156 } 127 157 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;134 194 } 135 - return [config.host];136 195 } 137 196 138 197 Future<bool> _tryConnect(String host, String clientId, {int timeout = 5000}) async { .. .. @@ -149,9 +208,12 @@ 149 208 client.onAutoReconnect = _onAutoReconnect; 150 209 client.onAutoReconnected = _onAutoReconnected; 151 210 152 - // Persistent session: broker queues QoS 1 messages while client is offline211 + // Clean session: we handle offline delivery ourselves via catch_up protocol.212 + // Persistent sessions cause the broker to flood all queued QoS 1 messages213 + // on reconnect, which overwhelms the client with large voice payloads.153 214 final connMessage = MqttConnectMessage() 154 215 .withClientIdentifier(clientId) 216 + .startClean()155 217 .authenticateAs('pailot', config.mqttToken ?? ''); 156 218 157 219 client.connectionMessage = connMessage; .. .. @@ -268,7 +330,7 @@ 268 330 269 331 /// Route incoming MQTT messages to the onMessage callback. 270 332 /// Translates MQTT topic structure into the flat message format 271 - /// that chat_screen expects (same as WebSocket messages).333 + /// that chat_screen expects.272 334 void _dispatchMessage(String topic, Map<String, dynamic> json) { 273 335 final parts = topic.split('/'); 274 336 .. .. @@ -369,7 +431,6 @@ 369 431 } 370 432 371 433 /// Send a message — routes to the appropriate MQTT topic based on content. 372 - /// Accepts the same message format as WebSocketService.send().373 434 void send(Map<String, dynamic> message) { 374 435 final type = message['type'] as String?; 375 436 final sessionId = message['sessionId'] as String?; .. .. @@ -423,6 +484,7 @@ 423 484 'sessionId': sessionId, 424 485 'caption': message['caption'] ?? '', 425 486 if (message['audioBase64'] != null) 'audioBase64': message['audioBase64'], 487 + if (message['voiceMessageId'] != null) 'voiceMessageId': message['voiceMessageId'],426 488 'attachments': message['attachments'] ?? [], 427 489 'ts': _now(), 428 490 }, MqttQos.atLeastOnce); .. .. @@ -505,11 +567,22 @@ 505 567 void didChangeAppLifecycleState(AppLifecycleState state) { 506 568 switch (state) { 507 569 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 reconnect575 + _mqttLog('MQTT: not connected on resume, reconnecting...');576 + _client = null;577 + _setStatus(ConnectionStatus.reconnecting);509 578 connect(); 579 + } else {580 + // Appears connected — notify listener to fetch missed messages581 + // 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();510 584 } 511 585 case AppLifecycleState.paused: 512 - // Keep connection alive — MQTT handles keepalive natively513 586 break; 514 587 default: 515 588 break; lib/services/websocket_service.dartdeleted 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 - // Callbacks33 - 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 configured61 - 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 > 175 - ? const Duration(milliseconds: 2500)76 - : const Duration(seconds: 5));77 - if (connected) return;78 - } catch (_) {79 - continue;80 - }81 - }82 -83 - // All URLs failed84 - _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 timeout95 - 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 pong123 - 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, ignore130 - }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 seconds149 - _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 seconds160 - _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 max204 - 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 integration273 - @override274 - 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 aggressively283 - break;284 - default:285 - break;286 - }287 - }288 -}lib/widgets/message_bubble.dart
.. .. @@ -4,6 +4,7 @@ 4 4 5 5 import 'package:flutter/material.dart'; 6 6 import 'package:flutter/services.dart'; 7 +import 'package:flutter_markdown/flutter_markdown.dart';7 8 import 'package:intl/intl.dart'; 8 9 9 10 import '../models/message.dart'; .. .. @@ -105,14 +106,17 @@ 105 106 Widget _buildContent(BuildContext context) { 106 107 switch (message.type) { 107 108 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);116 120 117 121 case MessageType.voice: 118 122 return _buildVoiceContent(context); .. .. @@ -120,6 +124,57 @@ 120 124 case MessageType.image: 121 125 return _buildImageContent(context); 122 126 } 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 = isDark133 + ? 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 + );123 178 } 124 179 125 180 Widget _buildVoiceContent(BuildContext context) { .. .. @@ -194,14 +249,17 @@ 194 249 // Transcript 195 250 if (message.content.isNotEmpty) ...[ 196 251 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 + else262 + _buildMarkdown(context),205 263 ], 206 264 ], 207 265 ); lib/widgets/status_dot.dart
.. .. @@ -1,9 +1,9 @@ 1 1 import 'package:flutter/material.dart'; 2 2 3 -import '../services/websocket_service.dart';3 +import '../services/mqtt_service.dart';4 4 import '../theme/app_theme.dart'; 5 5 6 -/// 10px circle indicating WebSocket connection status.6 +/// 10px circle indicating MQTT connection status.7 7 class StatusDot extends StatelessWidget { 8 8 final ConnectionStatus status; 9 9 pubspec.lock
.. .. @@ -73,6 +73,54 @@ 73 73 url: "https://pub.dev" 74 74 source: hosted 75 75 version: "4.3.0" 76 + bonsoir:77 + dependency: "direct main"78 + description:79 + name: bonsoir80 + sha256: "42f2c1eb55e833bcb541dfcb759851da0a703106646a0cf15a16c6de21f4a5a4"81 + url: "https://pub.dev"82 + source: hosted83 + version: "6.0.2"84 + bonsoir_android:85 + dependency: transitive86 + description:87 + name: bonsoir_android88 + sha256: e19728f94a0d9813abf9e2edf644fede008e58ef539865a1be86ac5d8994154e89 + url: "https://pub.dev"90 + source: hosted91 + version: "6.0.1"92 + bonsoir_darwin:93 + dependency: transitive94 + description:95 + name: bonsoir_darwin96 + sha256: e242a03a019fd474be657715826cfc13e43d02c88e46ec5611a20b9d4f72854d97 + url: "https://pub.dev"98 + source: hosted99 + version: "6.0.1"100 + bonsoir_linux:101 + dependency: transitive102 + description:103 + name: bonsoir_linux104 + sha256: "9c326c572c241c6a38ab7a8a5dba27c82917ec12504f84308ce3b5706619e8d3"105 + url: "https://pub.dev"106 + source: hosted107 + version: "6.0.2"108 + bonsoir_platform_interface:109 + dependency: transitive110 + description:111 + name: bonsoir_platform_interface112 + sha256: "3fa0c46b30eb2a2f48be6fa53591a5c0425bf00520be761b61763e58b51814ff"113 + url: "https://pub.dev"114 + source: hosted115 + version: "6.0.1"116 + bonsoir_windows:117 + dependency: transitive118 + description:119 + name: bonsoir_windows120 + sha256: "34c54802baaa2f00e3c4ab7ea46888f2a829876753778e2f40e3f273c3382d34"121 + url: "https://pub.dev"122 + source: hosted123 + version: "6.0.1"76 124 boolean_selector: 77 125 dependency: transitive 78 126 description: .. .. @@ -254,6 +302,14 @@ 254 302 url: "https://pub.dev" 255 303 source: hosted 256 304 version: "6.0.0" 305 + flutter_markdown:306 + dependency: "direct main"307 + description:308 + name: flutter_markdown309 + sha256: "08fb8315236099ff8e90cb87bb2b935e0a724a3af1623000a9cec930468e0f27"310 + url: "https://pub.dev"311 + source: hosted312 + version: "0.7.7+1"257 313 flutter_plugin_android_lifecycle: 258 314 dependency: transitive 259 315 description: .. .. @@ -488,6 +544,14 @@ 488 544 url: "https://pub.dev" 489 545 source: hosted 490 546 version: "1.3.0" 547 + markdown:548 + dependency: transitive549 + description:550 + name: markdown551 + sha256: ee85086ad7698b42522c6ad42fe195f1b9898e4d974a1af4576c1a3a176cada9552 + url: "https://pub.dev"553 + source: hosted554 + version: "7.3.1"491 555 matcher: 492 556 dependency: transitive 493 557 description: pubspec.yaml
.. .. @@ -30,6 +30,8 @@ 30 30 uuid: ^4.5.1 31 31 collection: ^1.19.1 32 32 file_picker: ^10.3.10 33 + flutter_markdown: ^0.7.7+134 + bonsoir: ^6.0.233 35 34 36 dev_dependencies: 35 37 flutter_test: