8d1f94e02e927fcb80d170fc85d13a091e5dc304..489419f3b133c4725d1ffdf5fb320898a55e9544
8 days ago Matthias Nott
fix: increase MQTT keepalive to 120s to prevent iOS network throttle drops
489419 diff | tree
8 days ago Matthias Nott
fix: faster notification tap - immediate catch_up, reduced sync delay to 200ms
52ecd9 diff | tree
8 days ago Matthias Nott
fix: re-enable autoReconnect, simple resume handler, stable connection life...
8b82df diff | tree
8 days ago Matthias Nott
fix: always rebuild index from log on startup, flush index after every append
a4f64f diff | tree
8 days ago Matthias Nott
fix: disable MQTT autoReconnect to prevent connection flickering on resume
e0606b diff | tree
8 days ago Matthias Nott
fix: markdown links open in browser/app instead of copying to clipboard
d420bf diff | tree
8 days ago Matthias Nott
feat: rewrite message store - append-only log, sync routing, eliminates asy...
06bb73 diff | tree
8 days ago Matthias Nott
docs: message system rewrite spec - append-only log, sync routing, state ma...
66e5a4 diff | tree
8 days ago Matthias Nott
fix: single pailot/out topic, per-session file locks, merge protection, res...
6cbbea diff | tree
8 days ago Matthias Nott
fix: immediate disk writes, notification tap skip same session, catch_up tr...
90fc31 diff | tree
8 days ago Matthias Nott
fix: resume message reload, direct session writes, MQTT trace pipe to server
1bf6e7 diff | tree
8 days ago Matthias Nott
fix: explicit per-session MQTT subscriptions, lifecycle observer, resume re...
34b82f diff | tree
8 days ago Matthias Nott
feat: animated splash screen with P logo reveal and plane fly-in
525030 diff | tree
2 files added
11 files modified
changed files
Notes/SPEC-message-rewrite.md patch | view | blame | history
ios/Podfile.lock patch | view | blame | history
lib/main.dart patch | view | blame | history
lib/providers/providers.dart patch | view | blame | history
lib/screens/chat_screen.dart patch | view | blame | history
lib/screens/splash_screen.dart patch | view | blame | history
lib/services/message_store.dart patch | view | blame | history
lib/services/mqtt_service.dart patch | view | blame | history
lib/services/trace_service.dart patch | view | blame | history
lib/widgets/message_bubble.dart patch | view | blame | history
macos/Flutter/GeneratedPluginRegistrant.swift patch | view | blame | history
pubspec.lock patch | view | blame | history
pubspec.yaml patch | view | blame | history
Notes/SPEC-message-rewrite.md
....@@ -0,0 +1,141 @@
1
+# PAILot Message System Rewrite Spec
2
+
3
+## Problem
4
+
5
+The current message handling has accumulated race conditions from incremental fixes:
6
+- `switchSession` is async — messages arriving during the async gap get overwritten by `loadAll`
7
+- iOS kills the MQTT socket in background but the client reports "connected"
8
+- File corruption from concurrent read/write on the same session file
9
+- Notification tap triggers `switchSession` which reloads from disk, losing in-memory messages
10
+- `addMessage` and `switchSession` compete on the same state and disk files
11
+
12
+## Architecture: Single Message Bus
13
+
14
+### 1. One MQTT topic for everything
15
+
16
+- **Server**: publish ALL outbound messages to `pailot/out` (DONE)
17
+- **App**: subscribe to `pailot/out` + `pailot/sessions` + `pailot/status` + `pailot/control/out`
18
+- Every message carries `type`, `sessionId`, `seq` in the payload
19
+- Client routes by `sessionId` — never by topic
20
+
21
+### 2. MessageStore redesign
22
+
23
+Replace per-session debounced saves with a single append-only log:
24
+
25
+```
26
+~/.../messages/log.jsonl — append-only, one JSON line per message
27
+~/.../messages/index.json — { sessionId: [lineNumbers] } for fast lookup
28
+```
29
+
30
+**Operations:**
31
+- `append(message)` — append one line to log.jsonl (sync, atomic, no race)
32
+- `loadSession(sessionId)` — read index, seek to lines, return messages
33
+- `compact()` — rewrite log removing old messages (run on app start, not during use)
34
+
35
+**Benefits:**
36
+- No per-session files — no file-level races
37
+- Append-only — no read-modify-write cycle
38
+- No debounce needed — each append is a single `writeAsStringSync` with `mode: FileMode.append`
39
+
40
+### 3. Connection state machine
41
+
42
+```
43
+States: disconnected → connecting → connected → suspended → reconnecting → connected
44
+ ↑ |
45
+ └──────────────────────────┘
46
+
47
+Transitions:
48
+- App launch: disconnected → connecting → connected
49
+- App background: connected → suspended (keep client, mark state)
50
+- App resume: suspended → reconnecting → connected (always force-reconnect)
51
+- Connection lost: connected → reconnecting → connected (autoReconnect)
52
+- User disconnect: any → disconnected
53
+```
54
+
55
+**Key rule:** In `suspended` state, do NOT process buffered MQTT messages. Process them only after reconnect + catch_up completes. This prevents the race where a buffered message is added to state before `loadAll` overwrites it.
56
+
57
+### 4. Message routing (synchronous, no async gaps)
58
+
59
+```dart
60
+void _onMessage(Map<String, dynamic> json) {
61
+ final type = json['type'] as String?;
62
+ final sessionId = json['sessionId'] as String?;
63
+ final currentId = _currentSessionId;
64
+
65
+ if (type == 'text' || type == 'voice' || type == 'image') {
66
+ // Append to log immediately (sync)
67
+ MessageStore.append(Message.fromMqtt(json));
68
+
69
+ // Display only if for current session
70
+ if (sessionId == currentId) {
71
+ _messages.add(Message.fromMqtt(json));
72
+ notifyListeners(); // or setState
73
+ } else {
74
+ _incrementUnread(sessionId);
75
+ }
76
+ }
77
+}
78
+```
79
+
80
+No async. No switchSession during message handling. No race.
81
+
82
+### 5. Session switching
83
+
84
+```dart
85
+void switchSession(String sessionId) {
86
+ _currentSessionId = sessionId;
87
+ _messages = MessageStore.loadSession(sessionId); // sync read from index
88
+ notifyListeners();
89
+}
90
+```
91
+
92
+Synchronous. No async gap. No race with incoming messages.
93
+
94
+### 6. Resume flow
95
+
96
+```dart
97
+void onResume() {
98
+ state = suspended;
99
+ // Kill old client (disable autoReconnect first)
100
+ _client?.autoReconnect = false;
101
+ _client?.disconnect();
102
+ _client = null;
103
+
104
+ // Fast reconnect to last host
105
+ await _fastReconnect(connectedHost);
106
+
107
+ // Now process: sync → sessions → catch_up
108
+ // catch_up messages go through same _onMessage path (append + display if current)
109
+ state = connected;
110
+}
111
+```
112
+
113
+### 7. Notification tap
114
+
115
+```dart
116
+void onNotificationTap(String sessionId) {
117
+ if (sessionId != _currentSessionId) {
118
+ switchSession(sessionId); // sync, no async
119
+ }
120
+ // Message is already in the log from MQTT delivery or catch_up
121
+ // switchSession loads it
122
+}
123
+```
124
+
125
+## Migration path
126
+
127
+1. Create `MessageStoreV2` with append-only log
128
+2. Create `ConnectionStateMachine` with explicit states
129
+3. Rewrite `_handleIncomingMessage` to use sync append
130
+4. Rewrite `switchSession` to be sync
131
+5. Remove debounced saves, per-session file locks, merge protection
132
+6. Test each step before moving to next
133
+
134
+## What stays the same
135
+
136
+- MQTT transport (mqtt_client package)
137
+- aedes broker with loopback client on server
138
+- Single `pailot/out` topic
139
+- APNs push notifications
140
+- Splash screen
141
+- UI components (chat bubbles, drawer, settings)
ios/Podfile.lock
....@@ -69,6 +69,8 @@
6969 - Flutter
7070 - FlutterMacOS
7171 - SwiftyGif (5.4.5)
72
+ - url_launcher_ios (0.0.1):
73
+ - Flutter
7274 - vibration (1.7.5):
7375 - Flutter
7476
....@@ -88,6 +90,7 @@
8890 - record_ios (from `.symlinks/plugins/record_ios/ios`)
8991 - share_plus (from `.symlinks/plugins/share_plus/ios`)
9092 - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
93
+ - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
9194 - vibration (from `.symlinks/plugins/vibration/ios`)
9295
9396 SPEC REPOS:
....@@ -128,6 +131,8 @@
128131 :path: ".symlinks/plugins/share_plus/ios"
129132 shared_preferences_foundation:
130133 :path: ".symlinks/plugins/shared_preferences_foundation/darwin"
134
+ url_launcher_ios:
135
+ :path: ".symlinks/plugins/url_launcher_ios/ios"
131136 vibration:
132137 :path: ".symlinks/plugins/vibration/ios"
133138
....@@ -151,6 +156,7 @@
151156 share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
152157 shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
153158 SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
159
+ url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
154160 vibration: 8e2f50fc35bb736f9eecb7dd9f7047fbb6a6e888
155161
156162 PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
lib/main.dart
....@@ -8,6 +8,7 @@
88 import 'providers/providers.dart';
99 import 'services/audio_service.dart';
1010 import 'services/purchase_service.dart';
11
+import 'screens/splash_screen.dart';
1112
1213 void main() async {
1314 WidgetsFlutterBinding.ensureInitialized();
....@@ -48,6 +49,7 @@
4849
4950 class _PAILotAppState extends ConsumerState<PAILotApp> {
5051 late final GoRouter _router;
52
+ bool _showSplash = true;
5153
5254 @override
5355 void initState() {
....@@ -78,6 +80,17 @@
7880 themeMode: themeMode,
7981 routerConfig: _router,
8082 debugShowCheckedModeBanner: false,
83
+ builder: (context, child) {
84
+ return Stack(
85
+ children: [
86
+ child ?? const SizedBox.shrink(),
87
+ if (_showSplash)
88
+ SplashScreen(onComplete: () {
89
+ if (mounted) setState(() => _showSplash = false);
90
+ }),
91
+ ],
92
+ );
93
+ },
8194 );
8295 }
8396 }
lib/providers/providers.dart
....@@ -9,6 +9,7 @@
99 import '../models/server_config.dart';
1010 import '../models/session.dart';
1111 import '../services/message_store.dart';
12
+import '../services/trace_service.dart';
1213 import '../services/mqtt_service.dart' show ConnectionStatus;
1314 import '../services/navigate_notifier.dart';
1415
....@@ -97,57 +98,48 @@
9798
9899 String? get currentSessionId => _currentSessionId;
99100
100
- /// Switch to a new session and load its messages.
101
- Future<void> switchSession(String sessionId) async {
102
- // Force-flush current session to disk before switching
103
- if (_currentSessionId != null && state.isNotEmpty) {
104
- MessageStore.save(_currentSessionId!, state);
105
- await MessageStore.flush();
101
+ /// Switch to a session. SYNCHRONOUS — no async gap, no race with incoming
102
+ /// messages. MessageStoreV2.loadSession reads from the in-memory index.
103
+ void switchSession(String sessionId) {
104
+ if (_currentSessionId == sessionId) {
105
+ TraceService.instance.addTrace(
106
+ 'switchSession SKIP', 'already on ${sessionId.substring(0, 8)}');
107
+ return;
106108 }
107
-
109
+ TraceService.instance.addTrace(
110
+ 'switchSession',
111
+ 'from=${_currentSessionId?.substring(0, 8) ?? "null"}(${state.length}) → ${sessionId.substring(0, 8)}',
112
+ );
108113 _currentSessionId = sessionId;
109
- final messages = await MessageStore.loadAll(sessionId);
110
- state = messages;
114
+ state = MessageStoreV2.loadSession(sessionId);
111115 }
112116
113
- /// Add a message to the current session.
117
+ /// Add a message to the current session (display + append-only persist).
114118 void addMessage(Message message) {
115119 state = [...state, message];
116120 if (_currentSessionId != null) {
117
- MessageStore.save(_currentSessionId!, state);
121
+ MessageStoreV2.append(_currentSessionId!, message);
118122 }
119123 }
120124
121
- /// Update a message by ID.
125
+ /// Update a message by ID (in-memory only — patch is not persisted to log).
122126 void updateMessage(String id, Message Function(Message) updater) {
123127 state = state.map((m) => m.id == id ? updater(m) : m).toList();
124
- if (_currentSessionId != null) {
125
- MessageStore.save(_currentSessionId!, state);
126
- }
127128 }
128129
129
- /// Remove a message by ID.
130
+ /// Remove a message by ID (in-memory only).
130131 void removeMessage(String id) {
131132 state = state.where((m) => m.id != id).toList();
132
- if (_currentSessionId != null) {
133
- MessageStore.save(_currentSessionId!, state);
134
- }
135133 }
136134
137
- /// Remove all messages matching a predicate.
135
+ /// Remove all messages matching a predicate (in-memory only).
138136 void removeWhere(bool Function(Message) test) {
139137 state = state.where((m) => !test(m)).toList();
140
- if (_currentSessionId != null) {
141
- MessageStore.save(_currentSessionId!, state);
142
- }
143138 }
144139
145
- /// Clear all messages for the current session.
140
+ /// Clear all messages for the current session (in-memory only).
146141 void clearMessages() {
147142 state = [];
148
- if (_currentSessionId != null) {
149
- MessageStore.save(_currentSessionId!, state);
150
- }
151143 }
152144
153145 void updateContent(String messageId, String content) {
....@@ -168,22 +160,6 @@
168160 else
169161 m,
170162 ];
171
- if (_currentSessionId != null) {
172
- MessageStore.save(_currentSessionId!, state);
173
- }
174
- }
175
-
176
- /// Load more (older) messages for pagination.
177
- Future<void> loadMore() async {
178
- if (_currentSessionId == null) return;
179
- final older = await MessageStore.load(
180
- _currentSessionId!,
181
- offset: state.length,
182
- limit: 50,
183
- );
184
- if (older.isNotEmpty) {
185
- state = [...older, ...state];
186
- }
187163 }
188164 }
189165
lib/screens/chat_screen.dart
....@@ -65,13 +65,15 @@
6565 String? _playingMessageId;
6666 int _lastSeq = 0;
6767 bool _isCatchingUp = false;
68
+ bool _catchUpReceived = false;
6869 bool _screenshotForChat = false;
6970 // FIFO dedup queue: O(1) eviction by removing from front when over cap.
7071 final List<int> _seenSeqsList = [];
7172 final Set<int> _seenSeqs = {};
7273 bool _sessionReady = false;
7374 final List<Map<String, dynamic>> _pendingMessages = [];
74
- final Map<String, List<Message>> _catchUpPending = {};
75
+ // _catchUpPending removed: cross-session catch_up messages are now appended
76
+ // synchronously via MessageStoreV2.append() in the catch_up handler.
7577 List<String>? _cachedSessionOrder;
7678 Timer? _typingTimer;
7779 bool _unreadCountsLoaded = false;
....@@ -85,6 +87,9 @@
8587 }
8688
8789 Future<void> _initAll() async {
90
+ // Initialize append-only message store (reads log, rebuilds index, compacts).
91
+ await MessageStoreV2.initialize();
92
+
8893 // Load persisted state BEFORE connecting
8994 final prefs = await SharedPreferences.getInstance();
9095 _lastSeq = prefs.getInt('lastSeq') ?? 0;
....@@ -103,8 +108,8 @@
103108 final savedSessionId = prefs.getString('activeSessionId');
104109 if (savedSessionId != null && mounted) {
105110 ref.read(activeSessionIdProvider.notifier).state = savedSessionId;
106
- // Load messages for the restored session so chat isn't empty on startup
107
- await ref.read(messagesProvider.notifier).switchSession(savedSessionId);
111
+ // Synchronous: no async gap between load and any arriving messages.
112
+ ref.read(messagesProvider.notifier).switchSession(savedSessionId);
108113 }
109114 if (!mounted) return;
110115
....@@ -164,14 +169,11 @@
164169 _persistUnreadCounts(counts);
165170 }
166171
172
+ // ignore: unused_field
167173 bool _isLoadingMore = false;
168174 void _onScroll() {
169
- if (!_isLoadingMore &&
170
- _scrollController.position.pixels >=
171
- _scrollController.position.maxScrollExtent - 100) {
172
- _isLoadingMore = true;
173
- ref.read(messagesProvider.notifier).loadMore().then((_) => _isLoadingMore = false);
174
- }
175
+ // Pagination removed: all messages are loaded synchronously on session
176
+ // switch via the in-memory index. Nothing to do on scroll.
175177 }
176178
177179 // Helper: send a command to the gateway in the expected format
....@@ -215,18 +217,29 @@
215217 _ws!.onOpen = () {
216218 _sessionReady = false; // Gate messages until sessions arrive
217219 _pendingMessages.clear();
218
- final activeId = ref.read(activeSessionIdProvider);
219
- _sendCommand('sync', activeId != null ? {'activeSessionId': activeId} : null);
220
- // catch_up is sent after sessions arrive (in _handleSessions)
221
-
222
- // Re-register APNs token after reconnect so daemon always has a fresh token
223
- _push?.onMqttConnected();
220
+ // Delay sync slightly to let broker acknowledge our subscriptions first.
221
+ // Without this, the catch_up response arrives before pailot/control/out
222
+ // subscription is active, and the message is lost.
223
+ Future.delayed(const Duration(milliseconds: 200), () {
224
+ if (!mounted) return;
225
+ final activeId = ref.read(activeSessionIdProvider);
226
+ _sendCommand('sync', activeId != null ? {'activeSessionId': activeId} : null);
227
+ _push?.onMqttConnected();
228
+ });
224229 };
225230 _ws!.onResume = () {
226
- // App came back from background with connection still alive.
227
- // Send catch_up to fetch any messages missed during suspend.
228
- _chatLog('onResume: sending catch_up with lastSeq=$_lastSeq');
231
+ // App came back from background. The in-memory state already has
232
+ // any messages received while suspended (addMessage was called).
233
+ // Just rebuild the UI and scroll to bottom to show them.
234
+ _chatLog('onResume: rebuilding UI and sending catch_up');
229235 _sendCommand('catch_up', {'lastSeq': _lastSeq});
236
+ if (mounted) {
237
+ setState(() {});
238
+ // Scroll after the frame rebuilds
239
+ WidgetsBinding.instance.addPostFrameCallback((_) {
240
+ if (mounted) _scrollToBottom();
241
+ });
242
+ }
230243 };
231244 _ws!.onError = (error) {
232245 debugPrint('MQTT error: $error');
....@@ -243,13 +256,19 @@
243256
244257 await _ws!.connect();
245258
259
+ // Attach MQTT to trace service for auto-publishing logs to server
260
+ TraceService.instance.attachMqtt(_ws!);
261
+
246262 // Initialize push notifications after MQTT is set up so token can be
247263 // sent immediately if already connected.
248264 _push = PushService(mqttService: _ws!);
249265 _push!.onNotificationTap = (data) {
250
- // If notification carried a sessionId, switch to that session
251266 final sessionId = data['sessionId'] as String?;
252
- if (sessionId != null && mounted) {
267
+ final activeId = ref.read(activeSessionIdProvider);
268
+ // Immediately request catch_up — don't wait for the sync flow.
269
+ // The message is already in the server queue.
270
+ _sendCommand('catch_up', {'lastSeq': _lastSeq});
271
+ if (sessionId != null && sessionId != activeId && mounted) {
253272 _switchSession(sessionId);
254273 }
255274 };
....@@ -352,24 +371,30 @@
352371 final sessionId = msg['sessionId'] as String?;
353372 if (sessionId != null) _incrementUnread(sessionId);
354373 case 'catch_up':
374
+ _catchUpReceived = true;
355375 final serverSeq = msg['serverSeq'] as int?;
356376 if (serverSeq != null) {
357377 // Always sync to server's seq — if server restarted, its seq may be lower
358378 _lastSeq = serverSeq;
359379 _saveLastSeq();
360380 }
361
- // Merge catch_up messages: only add messages not already in local storage.
362
- // We check by content match against existing messages to avoid duplicates
363
- // while still picking up messages that arrived while the app was backgrounded.
381
+ // Merge catch_up messages: only add messages not already displayed.
382
+ // Dedup by content to avoid showing messages already in the UI.
364383 final catchUpMsgs = msg['messages'] as List<dynamic>?;
365384 if (catchUpMsgs != null && catchUpMsgs.isNotEmpty) {
366385 _isCatchingUp = true;
367386 final activeId = ref.read(activeSessionIdProvider);
387
+ final currentId = ref.read(messagesProvider.notifier).currentSessionId;
368388 final existing = ref.read(messagesProvider);
369389 final existingContents = existing
370390 .where((m) => m.role == MessageRole.assistant)
371391 .map((m) => m.content)
372392 .toSet();
393
+
394
+ // Collect cross-session sessions that received messages (for toasts).
395
+ final crossSessionCounts = <String, int>{};
396
+ final crossSessionPreviews = <String, String>{};
397
+
373398 for (final m in catchUpMsgs) {
374399 final map = m as Map<String, dynamic>;
375400 final msgType = map['type'] as String? ?? 'text';
....@@ -398,51 +423,46 @@
398423 );
399424 }
400425
401
- if (msgSessionId == null || msgSessionId == activeId) {
402
- // Active session or no session: add directly to chat
426
+ _chatLog('catch_up msg: session=${msgSessionId?.substring(0, 8) ?? "NULL"} active=${activeId?.substring(0, 8)} content="${content.substring(0, content.length.clamp(0, 40))}"');
427
+
428
+ if (msgSessionId == null || msgSessionId == currentId) {
429
+ // Active session or no session: add to UI (addMessage also appends to log).
403430 ref.read(messagesProvider.notifier).addMessage(message);
404431 } else {
405
- // Different session: store + unread badge + toast
406
- // Collect for batch storage below to avoid race condition
407
- _catchUpPending.putIfAbsent(msgSessionId, () => []).add(message);
432
+ // Cross-session: synchronous append — no race condition.
433
+ MessageStoreV2.append(msgSessionId, message);
408434 _incrementUnread(msgSessionId);
435
+ crossSessionCounts[msgSessionId] = (crossSessionCounts[msgSessionId] ?? 0) + 1;
436
+ crossSessionPreviews.putIfAbsent(msgSessionId, () => content);
409437 }
410438 existingContents.add(content);
411439 }
440
+
412441 _isCatchingUp = false;
413442 _scrollToBottom();
414
- // Batch-store cross-session messages (sequential to avoid race condition)
415
- if (_catchUpPending.isNotEmpty) {
416
- final pending = Map<String, List<Message>>.from(_catchUpPending);
417
- _catchUpPending.clear();
418
- // Show one toast per session with message count
419
- if (mounted) {
420
- final sessions = ref.read(sessionsProvider);
421
- for (final entry in pending.entries) {
422
- final session = sessions.firstWhere(
423
- (s) => s.id == entry.key,
424
- orElse: () => Session(id: entry.key, index: 0, name: 'Unknown', type: 'claude'),
425
- );
426
- final count = entry.value.length;
427
- final preview = count == 1
428
- ? entry.value.first.content
429
- : '$count messages';
430
- ToastManager.show(
431
- context,
432
- sessionName: session.name,
433
- preview: preview.length > 100 ? '${preview.substring(0, 100)}...' : preview,
434
- onTap: () => _switchSession(entry.key),
435
- );
436
- }
443
+
444
+ // Show one toast per cross-session that received messages.
445
+ if (crossSessionCounts.isNotEmpty && mounted) {
446
+ final sessions = ref.read(sessionsProvider);
447
+ for (final entry in crossSessionCounts.entries) {
448
+ final sid = entry.key;
449
+ final count = entry.value;
450
+ final session = sessions.firstWhere(
451
+ (s) => s.id == sid,
452
+ orElse: () => Session(id: sid, index: 0, name: 'Unknown', type: 'claude'),
453
+ );
454
+ final preview = count == 1
455
+ ? (crossSessionPreviews[sid] ?? '')
456
+ : '$count messages';
457
+ ToastManager.show(
458
+ context,
459
+ sessionName: session.name,
460
+ preview: preview.length > 100 ? '${preview.substring(0, 100)}...' : preview,
461
+ onTap: () => _switchSession(sid),
462
+ );
437463 }
438
- () async {
439
- for (final entry in pending.entries) {
440
- final existing = await MessageStore.loadAll(entry.key);
441
- MessageStore.save(entry.key, [...existing, ...entry.value]);
442
- await MessageStore.flush();
443
- }
444
- }();
445464 }
465
+
446466 // Clear unread for active session
447467 if (activeId != null) {
448468 final counts = Map<String, int>.from(ref.read(unreadCountsProvider));
....@@ -480,6 +500,7 @@
480500 orElse: () => sessions.first,
481501 );
482502 ref.read(activeSessionIdProvider.notifier).state = active.id;
503
+ // Synchronous session switch — no async gap.
483504 ref.read(messagesProvider.notifier).switchSession(active.id);
484505 SharedPreferences.getInstance().then((p) => p.setString('activeSessionId', active.id));
485506 }
....@@ -500,7 +521,7 @@
500521 }
501522 }
502523
503
- Future<void> _handleIncomingMessage(Map<String, dynamic> msg) async {
524
+ void _handleIncomingMessage(Map<String, dynamic> msg) {
504525 final sessionId = msg['sessionId'] as String?;
505526 final content = msg['content'] as String? ??
506527 msg['text'] as String? ??
....@@ -517,14 +538,16 @@
517538 status: MessageStatus.sent,
518539 );
519540
520
- final activeId = ref.read(activeSessionIdProvider);
521
- if (sessionId != null && sessionId != activeId) {
522
- // Store message for the other session so it's there when user switches
541
+ // Use currentSessionId from notifier (what's actually loaded in the provider),
542
+ // not activeSessionIdProvider (can be stale after background resume).
543
+ final currentId = ref.read(messagesProvider.notifier).currentSessionId;
544
+ if (sessionId != null && sessionId != currentId) {
545
+ // Append directly to the log for the target session — synchronous, no race.
523546 TraceService.instance.addTrace(
524547 'message stored for session',
525548 'sessionId=${sessionId.substring(0, sessionId.length.clamp(0, 8))}, toast shown',
526549 );
527
- await _storeForSession(sessionId, message);
550
+ MessageStoreV2.append(sessionId, message);
528551 _incrementUnread(sessionId);
529552 final sessions = ref.read(sessionsProvider);
530553 final session = sessions.firstWhere(
....@@ -591,12 +614,13 @@
591614 duration: duration,
592615 );
593616
594
- final activeId = ref.read(activeSessionIdProvider);
595
- _chatLog('voice: sessionId=$sessionId activeId=$activeId audioPath=$savedAudioPath content="${content.substring(0, content.length.clamp(0, 30))}"');
596
- if (sessionId != null && sessionId != activeId) {
597
- _chatLog('voice: cross-session, storing for $sessionId');
598
- await _storeForSession(sessionId, storedMessage);
599
- _chatLog('voice: stored, incrementing unread');
617
+ final currentId = ref.read(messagesProvider.notifier).currentSessionId;
618
+ _chatLog('voice: sessionId=$sessionId currentId=$currentId audioPath=$savedAudioPath content="${content.substring(0, content.length.clamp(0, 30))}"');
619
+ if (sessionId != null && sessionId != currentId) {
620
+ _chatLog('voice: cross-session, appending to store for $sessionId');
621
+ // Synchronous append — no async gap, no race condition.
622
+ MessageStoreV2.append(sessionId, storedMessage);
623
+ _chatLog('voice: appended, incrementing unread');
600624 _incrementUnread(sessionId);
601625 final sessions = ref.read(sessionsProvider);
602626 final session = sessions.firstWhere(
....@@ -662,10 +686,10 @@
662686 status: MessageStatus.sent,
663687 );
664688
665
- // Cross-session routing: store for target session if not active
666
- final activeId = ref.read(activeSessionIdProvider);
667
- if (sessionId != null && sessionId != activeId) {
668
- _storeForSession(sessionId, message);
689
+ // Cross-session routing: append to log for target session if not currently loaded.
690
+ final currentId = ref.read(messagesProvider.notifier).currentSessionId;
691
+ if (sessionId != null && sessionId != currentId) {
692
+ MessageStoreV2.append(sessionId, message);
669693 _incrementUnread(sessionId);
670694 return;
671695 }
....@@ -675,51 +699,19 @@
675699 _scrollToBottom();
676700 }
677701
678
- /// Store a message for a non-active session so it persists when the user switches to it.
679
- Future<void> _storeForSession(String sessionId, Message message) async {
680
- final existing = await MessageStore.loadAll(sessionId);
681
- _chatLog('storeForSession: $sessionId existing=${existing.length} adding type=${message.type.name} content="${message.content.substring(0, message.content.length.clamp(0, 30))}" audioUri=${message.audioUri != null ? "set(${message.audioUri!.length})" : "null"}');
682
- MessageStore.save(sessionId, [...existing, message]);
683
- await MessageStore.flush();
684
- // Verify
685
- final verify = await MessageStore.loadAll(sessionId);
686
- _chatLog('storeForSession: verified ${verify.length} messages after save');
702
+ /// Superseded by MessageStoreV2.append() — call sites now use the synchronous
703
+ /// append directly. Kept as dead code until all callers are confirmed removed.
704
+ // ignore: unused_element
705
+ void _storeForSession(String sessionId, Message message) {
706
+ MessageStoreV2.append(sessionId, message);
687707 }
688708
689
- /// Update a transcript for a message stored on disk (not in the active session).
690
- /// Scans all session files to find the message by ID, updates content, and saves.
709
+ /// With the append-only log, transcript updates for cross-session messages
710
+ /// are not patched back to disk (the append-only design doesn't support
711
+ /// in-place edits). The transcript is updated in-memory if the message is
712
+ /// in the active session. Cross-session transcript updates are a no-op.
691713 Future<void> _updateTranscriptOnDisk(String messageId, String content) async {
692
- try {
693
- final dir = await getApplicationDocumentsDirectory();
694
- final msgDir = Directory('${dir.path}/messages');
695
- if (!await msgDir.exists()) return;
696
-
697
- await for (final entity in msgDir.list()) {
698
- if (entity is! File || !entity.path.endsWith('.json')) continue;
699
-
700
- final jsonStr = await entity.readAsString();
701
- final List<dynamic> jsonList = jsonDecode(jsonStr) as List<dynamic>;
702
- bool found = false;
703
-
704
- final updated = jsonList.map((j) {
705
- final map = j as Map<String, dynamic>;
706
- if (map['id'] == messageId) {
707
- found = true;
708
- return {...map, 'content': content};
709
- }
710
- return map;
711
- }).toList();
712
-
713
- if (found) {
714
- await entity.writeAsString(jsonEncode(updated));
715
- _chatLog('transcript: updated messageId=$messageId on disk in ${entity.path.split('/').last}');
716
- return;
717
- }
718
- }
719
- _chatLog('transcript: messageId=$messageId not found on disk');
720
- } catch (e) {
721
- _chatLog('transcript: disk update error=$e');
722
- }
714
+ _chatLog('transcript: cross-session update for messageId=$messageId — in-memory only (append-only log)');
723715 }
724716
725717 void _incrementUnread(String sessionId) {
....@@ -748,7 +740,8 @@
748740 ref.read(isTypingProvider.notifier).state = false;
749741
750742 ref.read(activeSessionIdProvider.notifier).state = sessionId;
751
- await ref.read(messagesProvider.notifier).switchSession(sessionId);
743
+ // Synchronous — no async gap between session switch and incoming messages.
744
+ ref.read(messagesProvider.notifier).switchSession(sessionId);
752745 SharedPreferences.getInstance().then((p) => p.setString('activeSessionId', sessionId));
753746
754747 final counts = Map<String, int>.from(ref.read(unreadCountsProvider));
lib/screens/splash_screen.dart
....@@ -0,0 +1,311 @@
1
+import 'dart:math' as math;
2
+import 'package:flutter/material.dart';
3
+
4
+/// PAILot animated splash screen.
5
+///
6
+/// Phase 1 (~0-1500ms): The P logo reveals itself progressively from top to
7
+/// bottom, as if being drawn by an invisible pen.
8
+///
9
+/// Phase 2 (~1000-1700ms): A paper airplane flies in from the left, arcing
10
+/// across the P.
11
+///
12
+/// After animation completes the [onComplete] callback fires.
13
+class SplashScreen extends StatefulWidget {
14
+ final VoidCallback onComplete;
15
+
16
+ const SplashScreen({super.key, required this.onComplete});
17
+
18
+ @override
19
+ State<SplashScreen> createState() => _SplashScreenState();
20
+}
21
+
22
+class _SplashScreenState extends State<SplashScreen>
23
+ with TickerProviderStateMixin {
24
+ late final AnimationController _drawController;
25
+ late final AnimationController _planeController;
26
+ late final AnimationController _fadeController;
27
+
28
+ static const Duration _drawDuration = Duration(milliseconds: 1500);
29
+ static const Duration _planeDuration = Duration(milliseconds: 700);
30
+ static const Duration _fadeDuration = Duration(milliseconds: 300);
31
+
32
+ @override
33
+ void initState() {
34
+ super.initState();
35
+
36
+ _drawController =
37
+ AnimationController(vsync: this, duration: _drawDuration);
38
+ _planeController =
39
+ AnimationController(vsync: this, duration: _planeDuration);
40
+ _fadeController =
41
+ AnimationController(vsync: this, duration: _fadeDuration);
42
+
43
+ _startSequence();
44
+ }
45
+
46
+ Future<void> _startSequence() async {
47
+ await Future.delayed(const Duration(milliseconds: 50));
48
+ if (!mounted) return;
49
+
50
+ _drawController.forward();
51
+
52
+ await Future.delayed(const Duration(milliseconds: 1000));
53
+ if (!mounted) return;
54
+ _planeController.forward();
55
+
56
+ await Future.delayed(const Duration(milliseconds: 800));
57
+ if (!mounted) return;
58
+
59
+ await _fadeController.forward();
60
+ if (!mounted) return;
61
+ widget.onComplete();
62
+ }
63
+
64
+ @override
65
+ void dispose() {
66
+ _drawController.dispose();
67
+ _planeController.dispose();
68
+ _fadeController.dispose();
69
+ super.dispose();
70
+ }
71
+
72
+ @override
73
+ Widget build(BuildContext context) {
74
+ return Scaffold(
75
+ backgroundColor: const Color(0xFF111217),
76
+ body: FadeTransition(
77
+ opacity: Tween<double>(begin: 1.0, end: 0.0).animate(
78
+ CurvedAnimation(parent: _fadeController, curve: Curves.easeIn),
79
+ ),
80
+ child: Center(
81
+ child: Column(
82
+ mainAxisSize: MainAxisSize.min,
83
+ children: [
84
+ AnimatedBuilder(
85
+ animation:
86
+ Listenable.merge([_drawController, _planeController]),
87
+ builder: (context, child) {
88
+ return CustomPaint(
89
+ size: const Size(300, 300),
90
+ painter: _PLogoWithPlanePainter(
91
+ drawProgress: _drawController.value,
92
+ planeProgress: _planeController.value,
93
+ ),
94
+ );
95
+ },
96
+ ),
97
+ const SizedBox(height: 24),
98
+ AnimatedBuilder(
99
+ animation: _drawController,
100
+ builder: (context, child) {
101
+ return Opacity(
102
+ opacity: _drawController.value.clamp(0.0, 1.0),
103
+ child: const Text(
104
+ 'PAILot',
105
+ style: TextStyle(
106
+ color: Color(0xFF00C7FF),
107
+ fontSize: 28,
108
+ fontWeight: FontWeight.w300,
109
+ letterSpacing: 6,
110
+ ),
111
+ ),
112
+ );
113
+ },
114
+ ),
115
+ ],
116
+ ),
117
+ ),
118
+ ),
119
+ );
120
+ }
121
+}
122
+
123
+class _PLogoWithPlanePainter extends CustomPainter {
124
+ final double drawProgress;
125
+ final double planeProgress;
126
+
127
+ const _PLogoWithPlanePainter({
128
+ required this.drawProgress,
129
+ required this.planeProgress,
130
+ });
131
+
132
+ @override
133
+ void paint(Canvas canvas, Size size) {
134
+ const double svgW = 519.2;
135
+ const double svgH = 519.3;
136
+
137
+ final double scale = math.min(size.width / svgW, size.height / svgH);
138
+ final double offsetX = (size.width - svgW * scale) / 2;
139
+ final double offsetY = (size.height - svgH * scale) / 2;
140
+
141
+ canvas.save();
142
+ canvas.translate(offsetX, offsetY);
143
+ canvas.scale(scale, scale);
144
+
145
+ _drawPLogo(canvas);
146
+ _drawPlane(canvas);
147
+
148
+ canvas.restore();
149
+ }
150
+
151
+ void _drawPLogo(Canvas canvas) {
152
+ if (drawProgress <= 0) return;
153
+
154
+ final paths = _buildPPaths();
155
+
156
+ Rect bounds = paths.first.getBounds();
157
+ for (final p in paths.skip(1)) {
158
+ bounds = bounds.expandToInclude(p.getBounds());
159
+ }
160
+
161
+ final double revealY = bounds.top +
162
+ bounds.height * Curves.easeInOut.transform(drawProgress);
163
+
164
+ canvas.save();
165
+ canvas.clipRect(Rect.fromLTRB(
166
+ bounds.left - 10,
167
+ bounds.top - 10,
168
+ bounds.right + 10,
169
+ revealY + 6,
170
+ ));
171
+
172
+ final Paint gradientPaint = Paint()
173
+ ..shader = const LinearGradient(
174
+ begin: Alignment.topLeft,
175
+ end: Alignment.bottomRight,
176
+ colors: [Color(0xFF0000FF), Color(0xFF00C7FF)],
177
+ ).createShader(Rect.fromLTWH(0, 0, 519.2, 519.3))
178
+ ..style = PaintingStyle.fill
179
+ ..isAntiAlias = true;
180
+
181
+ for (final p in paths) {
182
+ canvas.drawPath(p, gradientPaint);
183
+ }
184
+
185
+ canvas.restore();
186
+ }
187
+
188
+ List<Path> _buildPPaths() {
189
+ final path1 = Path();
190
+ path1.moveTo(149.4, 68.3);
191
+ path1.lineTo(191.1, 155.4);
192
+ path1.lineTo(300.6, 151.2);
193
+ path1.cubicTo(301.3, 151.2, 302.0, 151.1, 302.7, 151.1);
194
+ path1.cubicTo(324.8, 151.1, 343.0, 169.0, 343.0, 191.4);
195
+ path1.cubicTo(343.0, 201.3, 339.4, 210.4, 333.5, 217.4);
196
+ path1.cubicTo(366.8, 193.1, 389.3, 153.8, 389.3, 109.5);
197
+ path1.cubicTo(389.3, 69.5, 371.7, 24.8, 343.9, 0.3);
198
+ path1.cubicTo(339.9, 0.0, 335.8, -0.1, 331.7, -0.1);
199
+ path1.lineTo(0, -0.1);
200
+ path1.lineTo(0, 5.4);
201
+ path1.cubicTo(0, 81.7, 59.8, 152.7, 134.9, 156.8);
202
+ path1.lineTo(107.3, 68.3);
203
+ path1.close();
204
+
205
+ final path2 = Path();
206
+ path2.moveTo(518.9, 175.6);
207
+ path2.cubicTo(515.9, 128.6, 495.7, 86.3, 464.4, 55.0);
208
+ path2.cubicTo(433.2, 23.8, 391.1, 3.6, 344.4, 0.5);
209
+ path2.cubicTo(344.3, 0.5, 344.2, 0.6, 344.3, 0.7);
210
+ path2.cubicTo(372.0, 25.2, 389.5, 69.8, 389.5, 109.6);
211
+ path2.cubicTo(389.5, 151.9, 364.4, 195.1, 333.7, 217.5);
212
+ path2.cubicTo(327.9, 224.3, 319.9, 229.2, 310.9, 231.0);
213
+ path2.cubicTo(293.9, 238.9, 275.2, 243.4, 255.9, 243.4);
214
+ path2.cubicTo(274.9, 243.4, 293.3, 239.1, 310.1, 231.4);
215
+ path2.cubicTo(310.2, 231.3, 310.2, 231.1, 310.0, 231.2);
216
+ path2.cubicTo(307.1, 231.7, 304.0, 231.9, 300.9, 231.8);
217
+ path2.lineTo(191.5, 227.6);
218
+ path2.lineTo(191.4, 227.7);
219
+ path2.lineTo(149.7, 314.7);
220
+ path2.lineTo(149.6, 314.8);
221
+ path2.lineTo(107.7, 314.8);
222
+ path2.cubicTo(107.6, 314.8, 107.6, 314.7, 107.6, 314.6);
223
+ path2.lineTo(135.1, 226.2);
224
+ path2.cubicTo(135.1, 226.1, 135.1, 226.0, 135.0, 226.0);
225
+ path2.cubicTo(59.7, 230.2, 0, 283.6, 0, 359.9);
226
+ path2.lineTo(0, 375.0);
227
+ path2.lineTo(0, 516.6);
228
+ path2.cubicTo(0, 516.7, 0.2, 516.8, 0.2, 516.6);
229
+ path2.cubicTo(29.6, 429.6, 111.9, 374.7, 208.9, 374.7);
230
+ path2.lineTo(298.2, 374.7);
231
+ path2.lineTo(298.4, 375.0);
232
+ path2.lineTo(329.8, 375.0);
233
+ path2.cubicTo(439.7, 375.0, 525.7, 285.2, 518.9, 175.6);
234
+ path2.close();
235
+
236
+ final path3 = Path();
237
+ path3.moveTo(208.9, 374.5);
238
+ path3.cubicTo(111.7, 374.5, 29.2, 429.6, 0, 517.0);
239
+ path3.lineTo(0, 519.2);
240
+ path3.lineTo(158.7, 519.2);
241
+ path3.lineTo(159.0, 417.6);
242
+ path3.cubicTo(158.8, 393.9, 178.0, 374.6, 201.7, 374.6);
243
+ path3.lineTo(298.4, 374.6);
244
+
245
+ return [path1, path2, path3];
246
+ }
247
+
248
+ void _drawPlane(Canvas canvas) {
249
+ if (planeProgress <= 0) return;
250
+
251
+ const double startX = -80.0, startY = 290.0;
252
+ const double ctrlX = 260.0, ctrlY = 100.0;
253
+ const double endX = 480.0, endY = 250.0;
254
+
255
+ final double t = planeProgress;
256
+ final double mt = 1.0 - t;
257
+
258
+ final double px = mt * mt * startX + 2 * mt * t * ctrlX + t * t * endX;
259
+ final double py = mt * mt * startY + 2 * mt * t * ctrlY + t * t * endY;
260
+
261
+ final double dx = 2 * mt * (ctrlX - startX) + 2 * t * (endX - ctrlX);
262
+ final double dy = 2 * mt * (ctrlY - startY) + 2 * t * (endY - ctrlY);
263
+ final double angle = math.atan2(dy, dx);
264
+
265
+ double alpha = 1.0;
266
+ if (t < 0.15) {
267
+ alpha = t / 0.15;
268
+ } else if (t > 0.9) {
269
+ alpha = (1.0 - t) / 0.1;
270
+ }
271
+
272
+ canvas.save();
273
+ canvas.translate(px, py);
274
+ canvas.rotate(angle);
275
+
276
+ const double s = 30.0;
277
+ final Paint bodyPaint = Paint()
278
+ ..color = Color.fromRGBO(0, 199, 255, alpha)
279
+ ..style = PaintingStyle.fill
280
+ ..isAntiAlias = true;
281
+ final Paint edgePaint = Paint()
282
+ ..color = Color.fromRGBO(255, 255, 255, alpha * 0.6)
283
+ ..style = PaintingStyle.stroke
284
+ ..strokeWidth = 1.5
285
+ ..isAntiAlias = true;
286
+
287
+ final Path plane = Path()
288
+ ..moveTo(s, 0)
289
+ ..lineTo(-s * 0.6, -s * 0.55)
290
+ ..lineTo(-s * 0.2, 0)
291
+ ..lineTo(-s * 0.6, s * 0.55)
292
+ ..close();
293
+
294
+ canvas.drawPath(plane, bodyPaint);
295
+ canvas.drawPath(plane, edgePaint);
296
+
297
+ canvas.drawLine(
298
+ Offset(s, 0),
299
+ Offset(-s * 0.2, 0),
300
+ Paint()
301
+ ..color = Color.fromRGBO(255, 255, 255, alpha * 0.4)
302
+ ..strokeWidth = 1.0,
303
+ );
304
+
305
+ canvas.restore();
306
+ }
307
+
308
+ @override
309
+ bool shouldRepaint(_PLogoWithPlanePainter old) =>
310
+ old.drawProgress != drawProgress || old.planeProgress != planeProgress;
311
+}
lib/services/message_store.dart
....@@ -1,4 +1,3 @@
1
-import 'dart:async';
21 import 'dart:convert';
32 import 'dart:io';
43
....@@ -6,29 +5,41 @@
65 import 'package:path_provider/path_provider.dart';
76
87 import '../models/message.dart';
8
+import 'trace_service.dart';
99
10
-/// Per-session JSON file persistence with debounced saves.
11
-class MessageStore {
12
- MessageStore._();
10
+/// Append-only log-based message persistence.
11
+///
12
+/// Layout:
13
+/// messages/log.jsonl — one JSON object per line, each a serialized Message
14
+/// messages/index.json — { "sessionId": [lineNumber, ...] }
15
+///
16
+/// All writes are synchronous (writeAsStringSync with FileMode.append) to
17
+/// prevent race conditions between concurrent addMessage / switchSession calls.
18
+class MessageStoreV2 {
19
+ MessageStoreV2._();
20
+
21
+ static const _backupChannel = MethodChannel('com.mnsoft.pailot/backup');
22
+
23
+ // In-memory index: sessionId -> list of 0-based line numbers in log.jsonl
24
+ static final Map<String, List<int>> _index = {};
25
+
26
+ // Number of lines currently in the log (= next line number to write)
27
+ static int _lineCount = 0;
28
+
29
+ // Flush the index to disk every N appends to amortise I/O
30
+ static const _indexFlushInterval = 20;
31
+ static int _appendsSinceFlush = 0;
1332
1433 static Directory? _baseDir;
15
- static Timer? _debounceTimer;
16
- static final Map<String, List<Message>> _pendingSaves = {};
1734
18
- static const _backupChannel =
19
- MethodChannel('com.mnsoft.pailot/backup');
35
+ // ------------------------------------------------------------------ init --
2036
21
- /// Initialize the base directory for message storage.
22
- /// On iOS, the directory is excluded from iCloud / iTunes backup so that
23
- /// large base64 image attachments do not bloat the user's cloud storage.
24
- /// Messages can be re-fetched from the server if needed.
2537 static Future<Directory> _getBaseDir() async {
2638 if (_baseDir != null) return _baseDir!;
2739 final appDir = await getApplicationDocumentsDirectory();
2840 _baseDir = Directory('${appDir.path}/messages');
29
- final created = !await _baseDir!.exists();
30
- if (created) {
31
- await _baseDir!.create(recursive: true);
41
+ if (!_baseDir!.existsSync()) {
42
+ _baseDir!.createSync(recursive: true);
3243 }
3344 // Exclude from iCloud / iTunes backup (best-effort, iOS only).
3445 if (Platform.isIOS) {
....@@ -37,112 +48,230 @@
3748 'excludeFromBackup',
3849 _baseDir!.path,
3950 );
40
- } catch (_) {
41
- // Non-fatal: if the channel call fails, backup exclusion is skipped.
42
- }
51
+ } catch (_) {}
4352 }
4453 return _baseDir!;
4554 }
4655
47
- static String _fileForSession(String sessionId) {
48
- // Sanitize session ID for filename
49
- final safe = sessionId.replaceAll(RegExp(r'[^\w\-]'), '_');
50
- return 'session_$safe.json';
51
- }
56
+ static String _logPath(Directory dir) => '${dir.path}/log.jsonl';
57
+ static String _indexPath(Directory dir) => '${dir.path}/index.json';
5258
53
- /// Save messages for a session with 1-second debounce.
54
- static void save(String sessionId, List<Message> messages) {
55
- _pendingSaves[sessionId] = messages;
56
- _debounceTimer?.cancel();
57
- _debounceTimer = Timer(const Duration(seconds: 1), _flushAll);
58
- }
59
+ /// Called once at app startup. Reads log.jsonl and rebuilds the in-memory
60
+ /// index. Then calls compact() to trim old messages.
61
+ static Future<void> initialize() async {
62
+ try {
63
+ final dir = await _getBaseDir();
64
+ final logFile = File(_logPath(dir));
65
+ final indexFile = File(_indexPath(dir));
5966
60
- /// Immediately flush all pending saves.
61
- static Future<void> flush() async {
62
- _debounceTimer?.cancel();
63
- await _flushAll();
64
- }
67
+ // Always rebuild index from log (the saved index.json may be stale
68
+ // if the app was killed before a flush).
69
+ if (logFile.existsSync()) {
70
+ final content = logFile.readAsStringSync();
71
+ _lineCount = content.isEmpty
72
+ ? 0
73
+ : content.trimRight().split('\n').length;
74
+ if (_lineCount > 0) {
75
+ await _rebuildIndex(logFile);
76
+ }
77
+ } else {
78
+ _lineCount = 0;
79
+ }
6580
66
- static Future<void> _flushAll() async {
67
- final entries = Map<String, List<Message>>.from(_pendingSaves);
68
- _pendingSaves.clear();
81
+ TraceService.instance.addTrace(
82
+ 'MsgStoreV2 INIT', '$_lineCount lines, ${_index.length} sessions');
6983
70
- for (final entry in entries.entries) {
71
- await _writeSession(entry.key, entry.value);
84
+ // Compact on startup (keeps last 200 per session).
85
+ await compact();
86
+ } catch (e) {
87
+ TraceService.instance.addTrace('MsgStoreV2 INIT ERROR', '$e');
7288 }
7389 }
7490
75
- static Future<void> _writeSession(
76
- String sessionId, List<Message> messages) async {
77
- try {
78
- final dir = await _getBaseDir();
79
- final file = File('${dir.path}/${_fileForSession(sessionId)}');
80
- // Strip heavy fields for persistence
81
- final lightMessages = messages.map((m) => m.toJsonLight()).toList();
82
- await file.writeAsString(jsonEncode(lightMessages));
83
- } catch (e) {
84
- // Silently fail - message persistence is best-effort
91
+ static Future<void> _rebuildIndex(File logFile) async {
92
+ _index.clear();
93
+ final lines = logFile.readAsLinesSync();
94
+ for (var i = 0; i < lines.length; i++) {
95
+ final line = lines[i].trim();
96
+ if (line.isEmpty) continue;
97
+ try {
98
+ final map = jsonDecode(line) as Map<String, dynamic>;
99
+ final sessionId = map['sessionId'] as String?;
100
+ if (sessionId != null) {
101
+ _index.putIfAbsent(sessionId, () => []).add(i);
102
+ }
103
+ } catch (_) {}
85104 }
86105 }
87106
88
- /// Load messages for a session.
89
- /// [limit] controls how many recent messages to return (default: 50).
90
- /// [offset] is the number of messages to skip from the end (for pagination).
91
- static Future<List<Message>> load(
92
- String sessionId, {
93
- int limit = 50,
94
- int offset = 0,
95
- }) async {
107
+ // --------------------------------------------------------------- append --
108
+
109
+ /// Append a message to the log. SYNCHRONOUS — no async gap, no race.
110
+ ///
111
+ /// Each line written includes a 'sessionId' field so the index can be
112
+ /// rebuilt from the log alone if needed.
113
+ static void append(String sessionId, Message message) {
96114 try {
97
- final dir = await _getBaseDir();
98
- final file = File('${dir.path}/${_fileForSession(sessionId)}');
99
- if (!await file.exists()) return [];
115
+ final dir = _baseDir;
116
+ if (dir == null) {
117
+ // initialize() hasn't been called yet — silently drop (shouldn't happen).
118
+ TraceService.instance
119
+ .addTrace('MsgStoreV2 APPEND WARN', 'baseDir null, dropping');
120
+ return;
121
+ }
122
+ final logFile = File(_logPath(dir));
123
+ final json = message.toJsonLight();
124
+ json['sessionId'] = sessionId;
125
+ final line = '${jsonEncode(json)}\n';
100126
101
- final jsonStr = await file.readAsString();
102
- final List<dynamic> jsonList = jsonDecode(jsonStr) as List<dynamic>;
103
- final allMessages = jsonList
104
- .map((j) => _messageFromJson(j as Map<String, dynamic>))
105
- .where((m) => !m.isEmptyVoice && !m.isEmptyText)
106
- .toList();
127
+ // Synchronous append — atomic single write, no read-modify-write.
128
+ logFile.writeAsStringSync(line, mode: FileMode.append);
107129
108
- // Paginate from the end (newest messages first in storage)
109
- if (offset >= allMessages.length) return [];
110
- final end = allMessages.length - offset;
111
- final start = (end - limit).clamp(0, end);
112
- return allMessages.sublist(start, end);
130
+ // Update in-memory index.
131
+ final lineNum = _lineCount;
132
+ _index.putIfAbsent(sessionId, () => []).add(lineNum);
133
+ _lineCount++;
134
+
135
+ TraceService.instance.addTrace('MsgStoreV2 APPEND',
136
+ '${sessionId.substring(0, 8)} line=$lineNum total=$_lineCount idx=${_index[sessionId]?.length ?? 0}');
137
+
138
+ // Flush index after every append to prevent data loss on app kill.
139
+ _flushIndex(dir);
113140 } catch (e) {
141
+ TraceService.instance.addTrace('MsgStoreV2 APPEND ERROR', '$e');
142
+ }
143
+ }
144
+
145
+ // -------------------------------------------------------------- load --
146
+
147
+ /// Load messages for a session. SYNCHRONOUS — reads from the log using the
148
+ /// in-memory index. Safe to call from switchSession without async gaps.
149
+ static List<Message> loadSession(String sessionId) {
150
+ try {
151
+ final dir = _baseDir;
152
+ if (dir == null) return [];
153
+ final logFile = File(_logPath(dir));
154
+ if (!logFile.existsSync()) return [];
155
+
156
+ final lineNumbers = _index[sessionId];
157
+ if (lineNumbers == null || lineNumbers.isEmpty) return [];
158
+
159
+ // Read all lines at once then pick the ones we need.
160
+ final allLines = logFile.readAsLinesSync();
161
+ TraceService.instance.addTrace('MsgStoreV2 LOAD detail',
162
+ '${sessionId.substring(0, 8)}: fileLines=${allLines.length} indexEntries=${lineNumbers.length} lineCount=$_lineCount');
163
+ final messages = <Message>[];
164
+
165
+ for (final n in lineNumbers) {
166
+ if (n >= allLines.length) continue;
167
+ final line = allLines[n].trim();
168
+ if (line.isEmpty) continue;
169
+ try {
170
+ final map = jsonDecode(line) as Map<String, dynamic>;
171
+ // Remove synthetic sessionId field before deserialising.
172
+ map.remove('sessionId');
173
+ final msg = _messageFromJson(map);
174
+ if (!msg.isEmptyVoice && !msg.isEmptyText) {
175
+ messages.add(msg);
176
+ }
177
+ } catch (_) {}
178
+ }
179
+
180
+ TraceService.instance.addTrace(
181
+ 'MsgStoreV2 LOAD', '${sessionId.substring(0, 8)}: ${messages.length} msgs');
182
+ return messages;
183
+ } catch (e) {
184
+ TraceService.instance
185
+ .addTrace('MsgStoreV2 LOAD ERROR', '${sessionId.substring(0, 8)}: $e');
114186 return [];
115187 }
116188 }
117189
118
- /// Load all messages for a session (no pagination).
119
- static Future<List<Message>> loadAll(String sessionId) async {
190
+ // ------------------------------------------------------------- compact --
191
+
192
+ /// Rewrite the log keeping at most [keepPerSession] messages per session.
193
+ /// Called once on startup after initialize(). NOT called during normal use.
194
+ static Future<void> compact({int keepPerSession = 200}) async {
120195 try {
121196 final dir = await _getBaseDir();
122
- final file = File('${dir.path}/${_fileForSession(sessionId)}');
123
- if (!await file.exists()) return [];
197
+ final logFile = File(_logPath(dir));
198
+ if (!logFile.existsSync()) return;
124199
125
- final jsonStr = await file.readAsString();
126
- final List<dynamic> jsonList = jsonDecode(jsonStr) as List<dynamic>;
127
- return jsonList
128
- .map((j) => _messageFromJson(j as Map<String, dynamic>))
129
- .where((m) => !m.isEmptyVoice && !m.isEmptyText)
130
- .toList();
200
+ final allLines = logFile.readAsLinesSync();
201
+ if (allLines.length < 500) return; // nothing worth compacting
202
+
203
+ // Build a set of line numbers to keep: last keepPerSession per session.
204
+ final keepLines = <int>{};
205
+ for (final entry in _index.entries) {
206
+ final lines = entry.value;
207
+ final start = lines.length > keepPerSession
208
+ ? lines.length - keepPerSession
209
+ : 0;
210
+ for (var i = start; i < lines.length; i++) {
211
+ keepLines.add(lines[i]);
212
+ }
213
+ }
214
+
215
+ if (keepLines.length == allLines.length) return; // nothing removed
216
+
217
+ // Rewrite the log with only the kept lines, rebuilding the index.
218
+ final newIndex = <String, List<int>>{};
219
+ final buffer = StringBuffer();
220
+ var newLine = 0;
221
+
222
+ for (var i = 0; i < allLines.length; i++) {
223
+ if (!keepLines.contains(i)) continue;
224
+ final line = allLines[i].trim();
225
+ if (line.isEmpty) continue;
226
+ buffer.write('$line\n');
227
+ // Extract sessionId for new index.
228
+ try {
229
+ final map = jsonDecode(line) as Map<String, dynamic>;
230
+ final sid = map['sessionId'] as String?;
231
+ if (sid != null) {
232
+ newIndex.putIfAbsent(sid, () => []).add(newLine);
233
+ }
234
+ } catch (_) {}
235
+ newLine++;
236
+ }
237
+
238
+ logFile.writeAsStringSync(buffer.toString());
239
+ _index
240
+ ..clear()
241
+ ..addAll(newIndex);
242
+ _lineCount = newLine;
243
+ _flushIndex(dir);
244
+
245
+ TraceService.instance.addTrace(
246
+ 'MsgStoreV2 COMPACT', '${allLines.length} → $newLine lines');
131247 } catch (e) {
132
- return [];
248
+ TraceService.instance.addTrace('MsgStoreV2 COMPACT ERROR', '$e');
133249 }
134250 }
135251
136
- /// Deserialize a message from JSON, applying migration rules:
137
- /// - Voice messages without audioUri are downgraded to text (transcript only).
138
- /// This handles messages saved before a restart, where the temp audio file
139
- /// is no longer available. The transcript (content) is preserved.
252
+ // --------------------------------------------------------- index flush --
253
+
254
+ static void _flushIndex(Directory dir) {
255
+ try {
256
+ final indexMap = _index.map(
257
+ (k, v) => MapEntry(k, v));
258
+ File(_indexPath(dir))
259
+ .writeAsStringSync(jsonEncode(indexMap));
260
+ } catch (_) {}
261
+ }
262
+
263
+ /// Force-flush the index to disk (call on app suspend / session switch).
264
+ static void flushIndex() {
265
+ if (_baseDir != null) _flushIndex(_baseDir!);
266
+ }
267
+
268
+ // ------------------------------------------------- migration helper --
269
+
270
+ /// Deserialize a message, downgrading voice→text if audio is unavailable.
140271 static Message _messageFromJson(Map<String, dynamic> json) {
141272 final raw = Message.fromJson(json);
142273 if (raw.type == MessageType.voice &&
143274 (raw.audioUri == null || raw.audioUri!.isEmpty)) {
144
- // Downgrade to text so the bubble shows the transcript instead of a
145
- // broken play button.
146275 return Message(
147276 id: raw.id,
148277 role: raw.role,
....@@ -156,25 +285,18 @@
156285 return raw;
157286 }
158287
159
- /// Delete stored messages for a session.
160
- static Future<void> delete(String sessionId) async {
161
- try {
162
- final dir = await _getBaseDir();
163
- final file = File('${dir.path}/${_fileForSession(sessionId)}');
164
- if (await file.exists()) {
165
- await file.delete();
166
- }
167
- } catch (_) {}
168
- }
288
+ // --------------------------------------------------------- clear all --
169289
170
- /// Clear all stored messages.
290
+ /// Wipe everything (used from settings / debug).
171291 static Future<void> clearAll() async {
172292 try {
173293 final dir = await _getBaseDir();
174
- if (await dir.exists()) {
175
- await dir.delete(recursive: true);
176
- await dir.create(recursive: true);
294
+ if (dir.existsSync()) {
295
+ dir.deleteSync(recursive: true);
296
+ dir.createSync(recursive: true);
177297 }
298
+ _index.clear();
299
+ _lineCount = 0;
178300 } catch (_) {}
179301 }
180302 }
lib/services/mqtt_service.dart
....@@ -11,6 +11,7 @@
1111 import 'package:path_provider/path_provider.dart' as pp;
1212 import 'package:mqtt_client/mqtt_client.dart';
1313 import 'package:mqtt_client/mqtt_server_client.dart';
14
+import 'package:typed_data/typed_data.dart';
1415 import 'package:shared_preferences/shared_preferences.dart';
1516 import 'package:uuid/uuid.dart';
1617
....@@ -46,7 +47,9 @@
4647 /// Subscribes to all pailot/ topics and dispatches messages
4748 /// through the onMessage callback interface.
4849 class MqttService with WidgetsBindingObserver {
49
- MqttService({required this.config});
50
+ MqttService({required this.config}) {
51
+ WidgetsBinding.instance.addObserver(this);
52
+ }
5053
5154 ServerConfig config;
5255 MqttServerClient? _client;
....@@ -61,6 +64,8 @@
6164 // Message deduplication
6265 final Set<String> _seenMsgIds = {};
6366 final List<String> _seenMsgIdOrder = [];
67
+
68
+ // (Per-session subscriptions removed — single pailot/out topic now)
6469 static const int _maxSeenIds = 500;
6570
6671 // Callbacks
....@@ -97,6 +102,34 @@
97102 }
98103 _clientId = id;
99104 return id;
105
+ }
106
+
107
+ /// Force reconnect — disconnect and reconnect to last known host.
108
+ void forceReconnect() {
109
+ _mqttLog('MQTT: force reconnect requested');
110
+ final lastHost = connectedHost;
111
+ _client?.disconnect();
112
+ _client = null;
113
+ _setStatus(ConnectionStatus.reconnecting);
114
+ onReconnecting?.call();
115
+ if (lastHost != null) {
116
+ _fastReconnect(lastHost);
117
+ } else {
118
+ connect();
119
+ }
120
+ }
121
+
122
+ /// Fast reconnect to a known host — skips discovery, short timeout.
123
+ Future<void> _fastReconnect(String host) async {
124
+ _mqttLog('MQTT: fast reconnect to $host');
125
+ final clientId = await _getClientId();
126
+ if (await _tryConnect(host, clientId, timeout: 2000)) {
127
+ connectedHost = host;
128
+ return;
129
+ }
130
+ // Fast path failed — fall back to full connect
131
+ _mqttLog('MQTT: fast reconnect failed, full connect...');
132
+ connect();
100133 }
101134
102135 /// Connect to the MQTT broker.
....@@ -364,7 +397,7 @@
364397 Future<bool> _tryConnect(String host, String clientId, {int timeout = 5000}) async {
365398 try {
366399 final client = MqttServerClient.withPort(host, clientId, config.port);
367
- client.keepAlivePeriod = 30;
400
+ client.keepAlivePeriod = 120; // 2 min — iOS throttles bg network, short keepalive causes drops
368401 client.autoReconnect = false; // Don't auto-reconnect during trial — enable after success
369402 client.connectTimeoutPeriod = timeout;
370403 // client.maxConnectionAttempts is final — can't set it
....@@ -407,7 +440,7 @@
407440 );
408441 _mqttLog('MQTT: connect result=${result?.state}');
409442 if (result?.state == MqttConnectionState.connected) {
410
- client.autoReconnect = true; // Now enable auto-reconnect for the live connection
443
+ client.autoReconnect = true;
411444 return true;
412445 }
413446 _client = null;
....@@ -458,15 +491,14 @@
458491 _mqttLog('MQTT: _subscribe called but client is null');
459492 return;
460493 }
494
+ // Single outbound topic — all messages carry sessionId in payload.
495
+ // Client routes messages to the correct session based on payload.
461496 _mqttLog('MQTT: subscribing to topics...');
497
+ client.subscribe('pailot/out', MqttQos.atLeastOnce);
462498 client.subscribe('pailot/sessions', MqttQos.atLeastOnce);
463499 client.subscribe('pailot/status', MqttQos.atLeastOnce);
464500 client.subscribe('pailot/projects', MqttQos.atLeastOnce);
465
- client.subscribe('pailot/+/out', MqttQos.atLeastOnce);
466
- client.subscribe('pailot/+/typing', MqttQos.atMostOnce);
467
- client.subscribe('pailot/+/screenshot', MqttQos.atLeastOnce);
468501 client.subscribe('pailot/control/out', MqttQos.atLeastOnce);
469
- client.subscribe('pailot/voice/transcript', MqttQos.atLeastOnce);
470502 }
471503
472504 void _listenMessages() {
....@@ -523,8 +555,6 @@
523555 /// Translates MQTT topic structure into the flat message format
524556 /// that chat_screen expects.
525557 void _dispatchMessage(String topic, Map<String, dynamic> json) {
526
- final parts = topic.split('/');
527
-
528558 // pailot/sessions
529559 if (topic == 'pailot/sessions') {
530560 json['type'] = 'sessions';
....@@ -546,46 +576,25 @@
546576 return;
547577 }
548578
549
- // pailot/control/out — command responses (session_switched, session_renamed, error, unread)
579
+ // pailot/control/out — command responses
550580 if (topic == 'pailot/control/out') {
551581 onMessage?.call(json);
552582 return;
553583 }
554584
555
- // pailot/voice/transcript
556
- if (topic == 'pailot/voice/transcript') {
557
- json['type'] = 'transcript';
558
- onMessage?.call(json);
559
- return;
560
- }
561
-
562
- // pailot/<sessionId>/out — text, voice, image messages
563
- if (parts.length == 3 && parts[2] == 'out') {
564
- final sessionId = parts[1];
565
- json['sessionId'] ??= sessionId;
566
- onMessage?.call(json);
567
- return;
568
- }
569
-
570
- // pailot/<sessionId>/typing
571
- if (parts.length == 3 && parts[2] == 'typing') {
572
- final sessionId = parts[1];
573
- json['type'] = 'typing';
574
- json['sessionId'] ??= sessionId;
575
- // Map 'active' field to the 'typing'/'isTyping' fields chat_screen expects
576
- final active = json['active'] as bool? ?? true;
577
- json['typing'] = active;
578
- onMessage?.call(json);
579
- return;
580
- }
581
-
582
- // pailot/<sessionId>/screenshot
583
- if (parts.length == 3 && parts[2] == 'screenshot') {
584
- final sessionId = parts[1];
585
- json['type'] = 'screenshot';
586
- json['sessionId'] ??= sessionId;
587
- // Map imageBase64 to 'data' for compatibility with chat_screen handler
588
- json['data'] ??= json['imageBase64'];
585
+ // pailot/out — ALL content messages (text, voice, image, typing, screenshot, transcript)
586
+ // Each message carries its type and sessionId in the payload.
587
+ if (topic == 'pailot/out') {
588
+ final type = json['type'] as String?;
589
+ // Normalize typing fields for chat_screen
590
+ if (type == 'typing') {
591
+ final active = json['active'] as bool? ?? true;
592
+ json['typing'] = active;
593
+ }
594
+ // Normalize screenshot fields
595
+ if (type == 'screenshot') {
596
+ json['data'] ??= json['imageBase64'];
597
+ }
589598 onMessage?.call(json);
590599 return;
591600 }
....@@ -603,6 +612,15 @@
603612
604613 /// Current timestamp in milliseconds.
605614 int _now() => DateTime.now().millisecondsSinceEpoch;
615
+
616
+ /// Publish raw bytes to a topic. Used by TraceService for log streaming.
617
+ void publishRaw(String topic, Uint8Buffer payload, MqttQos qos) {
618
+ final client = _client;
619
+ if (client == null || client.connectionStatus?.state != MqttConnectionState.connected) return;
620
+ try {
621
+ client.publishMessage(topic, qos, payload);
622
+ } catch (_) {}
623
+ }
606624
607625 /// Publish a JSON payload to an MQTT topic.
608626 void _publish(String topic, Map<String, dynamic> payload, MqttQos qos) {
....@@ -770,6 +788,7 @@
770788
771789 /// Dispose all resources.
772790 void dispose() {
791
+ WidgetsBinding.instance.removeObserver(this);
773792 disconnect();
774793 }
775794
....@@ -779,20 +798,10 @@
779798 switch (state) {
780799 case AppLifecycleState.resumed:
781800 if (_intentionalClose) break;
782
- _mqttLog('MQTT: app resumed, status=$_status client=${_client != null} mqttState=${_client?.connectionStatus?.state}');
783
- final client = _client;
784
- if (client == null || client.connectionStatus?.state != MqttConnectionState.connected) {
785
- // Clearly disconnected — just reconnect
786
- _mqttLog('MQTT: not connected on resume, reconnecting...');
787
- _client = null;
788
- _setStatus(ConnectionStatus.reconnecting);
789
- connect();
790
- } else {
791
- // Appears connected — notify listener to fetch missed messages
792
- // via catch_up. Don't call onOpen (it resets sessionReady and causes flicker).
793
- _mqttLog('MQTT: appears connected on resume, triggering catch_up');
794
- onResume?.call();
795
- }
801
+ _mqttLog('MQTT: app resumed');
802
+ // Let autoReconnect handle dead connections (keepalive timeout).
803
+ // Just trigger catch_up to fetch missed messages and rebuild UI.
804
+ onResume?.call();
796805 case AppLifecycleState.paused:
797806 break;
798807 default:
lib/services/trace_service.dart
....@@ -1,4 +1,10 @@
1
+import 'dart:convert';
2
+
13 import 'package:flutter/foundation.dart';
4
+import 'package:mqtt_client/mqtt_client.dart';
5
+import 'package:mqtt_client/mqtt_server_client.dart';
6
+
7
+import 'mqtt_service.dart';
28
39 /// A single trace entry capturing a message-handling event.
410 class TraceEntry {
....@@ -22,17 +28,28 @@
2228 /// Captures message-handling events from MQTT, chat screen, and other
2329 /// components. The buffer is capped at [maxEntries] (default 200).
2430 /// Works in both debug and release builds.
31
+///
32
+/// When an MqttService is attached via [attachMqtt], trace entries are
33
+/// automatically published to the server on `pailot/control/in` so they
34
+/// can be read from the daemon log.
2535 class TraceService {
2636 TraceService._();
2737 static final TraceService instance = TraceService._();
2838
2939 static const int maxEntries = 200;
3040 final List<TraceEntry> _entries = [];
41
+ MqttService? _mqtt;
42
+
43
+ /// Attach an MQTT service for auto-publishing traces to the server.
44
+ void attachMqtt(MqttService mqtt) {
45
+ _mqtt = mqtt;
46
+ }
3147
3248 /// All entries, oldest first.
3349 List<TraceEntry> get entries => List.unmodifiable(_entries);
3450
3551 /// Add a trace entry. Oldest entry is evicted once the buffer is full.
52
+ /// If MQTT is attached and connected, the entry is also published to the server.
3653 void addTrace(String event, String details) {
3754 _entries.add(TraceEntry(
3855 timestamp: DateTime.now(),
....@@ -43,6 +60,28 @@
4360 _entries.removeAt(0);
4461 }
4562 debugPrint('[TRACE] $event — $details');
63
+
64
+ // Auto-publish to server if MQTT is connected
65
+ _publishTrace(event, details);
66
+ }
67
+
68
+ void _publishTrace(String event, String details) {
69
+ final mqtt = _mqtt;
70
+ if (mqtt == null || !mqtt.isConnected) return;
71
+ try {
72
+ final payload = jsonEncode({
73
+ 'type': 'command',
74
+ 'command': 'app_trace',
75
+ 'event': event,
76
+ 'details': details,
77
+ 'ts': DateTime.now().millisecondsSinceEpoch,
78
+ });
79
+ final builder = MqttClientPayloadBuilder();
80
+ builder.addString(payload);
81
+ mqtt.publishRaw('pailot/control/in', builder.payload!, MqttQos.atMostOnce);
82
+ } catch (_) {
83
+ // Non-fatal — don't let trace logging break the app
84
+ }
4685 }
4786
4887 /// Clear all entries.
lib/widgets/message_bubble.dart
....@@ -5,6 +5,7 @@
55 import 'package:flutter/material.dart';
66 import 'package:flutter/services.dart';
77 import 'package:flutter_markdown/flutter_markdown.dart';
8
+import 'package:url_launcher/url_launcher.dart';
89 import 'package:intl/intl.dart';
910
1011 import '../models/message.dart';
....@@ -165,13 +166,10 @@
165166 ),
166167 onTapLink: (text, href, title) {
167168 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
- );
169
+ final uri = Uri.tryParse(href);
170
+ if (uri != null) {
171
+ launchUrl(uri, mode: LaunchMode.externalApplication);
172
+ }
175173 }
176174 },
177175 );
macos/Flutter/GeneratedPluginRegistrant.swift
....@@ -18,6 +18,7 @@
1818 import record_macos
1919 import share_plus
2020 import shared_preferences_foundation
21
+import url_launcher_macos
2122
2223 func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
2324 AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin"))
....@@ -33,4 +34,5 @@
3334 RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
3435 SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
3536 SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
37
+ UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
3638 }
pubspec.lock
....@@ -1061,6 +1061,30 @@
10611061 url: "https://pub.dev"
10621062 source: hosted
10631063 version: "5.0.3"
1064
+ url_launcher:
1065
+ dependency: "direct main"
1066
+ description:
1067
+ name: url_launcher
1068
+ sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
1069
+ url: "https://pub.dev"
1070
+ source: hosted
1071
+ version: "6.3.2"
1072
+ url_launcher_android:
1073
+ dependency: transitive
1074
+ description:
1075
+ name: url_launcher_android
1076
+ sha256: "3bb000251e55d4a209aa0e2e563309dc9bb2befea2295fd0cec1f51760aac572"
1077
+ url: "https://pub.dev"
1078
+ source: hosted
1079
+ version: "6.3.29"
1080
+ url_launcher_ios:
1081
+ dependency: transitive
1082
+ description:
1083
+ name: url_launcher_ios
1084
+ sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0"
1085
+ url: "https://pub.dev"
1086
+ source: hosted
1087
+ version: "6.4.1"
10641088 url_launcher_linux:
10651089 dependency: transitive
10661090 description:
....@@ -1069,6 +1093,14 @@
10691093 url: "https://pub.dev"
10701094 source: hosted
10711095 version: "3.2.2"
1096
+ url_launcher_macos:
1097
+ dependency: transitive
1098
+ description:
1099
+ name: url_launcher_macos
1100
+ sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
1101
+ url: "https://pub.dev"
1102
+ source: hosted
1103
+ version: "3.2.5"
10721104 url_launcher_platform_interface:
10731105 dependency: transitive
10741106 description:
pubspec.yaml
....@@ -35,6 +35,7 @@
3535 flutter_app_badger: ^1.5.0
3636 connectivity_plus: ^7.1.0
3737 in_app_purchase: ^3.2.3
38
+ url_launcher: ^6.3.2
3839
3940 dev_dependencies:
4041 flutter_test: