Matthias Nott
2026-04-01 0af9986262e53b232731408ad38e9fda3da2cfa2
feat: smart badge counting, persisted unreads, flutter_app_badger, race condition fixes
7 files modified
changed files
ios/Podfile.lock patch | view | blame | history
ios/Runner/AppDelegate.swift patch | view | blame | history
lib/screens/chat_screen.dart patch | view | blame | history
lib/services/push_service.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
ios/Podfile.lock
....@@ -42,6 +42,8 @@
4242 - DKImagePickerController/PhotoGallery
4343 - Flutter
4444 - Flutter (1.0.0)
45
+ - flutter_app_badger (1.3.0):
46
+ - Flutter
4547 - flutter_secure_storage (6.0.0):
4648 - Flutter
4749 - image_picker_ios (0.0.1):
....@@ -71,6 +73,7 @@
7173 - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
7274 - file_picker (from `.symlinks/plugins/file_picker/ios`)
7375 - Flutter (from `Flutter`)
76
+ - flutter_app_badger (from `.symlinks/plugins/flutter_app_badger/ios`)
7477 - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
7578 - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
7679 - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
....@@ -98,6 +101,8 @@
98101 :path: ".symlinks/plugins/file_picker/ios"
99102 Flutter:
100103 :path: Flutter
104
+ flutter_app_badger:
105
+ :path: ".symlinks/plugins/flutter_app_badger/ios"
101106 flutter_secure_storage:
102107 :path: ".symlinks/plugins/flutter_secure_storage/ios"
103108 image_picker_ios:
....@@ -123,6 +128,7 @@
123128 DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
124129 file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be
125130 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
131
+ flutter_app_badger: 16b371e989d04cd265df85be2c3851b49cb68d18
126132 flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
127133 image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
128134 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
ios/Runner/AppDelegate.swift
....@@ -8,8 +8,6 @@
88 _ application: UIApplication,
99 didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
1010 ) -> Bool {
11
- application.applicationIconBadgeNumber = 0
12
- if #available(iOS 16.0, *) { UNUserNotificationCenter.current().setBadgeCount(0) }
1311 return super.application(application, didFinishLaunchingWithOptions: launchOptions)
1412 }
1513
....@@ -17,13 +15,18 @@
1715 GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
1816 }
1917
20
- // Clear badge when app becomes active
21
- override func applicationDidBecomeActive(_ application: UIApplication) {
22
- super.applicationDidBecomeActive(application)
23
- application.applicationIconBadgeNumber = 0
24
- if #available(iOS 16.0, *) { UNUserNotificationCenter.current().setBadgeCount(0) }
18
+ // Read badge count from Flutter's SharedPreferences (UserDefaults) and update icon
19
+ private func updateBadgeFromPrefs() {
20
+ // Flutter SharedPreferences stores ints with "flutter." prefix
21
+ let prefs = UserDefaults.standard
22
+ let count = prefs.integer(forKey: "flutter.badgeCount")
23
+ UIApplication.shared.applicationIconBadgeNumber = count
2524 }
2625
26
+ // Don't touch badge on resume — APNs sets it, Flutter decrements it on session view
27
+
28
+ // Badge handled by Flutter via platform channel on session switch and background
29
+
2730 // Forward APNs token registration to the push plugin
2831 override func application(
2932 _ application: UIApplication,
lib/screens/chat_screen.dart
....@@ -67,6 +67,7 @@
6767 final Map<String, List<Message>> _catchUpPending = {};
6868 List<String>? _cachedSessionOrder;
6969 Timer? _typingTimer;
70
+ bool _unreadCountsLoaded = false;
7071
7172 @override
7273 void initState() {
....@@ -80,6 +81,16 @@
8081 // Load persisted state BEFORE connecting
8182 final prefs = await SharedPreferences.getInstance();
8283 _lastSeq = prefs.getInt('lastSeq') ?? 0;
84
+ // Restore persisted unread counts
85
+ final savedUnreads = prefs.getString('unreadCounts');
86
+ if (savedUnreads != null && mounted) {
87
+ try {
88
+ final map = (jsonDecode(savedUnreads) as Map<String, dynamic>)
89
+ .map((k, v) => MapEntry(k, v as int));
90
+ ref.read(unreadCountsProvider.notifier).state = map;
91
+ } catch (_) {}
92
+ }
93
+ _unreadCountsLoaded = true;
8394 // Restore saved session order and active session
8495 _cachedSessionOrder = prefs.getStringList('sessionOrder');
8596 final savedSessionId = prefs.getString('activeSessionId');
....@@ -133,7 +144,17 @@
133144 if (_ws != null && !_ws!.isConnected) {
134145 _ws!.connect();
135146 }
147
+ // Don't update badge here — provider might not have loaded persisted counts yet.
148
+ // Native applicationDidBecomeActive reads correct value from UserDefaults.
149
+ } else if (state == AppLifecycleState.paused && _unreadCountsLoaded) {
150
+ // Set badge to total unread count when going to background
151
+ _updateBadgeFromUnreads();
136152 }
153
+ }
154
+
155
+ void _updateBadgeFromUnreads() {
156
+ final counts = ref.read(unreadCountsProvider);
157
+ _persistUnreadCounts(counts);
137158 }
138159
139160 bool _isLoadingMore = false;
....@@ -675,6 +696,18 @@
675696 final counts = Map<String, int>.from(ref.read(unreadCountsProvider));
676697 counts[sessionId] = (counts[sessionId] ?? 0) + 1;
677698 ref.read(unreadCountsProvider.notifier).state = counts;
699
+ _persistUnreadCounts(counts);
700
+ }
701
+
702
+ void _persistUnreadCounts(Map<String, int> counts) {
703
+ final total = counts.values.fold<int>(0, (sum, v) => sum + v);
704
+ // Set badge immediately via platform channel (synchronous native call)
705
+ PushService.setBadge(total);
706
+ // Also persist to SharedPreferences for app restart
707
+ SharedPreferences.getInstance().then((prefs) {
708
+ prefs.setString('unreadCounts', jsonEncode(counts));
709
+ prefs.setInt('badgeCount', total);
710
+ });
678711 }
679712
680713 Future<void> _switchSession(String sessionId) async {
....@@ -691,6 +724,10 @@
691724 final counts = Map<String, int>.from(ref.read(unreadCountsProvider));
692725 counts.remove(sessionId);
693726 ref.read(unreadCountsProvider.notifier).state = counts;
727
+ _persistUnreadCounts(counts);
728
+
729
+ // Update badge to reflect remaining unreads
730
+ _updateBadgeFromUnreads();
694731
695732 _sendCommand('switch', {'sessionId': sessionId});
696733 _scrollToBottom();
lib/services/push_service.dart
....@@ -1,5 +1,5 @@
11 import 'package:flutter/foundation.dart';
2
-import 'package:flutter/services.dart';
2
+import 'package:flutter_app_badger/flutter_app_badger.dart';
33 import 'package:push/push.dart';
44
55 import 'mqtt_service.dart';
....@@ -95,14 +95,16 @@
9595
9696 /// Clear the app icon badge number.
9797 static void clearBadge() {
98
- try {
99
- // Use UIApplication.shared.applicationIconBadgeNumber = 0 via platform channel
100
- const platform = MethodChannel('com.tekmidian.pailot/badge');
101
- platform.invokeMethod('clearBadge').catchError((_) {
102
- // Fallback: UNUserNotificationCenter approach
103
- debugPrint('[Push] clearBadge via platform channel failed, using Push API');
104
- });
105
- } catch (_) {}
98
+ FlutterAppBadger.removeBadge();
99
+ }
100
+
101
+ /// Set the app icon badge to a specific count.
102
+ static void setBadge(int count) {
103
+ if (count <= 0) {
104
+ FlutterAppBadger.removeBadge();
105
+ } else {
106
+ FlutterAppBadger.updateBadgeCount(count);
107
+ }
106108 }
107109
108110 /// Publish the device token to the daemon via MQTT.
macos/Flutter/GeneratedPluginRegistrant.swift
....@@ -10,6 +10,7 @@
1010 import device_info_plus
1111 import file_picker
1212 import file_selector_macos
13
+import flutter_app_badger
1314 import flutter_secure_storage_macos
1415 import push
1516 import record_macos
....@@ -22,6 +23,7 @@
2223 DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
2324 FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
2425 FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
26
+ FlutterAppBadgerPlugin.register(with: registry.registrar(forPlugin: "FlutterAppBadgerPlugin"))
2527 FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
2628 PushPlugin.register(with: registry.registrar(forPlugin: "PushPlugin"))
2729 RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
pubspec.lock
....@@ -294,6 +294,14 @@
294294 description: flutter
295295 source: sdk
296296 version: "0.0.0"
297
+ flutter_app_badger:
298
+ dependency: "direct main"
299
+ description:
300
+ name: flutter_app_badger
301
+ sha256: "64d4a279bab862ed28850431b9b446b9820aaae0bf363322d51077419f930fa8"
302
+ url: "https://pub.dev"
303
+ source: hosted
304
+ version: "1.5.0"
297305 flutter_lints:
298306 dependency: "direct dev"
299307 description:
pubspec.yaml
....@@ -32,6 +32,7 @@
3232 bonsoir: ^6.0.2
3333 crypto: ^3.0.7
3434 push: ^3.3.3
35
+ flutter_app_badger: ^1.5.0
3536
3637 dev_dependencies:
3738 flutter_test: