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