import 'dart:ui'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter/foundation.dart'; import '../l10n/app_localizations.dart'; class NotificationService { static final NotificationService _instance = NotificationService._internal(); factory NotificationService() => _instance; NotificationService._internal(); final FlutterLocalNotificationsPlugin _notifications = FlutterLocalNotificationsPlugin(); bool _isInitialized = false; // Locale for localized notification strings Locale _locale = const Locale('en'); /// Set the locale for notification strings (call when app locale changes) void setLocale(Locale locale) { _locale = locale; } AppLocalizations get _l10n => lookupAppLocalizations(_locale); // Rate limiting to prevent notification storms // (Added after getting notification-flooded while evaluating RF flood management. The irony.) static const _minNotificationInterval = Duration(seconds: 3); static const _batchWindow = Duration(seconds: 5); DateTime? _lastNotificationTime; final List<_PendingNotification> _pendingNotifications = []; bool _isBatchingActive = false; bool _suppressNotifications = false; /// Temporarily suppress all notifications (e.g., during sync) void suppressNotifications(bool suppress) { _suppressNotifications = suppress; if (suppress) { _pendingNotifications.clear(); } } Future initialize() async { if (_isInitialized) return; const androidSettings = AndroidInitializationSettings( '@mipmap/ic_launcher', ); const iosSettings = DarwinInitializationSettings( requestAlertPermission: true, requestBadgePermission: true, requestSoundPermission: true, ); const macSettings = DarwinInitializationSettings( requestAlertPermission: true, requestBadgePermission: true, requestSoundPermission: true, ); const initSettings = InitializationSettings( android: androidSettings, iOS: iosSettings, macOS: macSettings, ); try { await _notifications.initialize( initSettings, onDidReceiveNotificationResponse: _onNotificationTapped, ); _isInitialized = true; } catch (e) { debugPrint('Error initializing notifications: $e'); } } Future requestPermissions() async { if (!_isInitialized) { await initialize(); } // Request Android 13+ notification permission final androidPlugin = _notifications .resolvePlatformSpecificImplementation< AndroidFlutterLocalNotificationsPlugin >(); if (androidPlugin != null) { final granted = await androidPlugin.requestNotificationsPermission(); return granted ?? false; } // iOS permissions are requested during initialization final iosPlugin = _notifications .resolvePlatformSpecificImplementation< IOSFlutterLocalNotificationsPlugin >(); if (iosPlugin != null) { final granted = await iosPlugin.requestPermissions( alert: true, badge: true, sound: true, ); return granted ?? false; } return true; } Future _showMessageNotificationImpl({ required String contactName, required String message, String? contactId, int? badgeCount, }) async { if (!_isInitialized) { await initialize(); } final androidDetails = AndroidNotificationDetails( 'messages', 'Messages', channelDescription: 'New message notifications', importance: Importance.high, priority: Priority.high, icon: '@mipmap/ic_launcher', number: badgeCount, ); final iosDetails = DarwinNotificationDetails( presentAlert: true, presentBadge: true, presentSound: true, badgeNumber: badgeCount, ); final macDetails = DarwinNotificationDetails( presentAlert: true, presentBadge: true, presentSound: true, badgeNumber: badgeCount, ); final notificationDetails = NotificationDetails( android: androidDetails, iOS: iosDetails, macOS: macDetails, ); await _notifications.show( contactId?.hashCode ?? 0, contactName, message, notificationDetails, payload: 'message:$contactId', ); } Future _showAdvertNotificationImpl({ required String contactName, required String contactType, String? contactId, }) async { if (!_isInitialized) { await initialize(); } const androidDetails = AndroidNotificationDetails( 'adverts', 'Advertisements', channelDescription: 'New node advertisement notifications', importance: Importance.defaultImportance, priority: Priority.defaultPriority, icon: '@mipmap/ic_launcher', ); const iosDetails = DarwinNotificationDetails( presentAlert: true, presentBadge: true, presentSound: true, ); const macDetails = DarwinNotificationDetails( presentAlert: true, presentBadge: true, presentSound: true, ); const notificationDetails = NotificationDetails( android: androidDetails, iOS: iosDetails, macOS: macDetails, ); await _notifications.show( contactId?.hashCode ?? DateTime.now().millisecondsSinceEpoch, _l10n.notification_newTypeDiscovered(contactType), contactName, notificationDetails, payload: 'advert:$contactId', ); } Future _showChannelMessageNotificationImpl({ required String channelName, required String message, int? channelIndex, int? badgeCount, }) async { if (!_isInitialized) { await initialize(); } final androidDetails = AndroidNotificationDetails( 'channel_messages', 'Channel Messages', channelDescription: 'New channel message notifications', importance: Importance.high, priority: Priority.high, icon: '@mipmap/ic_launcher', number: badgeCount, ); final iosDetails = DarwinNotificationDetails( presentAlert: true, presentBadge: true, presentSound: true, badgeNumber: badgeCount, ); final macDetails = DarwinNotificationDetails( presentAlert: true, presentBadge: true, presentSound: true, badgeNumber: badgeCount, ); final notificationDetails = NotificationDetails( android: androidDetails, iOS: iosDetails, macOS: macDetails, ); final preview = message.trim(); final body = preview.isEmpty ? _l10n.notification_receivedNewMessage : preview; await _notifications.show( channelIndex?.hashCode ?? DateTime.now().millisecondsSinceEpoch, channelName, body, notificationDetails, payload: 'channel:$channelIndex', ); } /// Returns a privacy-safe identifier for debug logging. /// - advert: shows device name (body contains contactName) /// - message: shows "from: sender" (avoids logging message content) /// - channelMessage: shows "in: channel" (avoids logging message content) String _getNotificationIdentifier(_PendingNotification n) { switch (n.type) { case _NotificationType.advert: return n.body; case _NotificationType.message: return 'from: ${n.title}'; case _NotificationType.channelMessage: return 'in: ${n.title}'; } } void _onNotificationTapped(NotificationResponse response) { final payload = response.payload; if (payload != null) { debugPrint('Notification tapped: $payload'); // Handle navigation based on payload // This can be extended to navigate to specific screens } } Future cancelAll() async { await _notifications.cancelAll(); } Future cancel(int id) async { await _notifications.cancel(id); } // ───────────────────────────────────────────────────────────────── // Public notification methods (rate limiting is enforced automatically) // ───────────────────────────────────────────────────────────────── Future showMessageNotification({ required String contactName, required String message, String? contactId, int? badgeCount, }) async { if (_suppressNotifications) return; _queueNotification( _PendingNotification( type: _NotificationType.message, title: contactName, body: message, id: contactId, badgeCount: badgeCount, ), ); } Future showAdvertNotification({ required String contactName, required String contactType, String? contactId, }) async { if (_suppressNotifications) return; _queueNotification( _PendingNotification( type: _NotificationType.advert, title: contactType, body: contactName, id: contactId, ), ); } Future showChannelMessageNotification({ required String channelName, required String message, int? channelIndex, int? badgeCount, }) async { if (_suppressNotifications) return; _queueNotification( _PendingNotification( type: _NotificationType.channelMessage, title: channelName, body: message, id: channelIndex?.toString(), badgeCount: badgeCount, ), ); } void _queueNotification(_PendingNotification notification) { final now = DateTime.now(); // If we recently showed a notification, start batching if (_lastNotificationTime != null && now.difference(_lastNotificationTime!) < _minNotificationInterval) { _pendingNotifications.add(notification); debugPrint( '[Notification] queued: ${notification.type.name} (${_getNotificationIdentifier(notification)})', ); // Start batch timer if not already running if (!_isBatchingActive) { _isBatchingActive = true; Future.delayed(_batchWindow, _processBatch); } return; } // Show immediately if enough time has passed debugPrint( '[Notification] sent immediately: ${notification.type.name} (${_getNotificationIdentifier(notification)})', ); _showNotificationImmediately(notification); _lastNotificationTime = now; } Future _processBatch() async { _isBatchingActive = false; if (_pendingNotifications.isEmpty) return; final batch = List<_PendingNotification>.from(_pendingNotifications); _pendingNotifications.clear(); if (batch.length == 1) { // Single notification, show normally _showNotificationImmediately(batch.first); } else { // Multiple notifications, show summary await _showBatchSummary(batch); } _lastNotificationTime = DateTime.now(); } Future _showNotificationImmediately( _PendingNotification notification, ) async { switch (notification.type) { case _NotificationType.message: await _showMessageNotificationImpl( contactName: notification.title, message: notification.body, contactId: notification.id, badgeCount: notification.badgeCount, ); break; case _NotificationType.advert: await _showAdvertNotificationImpl( contactName: notification.body, contactType: notification.title, contactId: notification.id, ); break; case _NotificationType.channelMessage: await _showChannelMessageNotificationImpl( channelName: notification.title, message: notification.body, channelIndex: int.tryParse(notification.id ?? ''), badgeCount: notification.badgeCount, ); break; } } Future _showBatchSummary(List<_PendingNotification> batch) async { if (!_isInitialized) await initialize(); // Group by type final messages = batch .where((n) => n.type == _NotificationType.message) .toList(); final adverts = batch .where((n) => n.type == _NotificationType.advert) .toList(); final channelMsgs = batch .where((n) => n.type == _NotificationType.channelMessage) .toList(); // Build summary text using localized plurals final parts = []; if (messages.isNotEmpty) { parts.add(_l10n.notification_messagesCount(messages.length)); } if (channelMsgs.isNotEmpty) { parts.add(_l10n.notification_channelMessagesCount(channelMsgs.length)); } if (adverts.isNotEmpty) { parts.add(_l10n.notification_newNodesCount(adverts.length)); } if (parts.isEmpty) return; // Show first few device names in batch summary for debugging (only if adverts exist) final deviceInfo = adverts.isNotEmpty ? ' (${adverts.take(5).map((n) => n.body).join(', ')}${adverts.length > 5 ? ', ...' : ''})' : ''; debugPrint('[Notification] batch summary: ${parts.join(", ")}$deviceInfo'); const androidDetails = AndroidNotificationDetails( 'batch_summary', 'Activity Summary', channelDescription: 'Batched notification summaries', importance: Importance.defaultImportance, priority: Priority.defaultPriority, icon: '@mipmap/ic_launcher', ); const notificationDetails = NotificationDetails(android: androidDetails); await _notifications.show( 'batch_summary'.hashCode, _l10n.notification_activityTitle, parts.join(', '), notificationDetails, payload: 'batch', ); } } // Helper class for pending notifications enum _NotificationType { message, advert, channelMessage } class _PendingNotification { final _NotificationType type; final String title; final String body; final String? id; final int? badgeCount; _PendingNotification({ required this.type, required this.title, required this.body, this.id, this.badgeCount, }); }