Matthias Nott
2026-06-11 7747e467ed7d5316c62a439a5e3e58bf891f0d73
fix: clear unread counts on app foreground so badge doesn't ghost

The badge persisted at e.g. 15 after the user had effectively seen the
messages because the app re-painted PushService.setBadge(total) on
AppLifecycleState.paused from persisted unread counts, even when those
counts no longer reflected reality. Now:

- On AppLifecycleState.resumed: clear all unread counts and the icon
badge. App being foregrounded = user is here = everything is seen.
- After cold-start load: same. The brief load is kept so any code that
reads the provider sees a valid state, but it's immediately cleared
so the next background doesn't re-paint a stale total.

New messages arriving while backgrounded still accumulate fresh and
update the badge as before.
1 files modified
changed files
lib/screens/chat_screen.dart patch | view | blame | history
lib/screens/chat_screen.dart
....@@ -93,7 +93,10 @@
9393 // Load persisted state BEFORE connecting
9494 final prefs = await SharedPreferences.getInstance();
9595 _lastSeq = prefs.getInt('lastSeq') ?? 0;
96
- // Restore persisted unread counts
96
+ // Restore persisted unread counts (kept loaded so any pre-resume read code
97
+ // sees a valid state), but the app is foregrounding right now → user is
98
+ // here, so everything is "seen." Clear immediately so a stale 15 doesn't
99
+ // re-paint the badge on next background.
97100 final savedUnreads = prefs.getString('unreadCounts');
98101 if (savedUnreads != null && mounted) {
99102 try {
....@@ -103,6 +106,7 @@
103106 } catch (_) {}
104107 }
105108 _unreadCountsLoaded = true;
109
+ _clearAllUnreads();
106110 // Restore saved session order and active session
107111 _cachedSessionOrder = prefs.getStringList('sessionOrder');
108112 final savedSessionId = prefs.getString('activeSessionId');
....@@ -156,8 +160,10 @@
156160 if (_ws != null && !_ws!.isConnected) {
157161 _ws!.connect();
158162 }
159
- // Don't update badge here — provider might not have loaded persisted counts yet.
160
- // Native applicationDidBecomeActive reads correct value from UserDefaults.
163
+ // App is foregrounded → user is here → everything previously unread is
164
+ // now considered seen. Clear so we don't re-paint a stale badge when the
165
+ // user backgrounds the app without having explicitly switched sessions.
166
+ _clearAllUnreads();
161167 } else if (state == AppLifecycleState.paused && _unreadCountsLoaded) {
162168 // Set badge to total unread count when going to background
163169 _updateBadgeFromUnreads();
....@@ -167,6 +173,19 @@
167173 void _updateBadgeFromUnreads() {
168174 final counts = ref.read(unreadCountsProvider);
169175 _persistUnreadCounts(counts);
176
+ }
177
+
178
+ /// Clear in-memory + persisted unread counts and remove the icon badge.
179
+ /// Called on cold-start (after load) and on resume — the app being open
180
+ /// means the user has effectively seen what was pending.
181
+ void _clearAllUnreads() {
182
+ if (!mounted) return;
183
+ ref.read(unreadCountsProvider.notifier).state = <String, int>{};
184
+ SharedPreferences.getInstance().then((prefs) {
185
+ prefs.setString('unreadCounts', jsonEncode(<String, int>{}));
186
+ prefs.setInt('badgeCount', 0);
187
+ });
188
+ PushService.clearBadge();
170189 }
171190
172191 // ignore: unused_field
....@@ -214,6 +233,7 @@
214233 }
215234 };
216235 _ws!.onMessage = _handleMessage;
236
+ _ws!.onDebugStateRequest = _handleDebugStateRequest;
217237 _ws!.onOpen = () {
218238 _sessionReady = false; // Gate messages until sessions arrive
219239 _pendingMessages.clear();
....@@ -522,6 +542,30 @@
522542 }
523543 }
524544
545
+ /// Respond to a pailot_debug_state request from the server.
546
+ /// Reads the in-memory session list and active session from providers
547
+ /// and publishes exactly what the app is currently rendering.
548
+ void _handleDebugStateRequest(String requestId) {
549
+ final sessions = ref.read(sessionsProvider);
550
+ final activeSessionId = ref.read(activeSessionIdProvider);
551
+
552
+ final sessionPayloads = sessions.map((s) => {
553
+ 'sessionId': s.id,
554
+ 'index': s.index,
555
+ 'displayedName': s.name, // exactly what is shown in the drawer
556
+ 'type': s.type,
557
+ if (s.kind != null) 'kind': s.kind,
558
+ 'isActive': s.id == activeSessionId,
559
+ }).toList();
560
+
561
+ _ws?.publishDebugStateResponse(
562
+ requestId: requestId,
563
+ activeSessionId: activeSessionId,
564
+ sessions: sessionPayloads,
565
+ platform: Platform.operatingSystem,
566
+ );
567
+ }
568
+
525569 void _handleIncomingMessage(Map<String, dynamic> msg) {
526570 final sessionId = msg['sessionId'] as String?;
527571 final content = msg['content'] as String? ??