From 0af9986262e53b232731408ad38e9fda3da2cfa2 Mon Sep 17 00:00:00 2001
From: Matthias Nott <mnott@mnsoft.org>
Date: Wed, 01 Apr 2026 18:02:59 +0200
Subject: [PATCH] feat: smart badge counting, persisted unreads, flutter_app_badger, race condition fixes

---
 ios/Podfile.lock                              |    6 +++
 macos/Flutter/GeneratedPluginRegistrant.swift |    2 +
 ios/Runner/AppDelegate.swift                  |   17 +++++---
 lib/services/push_service.dart                |   20 +++++----
 pubspec.lock                                  |    8 ++++
 lib/screens/chat_screen.dart                  |   37 ++++++++++++++++++
 pubspec.yaml                                  |    1 
 7 files changed, 75 insertions(+), 16 deletions(-)

diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index 853dbaf..251c69f 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -42,6 +42,8 @@
     - DKImagePickerController/PhotoGallery
     - Flutter
   - Flutter (1.0.0)
+  - flutter_app_badger (1.3.0):
+    - Flutter
   - flutter_secure_storage (6.0.0):
     - Flutter
   - image_picker_ios (0.0.1):
@@ -71,6 +73,7 @@
   - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
   - file_picker (from `.symlinks/plugins/file_picker/ios`)
   - Flutter (from `Flutter`)
+  - flutter_app_badger (from `.symlinks/plugins/flutter_app_badger/ios`)
   - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
   - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
   - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
@@ -98,6 +101,8 @@
     :path: ".symlinks/plugins/file_picker/ios"
   Flutter:
     :path: Flutter
+  flutter_app_badger:
+    :path: ".symlinks/plugins/flutter_app_badger/ios"
   flutter_secure_storage:
     :path: ".symlinks/plugins/flutter_secure_storage/ios"
   image_picker_ios:
@@ -123,6 +128,7 @@
   DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
   file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be
   Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
+  flutter_app_badger: 16b371e989d04cd265df85be2c3851b49cb68d18
   flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
   image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
   permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift
index 046a9dd..c719ba2 100644
--- a/ios/Runner/AppDelegate.swift
+++ b/ios/Runner/AppDelegate.swift
@@ -8,8 +8,6 @@
     _ application: UIApplication,
     didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
   ) -> Bool {
-    application.applicationIconBadgeNumber = 0
-    if #available(iOS 16.0, *) { UNUserNotificationCenter.current().setBadgeCount(0) }
     return super.application(application, didFinishLaunchingWithOptions: launchOptions)
   }
 
@@ -17,13 +15,18 @@
     GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
   }
 
-  // Clear badge when app becomes active
-  override func applicationDidBecomeActive(_ application: UIApplication) {
-    super.applicationDidBecomeActive(application)
-    application.applicationIconBadgeNumber = 0
-    if #available(iOS 16.0, *) { UNUserNotificationCenter.current().setBadgeCount(0) }
+  // Read badge count from Flutter's SharedPreferences (UserDefaults) and update icon
+  private func updateBadgeFromPrefs() {
+    // Flutter SharedPreferences stores ints with "flutter." prefix
+    let prefs = UserDefaults.standard
+    let count = prefs.integer(forKey: "flutter.badgeCount")
+    UIApplication.shared.applicationIconBadgeNumber = count
   }
 
+  // Don't touch badge on resume — APNs sets it, Flutter decrements it on session view
+
+  // Badge handled by Flutter via platform channel on session switch and background
+
   // Forward APNs token registration to the push plugin
   override func application(
     _ application: UIApplication,
diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart
index 63fc4cd..77af560 100644
--- a/lib/screens/chat_screen.dart
+++ b/lib/screens/chat_screen.dart
@@ -67,6 +67,7 @@
   final Map<String, List<Message>> _catchUpPending = {};
   List<String>? _cachedSessionOrder;
   Timer? _typingTimer;
+  bool _unreadCountsLoaded = false;
 
   @override
   void initState() {
@@ -80,6 +81,16 @@
     // Load persisted state BEFORE connecting
     final prefs = await SharedPreferences.getInstance();
     _lastSeq = prefs.getInt('lastSeq') ?? 0;
+    // Restore persisted unread counts
+    final savedUnreads = prefs.getString('unreadCounts');
+    if (savedUnreads != null && mounted) {
+      try {
+        final map = (jsonDecode(savedUnreads) as Map<String, dynamic>)
+            .map((k, v) => MapEntry(k, v as int));
+        ref.read(unreadCountsProvider.notifier).state = map;
+      } catch (_) {}
+    }
+    _unreadCountsLoaded = true;
     // Restore saved session order and active session
     _cachedSessionOrder = prefs.getStringList('sessionOrder');
     final savedSessionId = prefs.getString('activeSessionId');
@@ -133,7 +144,17 @@
       if (_ws != null && !_ws!.isConnected) {
         _ws!.connect();
       }
+      // Don't update badge here — provider might not have loaded persisted counts yet.
+      // Native applicationDidBecomeActive reads correct value from UserDefaults.
+    } else if (state == AppLifecycleState.paused && _unreadCountsLoaded) {
+      // Set badge to total unread count when going to background
+      _updateBadgeFromUnreads();
     }
+  }
+
+  void _updateBadgeFromUnreads() {
+    final counts = ref.read(unreadCountsProvider);
+    _persistUnreadCounts(counts);
   }
 
   bool _isLoadingMore = false;
@@ -675,6 +696,18 @@
     final counts = Map<String, int>.from(ref.read(unreadCountsProvider));
     counts[sessionId] = (counts[sessionId] ?? 0) + 1;
     ref.read(unreadCountsProvider.notifier).state = counts;
+    _persistUnreadCounts(counts);
+  }
+
+  void _persistUnreadCounts(Map<String, int> counts) {
+    final total = counts.values.fold<int>(0, (sum, v) => sum + v);
+    // Set badge immediately via platform channel (synchronous native call)
+    PushService.setBadge(total);
+    // Also persist to SharedPreferences for app restart
+    SharedPreferences.getInstance().then((prefs) {
+      prefs.setString('unreadCounts', jsonEncode(counts));
+      prefs.setInt('badgeCount', total);
+    });
   }
 
   Future<void> _switchSession(String sessionId) async {
@@ -691,6 +724,10 @@
     final counts = Map<String, int>.from(ref.read(unreadCountsProvider));
     counts.remove(sessionId);
     ref.read(unreadCountsProvider.notifier).state = counts;
+    _persistUnreadCounts(counts);
+
+    // Update badge to reflect remaining unreads
+    _updateBadgeFromUnreads();
 
     _sendCommand('switch', {'sessionId': sessionId});
     _scrollToBottom();
diff --git a/lib/services/push_service.dart b/lib/services/push_service.dart
index 6f9b306..b246a6b 100644
--- a/lib/services/push_service.dart
+++ b/lib/services/push_service.dart
@@ -1,5 +1,5 @@
 import 'package:flutter/foundation.dart';
-import 'package:flutter/services.dart';
+import 'package:flutter_app_badger/flutter_app_badger.dart';
 import 'package:push/push.dart';
 
 import 'mqtt_service.dart';
@@ -95,14 +95,16 @@
 
   /// Clear the app icon badge number.
   static void clearBadge() {
-    try {
-      // Use UIApplication.shared.applicationIconBadgeNumber = 0 via platform channel
-      const platform = MethodChannel('com.tekmidian.pailot/badge');
-      platform.invokeMethod('clearBadge').catchError((_) {
-        // Fallback: UNUserNotificationCenter approach
-        debugPrint('[Push] clearBadge via platform channel failed, using Push API');
-      });
-    } catch (_) {}
+    FlutterAppBadger.removeBadge();
+  }
+
+  /// Set the app icon badge to a specific count.
+  static void setBadge(int count) {
+    if (count <= 0) {
+      FlutterAppBadger.removeBadge();
+    } else {
+      FlutterAppBadger.updateBadgeCount(count);
+    }
   }
 
   /// Publish the device token to the daemon via MQTT.
diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift
index d0cd42a..0a6522c 100644
--- a/macos/Flutter/GeneratedPluginRegistrant.swift
+++ b/macos/Flutter/GeneratedPluginRegistrant.swift
@@ -10,6 +10,7 @@
 import device_info_plus
 import file_picker
 import file_selector_macos
+import flutter_app_badger
 import flutter_secure_storage_macos
 import push
 import record_macos
@@ -22,6 +23,7 @@
   DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
   FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
   FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
+  FlutterAppBadgerPlugin.register(with: registry.registrar(forPlugin: "FlutterAppBadgerPlugin"))
   FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
   PushPlugin.register(with: registry.registrar(forPlugin: "PushPlugin"))
   RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
diff --git a/pubspec.lock b/pubspec.lock
index 006418e..60df114 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -294,6 +294,14 @@
     description: flutter
     source: sdk
     version: "0.0.0"
+  flutter_app_badger:
+    dependency: "direct main"
+    description:
+      name: flutter_app_badger
+      sha256: "64d4a279bab862ed28850431b9b446b9820aaae0bf363322d51077419f930fa8"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.5.0"
   flutter_lints:
     dependency: "direct dev"
     description:
diff --git a/pubspec.yaml b/pubspec.yaml
index c940897..ef8929f 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -32,6 +32,7 @@
   bonsoir: ^6.0.2
   crypto: ^3.0.7
   push: ^3.3.3
+  flutter_app_badger: ^1.5.0
 
 dev_dependencies:
   flutter_test:

--
Gitblit v1.3.1