diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 36869a1..89cf28f 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -9,7 +9,8 @@ "Bash(dart pub global run intl_utils:generate:*)", "Bash(dart pub global deactivate:*)", "Bash(flutter precache:*)", - "Bash(flutter analyze:*)" + "Bash(flutter analyze:*)", + "Bash(flutter pub get:*)" ], "deny": [], "ask": [] diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index c2ae9b8..c513bc6 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -18,6 +18,12 @@ + + + + + + dev.flutter.background.refresh + UIUserNotificationSettings + + UIUserNotificationTypesEnabled + + UIUserNotificationTypeAlert + UIUserNotificationTypeBadge + UIUserNotificationTypeSound + + diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index dfdb6c1..308edb5 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1698,5 +1698,40 @@ "failed": "Failed", "@failed": { "description": "Delivery status: failed" + }, + + "sarMarkerFoundPerson": "Found Person", + "@sarMarkerFoundPerson": { + "description": "SAR marker type: found person" + }, + + "sarMarkerFire": "Fire Location", + "@sarMarkerFire": { + "description": "SAR marker type: fire" + }, + + "sarMarkerStagingArea": "Staging Area", + "@sarMarkerStagingArea": { + "description": "SAR marker type: staging area" + }, + + "sarMarkerObject": "Object Found", + "@sarMarkerObject": { + "description": "SAR marker type: object" + }, + + "from": "From", + "@from": { + "description": "Sender label in notifications" + }, + + "coordinates": "Coordinates", + "@coordinates": { + "description": "Coordinates label" + }, + + "tapToViewOnMap": "Tap to view on map", + "@tapToViewOnMap": { + "description": "Notification action text" } } diff --git a/lib/l10n/app_hr.arb b/lib/l10n/app_hr.arb index 6e4024a..566b01e 100644 --- a/lib/l10n/app_hr.arb +++ b/lib/l10n/app_hr.arb @@ -567,5 +567,19 @@ "messageDeleted": "Poruka izbrisana", - "refreshedContacts": "Kontakti osvježeni" + "refreshedContacts": "Kontakti osvježeni", + + "sarMarkerFoundPerson": "Pronađena osoba", + + "sarMarkerFire": "Lokacija požara", + + "sarMarkerStagingArea": "Zbirno mjesto", + + "sarMarkerObject": "Pronađen objekt", + + "from": "Od", + + "coordinates": "Koordinate", + + "tapToViewOnMap": "Dodirnite za prikaz na karti" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 9ecdb22..39a94a2 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1808,6 +1808,48 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Failed'** String get failed; + + /// SAR marker type: found person + /// + /// In en, this message translates to: + /// **'Found Person'** + String get sarMarkerFoundPerson; + + /// SAR marker type: fire + /// + /// In en, this message translates to: + /// **'Fire Location'** + String get sarMarkerFire; + + /// SAR marker type: staging area + /// + /// In en, this message translates to: + /// **'Staging Area'** + String get sarMarkerStagingArea; + + /// SAR marker type: object + /// + /// In en, this message translates to: + /// **'Object Found'** + String get sarMarkerObject; + + /// Sender label in notifications + /// + /// In en, this message translates to: + /// **'From'** + String get from; + + /// Coordinates label + /// + /// In en, this message translates to: + /// **'Coordinates'** + String get coordinates; + + /// Notification action text + /// + /// In en, this message translates to: + /// **'Tap to view on map'** + String get tapToViewOnMap; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index a336d27..1b19817 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -988,4 +988,25 @@ class AppLocalizationsEn extends AppLocalizations { @override String get failed => 'Failed'; + + @override + String get sarMarkerFoundPerson => 'Found Person'; + + @override + String get sarMarkerFire => 'Fire Location'; + + @override + String get sarMarkerStagingArea => 'Staging Area'; + + @override + String get sarMarkerObject => 'Object Found'; + + @override + String get from => 'From'; + + @override + String get coordinates => 'Coordinates'; + + @override + String get tapToViewOnMap => 'Tap to view on map'; } diff --git a/lib/l10n/app_localizations_hr.dart b/lib/l10n/app_localizations_hr.dart index 47f310a..63c1606 100644 --- a/lib/l10n/app_localizations_hr.dart +++ b/lib/l10n/app_localizations_hr.dart @@ -990,4 +990,25 @@ class AppLocalizationsHr extends AppLocalizations { @override String get failed => 'Neuspjelo'; + + @override + String get sarMarkerFoundPerson => 'Pronađena osoba'; + + @override + String get sarMarkerFire => 'Lokacija požara'; + + @override + String get sarMarkerStagingArea => 'Zbirno mjesto'; + + @override + String get sarMarkerObject => 'Pronađen objekt'; + + @override + String get from => 'Od'; + + @override + String get coordinates => 'Koordinate'; + + @override + String get tapToViewOnMap => 'Dodirnite za prikaz na karti'; } diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index c5dee63..c516629 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -990,4 +990,25 @@ class AppLocalizationsSl extends AppLocalizations { @override String get failed => 'Neuspešno'; + + @override + String get sarMarkerFoundPerson => 'Najdena oseba'; + + @override + String get sarMarkerFire => 'Lokacija ognja'; + + @override + String get sarMarkerStagingArea => 'Zbirališče'; + + @override + String get sarMarkerObject => 'Najden predmet'; + + @override + String get from => 'Od'; + + @override + String get coordinates => 'Koordinate'; + + @override + String get tapToViewOnMap => 'Tapnite za prikaz na zemljevidu'; } diff --git a/lib/l10n/app_sl.arb b/lib/l10n/app_sl.arb index ea4ae9e..e43b2c3 100644 --- a/lib/l10n/app_sl.arb +++ b/lib/l10n/app_sl.arb @@ -567,5 +567,19 @@ "messageDeleted": "Sporočilo izbrisano", - "refreshedContacts": "Stiki osveženi" + "refreshedContacts": "Stiki osveženi", + + "sarMarkerFoundPerson": "Najdena oseba", + + "sarMarkerFire": "Lokacija ognja", + + "sarMarkerStagingArea": "Zbirališče", + + "sarMarkerObject": "Najden predmet", + + "from": "Od", + + "coordinates": "Koordinate", + + "tapToViewOnMap": "Tapnite za prikaz na zemljevidu" } diff --git a/lib/main.dart b/lib/main.dart index 1c23f07..6f7cddf 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -9,6 +9,7 @@ import 'providers/map_provider.dart'; import 'providers/drawing_provider.dart'; import 'providers/app_provider.dart'; import 'services/tile_cache_service.dart'; +import 'services/notification_service.dart'; import 'services/locale_preferences.dart'; import 'screens/home_screen.dart'; import 'theme/app_theme.dart'; @@ -39,6 +40,8 @@ class _MeshCoreSarAppState extends State { Future _initializeApp() async { await _loadThemePreference(); await _loadLocalePreference(); + // Initialize notification service + await NotificationService().initialize(); setState(() { _isInitialized = true; }); diff --git a/lib/providers/messages_provider.dart b/lib/providers/messages_provider.dart index 855f0dc..0ff7997 100644 --- a/lib/providers/messages_provider.dart +++ b/lib/providers/messages_provider.dart @@ -4,14 +4,18 @@ import 'package:flutter/foundation.dart'; import '../models/message.dart'; import '../models/sar_marker.dart'; import '../services/message_storage_service.dart'; +import '../services/notification_service.dart'; import '../utils/sar_message_parser.dart'; +import '../l10n/app_localizations.dart'; /// Messages Provider - manages message history and SAR markers class MessagesProvider with ChangeNotifier { final List _messages = []; final Map _sarMarkers = {}; final MessageStorageService _storageService = MessageStorageService(); + final NotificationService _notificationService = NotificationService(); bool _isInitialized = false; + AppLocalizations? _localizations; // Track pending sent messages by expected ACK/TAG final Map _pendingSentMessages = {}; @@ -49,6 +53,11 @@ class MessagesProvider with ChangeNotifier { bool get isInitialized => _isInitialized; + /// Set localizations for notifications + void setLocalizations(AppLocalizations localizations) { + _localizations = localizations; + } + /// Get count of unread messages (excluding sent messages and system messages) int get unreadCount => _messages .where((m) => @@ -146,6 +155,11 @@ class MessagesProvider with ChangeNotifier { final marker = finalMessage.toSarMarker(); if (marker != null) { _sarMarkers[marker.id] = marker; + + // Trigger urgent notification for received SAR messages (not sent by user) + if (!finalMessage.isSentMessage) { + _triggerSarNotification(finalMessage, marker); + } } } @@ -231,6 +245,31 @@ class MessagesProvider with ChangeNotifier { notifyListeners(); } + /// Trigger urgent notification for SAR marker + Future _triggerSarNotification(Message message, SarMarker marker) async { + try { + // Format coordinates + final coords = '${marker.location.latitude.toStringAsFixed(5)}, ${marker.location.longitude.toStringAsFixed(5)}'; + + // Get sender name from message + final senderName = message.senderName ?? message.senderKeyShort ?? 'Unknown'; + + print('🔔 [MessagesProvider] Triggering SAR notification for ${marker.type.displayName}'); + print(' Sender: $senderName'); + print(' Coordinates: $coords'); + + await _notificationService.showSarNotification( + type: marker.type, + senderName: senderName, + coordinates: coords, + notes: marker.notes, + localizations: _localizations, + ); + } catch (e) { + print('❌ [MessagesProvider] Error triggering SAR notification: $e'); + } + } + /// Persist messages to storage (async, non-blocking) Future _persistMessages() async { try { diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 49533f6..94b03c3 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -451,6 +451,13 @@ class _HomeScreenState extends State @override Widget build(BuildContext context) { + // Set localizations for notifications + final messagesProvider = context.read(); + final localizations = AppLocalizations.of(context); + if (localizations != null) { + messagesProvider.setLocalizations(localizations); + } + // Determine if we should hide the UI (only in fullscreen on map tab) final shouldHideUI = _isMapFullscreen && _currentIndex == 2; diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart new file mode 100644 index 0000000..16d57d6 --- /dev/null +++ b/lib/services/notification_service.dart @@ -0,0 +1,353 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:timezone/timezone.dart' as tz; +import 'package:timezone/data/latest_all.dart' as tz; +import '../models/sar_marker.dart'; +import '../l10n/app_localizations.dart'; + +/// Notification Service - manages urgent notifications for SAR messages +/// Provides critical alert functionality for SAR marker events +class NotificationService { + static final NotificationService _instance = NotificationService._internal(); + factory NotificationService() => _instance; + NotificationService._internal(); + + final FlutterLocalNotificationsPlugin _notificationsPlugin = + FlutterLocalNotificationsPlugin(); + + bool _isInitialized = false; + bool _permissionGranted = false; + + // Notification IDs + static const int _sarNotificationId = 1000; + + // Notification channels + static const String _urgentChannelId = 'sar_urgent'; + static const String _urgentChannelName = 'SAR Urgent Alerts'; + static const String _urgentChannelDescription = + 'Critical alerts for SAR markers (found persons, fires, staging areas)'; + + /// Initialize notification service + Future initialize() async { + if (_isInitialized) return; + + try { + print('📬 [NotificationService] Initializing...'); + + // Initialize timezone data + tz.initializeTimeZones(); + + // Android initialization settings + const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher'); + + // iOS initialization settings + final darwinSettings = DarwinInitializationSettings( + requestAlertPermission: true, + requestBadgePermission: true, + requestSoundPermission: true, + requestCriticalPermission: true, // For urgent SAR notifications + ); + + // Combined initialization settings + final initSettings = InitializationSettings( + android: androidSettings, + iOS: darwinSettings, + ); + + // Initialize plugin + await _notificationsPlugin.initialize( + initSettings, + onDidReceiveNotificationResponse: _onNotificationResponse, + ); + + // Request permissions + await _requestPermissions(); + + // Create notification channels (Android) + await _createNotificationChannels(); + + _isInitialized = true; + print('✅ [NotificationService] Initialized successfully'); + print(' Permission granted: $_permissionGranted'); + } catch (e) { + print('❌ [NotificationService] Initialization error: $e'); + } + } + + /// Request notification permissions + Future _requestPermissions() async { + try { + // iOS permissions + final iosPlugin = _notificationsPlugin + .resolvePlatformSpecificImplementation(); + if (iosPlugin != null) { + final granted = await iosPlugin.requestPermissions( + alert: true, + badge: true, + sound: true, + critical: true, // Request critical alert permission for urgent SAR notifications + ); + _permissionGranted = granted ?? false; + print('📱 [NotificationService] iOS permissions granted: $_permissionGranted'); + } + + // Android 13+ permissions + final androidPlugin = _notificationsPlugin + .resolvePlatformSpecificImplementation(); + if (androidPlugin != null) { + final granted = await androidPlugin.requestNotificationsPermission(); + _permissionGranted = granted ?? false; + print('🤖 [NotificationService] Android permissions granted: $_permissionGranted'); + } + } catch (e) { + print('⚠️ [NotificationService] Error requesting permissions: $e'); + } + } + + /// Create notification channels for Android + Future _createNotificationChannels() async { + try { + final androidPlugin = _notificationsPlugin + .resolvePlatformSpecificImplementation(); + + if (androidPlugin == null) return; + + // Urgent SAR channel with maximum priority + const urgentChannel = AndroidNotificationChannel( + _urgentChannelId, + _urgentChannelName, + description: _urgentChannelDescription, + importance: Importance.max, + playSound: true, + enableVibration: true, + enableLights: true, + showBadge: true, + sound: RawResourceAndroidNotificationSound('notification'), + ); + + await androidPlugin.createNotificationChannel(urgentChannel); + print('✅ [NotificationService] Created urgent notification channel'); + } catch (e) { + print('⚠️ [NotificationService] Error creating channels: $e'); + } + } + + /// Handle notification tap (foreground) + void _onNotificationResponse(NotificationResponse response) { + print('🔔 [NotificationService] Notification tapped: ${response.payload}'); + // TODO: Navigate to map tab and show SAR marker + // This would require a callback to the app layer + } + + /// Show urgent notification for SAR marker + Future showSarNotification({ + required SarMarkerType type, + required String senderName, + required String coordinates, + String? notes, + AppLocalizations? localizations, + }) async { + if (!_isInitialized) { + print('⚠️ [NotificationService] Not initialized, skipping notification'); + return; + } + + if (!_permissionGranted) { + print('⚠️ [NotificationService] Permission not granted, skipping notification'); + return; + } + + try { + // Generate unique notification ID based on timestamp + final notificationId = _sarNotificationId + (DateTime.now().millisecondsSinceEpoch % 1000); + + // Build notification title and body + final title = _buildNotificationTitle(type, localizations); + final body = _buildNotificationBody( + type: type, + senderName: senderName, + coordinates: coordinates, + notes: notes, + localizations: localizations, + ); + + // Android notification details + final androidDetails = AndroidNotificationDetails( + _urgentChannelId, + _urgentChannelName, + channelDescription: _urgentChannelDescription, + importance: Importance.max, + priority: Priority.high, + ticker: title, + playSound: true, + enableVibration: true, + enableLights: true, + color: Color(_getNotificationColor(type)), + colorized: true, + showWhen: true, + when: DateTime.now().millisecondsSinceEpoch, + category: AndroidNotificationCategory.alarm, // High priority category + fullScreenIntent: true, // Show as full screen on some devices + styleInformation: BigTextStyleInformation( + body, + contentTitle: title, + summaryText: _getSummaryText(type, localizations), + ), + ); + + // iOS notification details + final darwinDetails = DarwinNotificationDetails( + presentAlert: true, + presentBadge: true, + presentSound: true, + sound: 'default', + badgeNumber: 1, + threadIdentifier: 'sar_markers', + categoryIdentifier: 'SAR_ALERT', + interruptionLevel: InterruptionLevel.critical, // Critical alert (bypasses silent mode) + ); + + // Combined notification details + final notificationDetails = NotificationDetails( + android: androidDetails, + iOS: darwinDetails, + ); + + // Show notification + await _notificationsPlugin.show( + notificationId, + title, + body, + notificationDetails, + payload: 'sar:${type.name}:$coordinates', + ); + + print('✅ [NotificationService] Showed SAR notification: $title'); + print(' Type: ${type.displayName}'); + print(' Sender: $senderName'); + print(' Coordinates: $coordinates'); + } catch (e) { + print('❌ [NotificationService] Error showing notification: $e'); + } + } + + /// Build notification title based on SAR marker type + String _buildNotificationTitle(SarMarkerType type, AppLocalizations? localizations) { + if (localizations == null) { + return '🚨 ${type.displayName} Detected'; + } + + switch (type) { + case SarMarkerType.foundPerson: + return '🚨 ${localizations.sarMarkerFoundPerson}'; + case SarMarkerType.fire: + return '🚨 ${localizations.sarMarkerFire}'; + case SarMarkerType.stagingArea: + return '🚨 ${localizations.sarMarkerStagingArea}'; + case SarMarkerType.object: + return '🚨 ${localizations.sarMarkerObject}'; + case SarMarkerType.unknown: + return '🚨 SAR Alert'; + } + } + + /// Build notification body with all details + String _buildNotificationBody({ + required SarMarkerType type, + required String senderName, + required String coordinates, + String? notes, + AppLocalizations? localizations, + }) { + final buffer = StringBuffer(); + + // Sender + if (localizations != null) { + buffer.write('${localizations.from}: $senderName\n'); + buffer.write('${localizations.coordinates}: $coordinates'); + } else { + buffer.write('From: $senderName\n'); + buffer.write('Coordinates: $coordinates'); + } + + // Optional notes + if (notes != null && notes.isNotEmpty) { + buffer.write('\n\n$notes'); + } + + return buffer.toString(); + } + + /// Get summary text for notification + String _getSummaryText(SarMarkerType type, AppLocalizations? localizations) { + if (localizations == null) { + return 'Tap to view on map'; + } + return localizations.tapToViewOnMap; + } + + /// Get notification color based on SAR marker type + int _getNotificationColor(SarMarkerType type) { + // Return ARGB color codes + switch (type) { + case SarMarkerType.foundPerson: + return 0xFF4CAF50; // Green + case SarMarkerType.fire: + return 0xFFF44336; // Red + case SarMarkerType.stagingArea: + return 0xFFFF9800; // Orange + case SarMarkerType.object: + return 0xFF2196F3; // Blue + case SarMarkerType.unknown: + return 0xFF9E9E9E; // Gray + } + } + + /// Cancel all notifications + Future cancelAll() async { + try { + await _notificationsPlugin.cancelAll(); + print('✅ [NotificationService] Cancelled all notifications'); + } catch (e) { + print('❌ [NotificationService] Error canceling notifications: $e'); + } + } + + /// Cancel specific notification + Future cancel(int id) async { + try { + await _notificationsPlugin.cancel(id); + print('✅ [NotificationService] Cancelled notification: $id'); + } catch (e) { + print('❌ [NotificationService] Error canceling notification: $e'); + } + } + + /// Check if notifications are enabled + Future areNotificationsEnabled() async { + try { + final androidPlugin = _notificationsPlugin + .resolvePlatformSpecificImplementation(); + if (androidPlugin != null) { + final enabled = await androidPlugin.areNotificationsEnabled(); + return enabled ?? false; + } + + // For iOS, assume enabled if permission was granted + return _permissionGranted; + } catch (e) { + print('⚠️ [NotificationService] Error checking notification status: $e'); + return false; + } + } + + /// Get pending notifications + Future> getPendingNotifications() async { + try { + return await _notificationsPlugin.pendingNotificationRequests(); + } catch (e) { + print('⚠️ [NotificationService] Error getting pending notifications: $e'); + return []; + } + } +} diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 11ab6c9..dc9b76e 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,6 +6,7 @@ import FlutterMacOS import Foundation import flutter_blue_plus_darwin +import flutter_local_notifications import geolocator_apple import objectbox_flutter_libs import package_info_plus @@ -15,6 +16,7 @@ import shared_preferences_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterBluePlusPlugin.register(with: registry.registrar(forPlugin: "FlutterBluePlusPlugin")) + FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) ObjectboxFlutterLibsPlugin.register(with: registry.registrar(forPlugin: "ObjectboxFlutterLibsPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) diff --git a/pubspec.lock b/pubspec.lock index cf5418d..023f654 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -246,6 +246,30 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_local_notifications: + dependency: "direct main" + description: + name: flutter_local_notifications + sha256: ef41ae901e7529e52934feba19ed82827b11baa67336829564aeab3129460610 + url: "https://pub.dev" + source: hosted + version: "18.0.1" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + sha256: "8f685642876742c941b29c32030f6f4f6dacd0e4eaecb3efbb187d6a3812ca01" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + sha256: "6c5b83c86bf819cdb177a9247a3722067dd8cc6313827ce7c77a4b238a26fd52" + url: "https://pub.dev" + source: hosted + version: "8.0.0" flutter_localizations: dependency: "direct main" description: flutter @@ -794,6 +818,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.6" + timezone: + dependency: transitive + description: + name: timezone + sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1 + url: "https://pub.dev" + source: hosted + version: "0.10.1" typed_data: dependency: transitive description: