import 'dart:convert'; import 'dart:io'; import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:real_estate_mobile/features/notifications/data/notification_repository.dart'; /// Top-level handler for background messages (must be top-level function). @pragma('vm:entry-point') Future _firebaseMessagingBackgroundHandler(RemoteMessage message) async { await Firebase.initializeApp(); debugPrint('[FCM] Background message: ${message.messageId}'); } class PushNotificationService { static final PushNotificationService _instance = PushNotificationService._internal(); factory PushNotificationService() => _instance; PushNotificationService._internal(); FirebaseMessaging? _messaging; FlutterLocalNotificationsPlugin? _localNotifications; final NotificationRepository _repo = NotificationRepository(); String? _currentToken; bool _initialized = false; /// Callback when user taps a notification — set by the app to navigate. /// Receives the full FCM data payload so the app can route by `type` /// (e.g. 'message' → open chat) instead of only relying on `actionUrl`. /// /// When this setter is assigned and a pending tap was buffered (because the /// terminated-state tap fired before the app UI was ready), the buffered /// tap is replayed immediately. void Function(Map data)? _onNotificationTap; Map? _pendingTap; set onNotificationTap(void Function(Map data)? handler) { _onNotificationTap = handler; // Replay any buffered tap once a listener is attached if (handler != null && _pendingTap != null) { final buffered = _pendingTap!; _pendingTap = null; // Defer to next microtask so the caller's initState finishes first Future.microtask(() => handler(buffered)); } } void Function(Map data)? get onNotificationTap => _onNotificationTap; void _dispatchTap(Map data) { final handler = _onNotificationTap; if (handler != null) { handler(data); } else { // No listener yet — buffer for replay once one attaches _pendingTap = data; } } /// The conversation ID currently being viewed — suppress message push /// notifications for this conversation to avoid self-notifications. String? activeConversationId; /// Initialize Firebase Messaging and local notifications. Future initialize() async { if (_initialized) return; _initialized = true; _messaging = FirebaseMessaging.instance; _localNotifications = FlutterLocalNotificationsPlugin(); // Register background handler FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler); // Request permission final settings = await _messaging!.requestPermission( alert: true, badge: true, sound: true, provisional: false, ); debugPrint('[FCM] Permission: ${settings.authorizationStatus}'); if (settings.authorizationStatus == AuthorizationStatus.denied) { return; } // Setup local notifications for foreground display await _setupLocalNotifications(); // Get token and register with backend await _getAndRegisterToken(); // Listen for token refresh _messaging!.onTokenRefresh.listen(_onTokenRefresh); // Handle foreground messages FirebaseMessaging.onMessage.listen(_onForegroundMessage); // Handle notification taps (app was in background) FirebaseMessaging.onMessageOpenedApp.listen(_onMessageOpenedApp); // Check if app was opened from a terminated state notification final initialMessage = await _messaging!.getInitialMessage(); if (initialMessage != null) { _onMessageOpenedApp(initialMessage); } // iOS: set foreground notification presentation options if (Platform.isIOS) { await _messaging!.setForegroundNotificationPresentationOptions( alert: true, badge: true, sound: true, ); } } Future _setupLocalNotifications() async { const androidChannel = AndroidNotificationChannel( 'high_importance_channel', 'High Importance Notifications', description: 'Used for important notifications', importance: Importance.high, ); await _localNotifications! .resolvePlatformSpecificImplementation< AndroidFlutterLocalNotificationsPlugin>() ?.createNotificationChannel(androidChannel); const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher'); const iosSettings = DarwinInitializationSettings( requestAlertPermission: false, requestBadgePermission: false, requestSoundPermission: false, ); await _localNotifications!.initialize( const InitializationSettings( android: androidSettings, iOS: iosSettings, ), onDidReceiveNotificationResponse: (response) { final payload = response.payload; if (payload == null || payload.isEmpty) { _dispatchTap(const {}); return; } try { final data = jsonDecode(payload) as Map; _dispatchTap(data); } catch (_) { // Legacy payloads may be a bare actionUrl string _dispatchTap({'actionUrl': payload}); } }, ); } Future _getAndRegisterToken() async { try { // On iOS, wait for APNS token before requesting FCM token. // Apple may not deliver it immediately — retry a few times. if (Platform.isIOS) { String? apnsToken; for (int attempt = 0; attempt < 5; attempt++) { apnsToken = await _messaging!.getAPNSToken(); if (apnsToken != null) break; debugPrint('[FCM] APNS token not available yet, retrying in ${attempt + 1}s...'); await Future.delayed(Duration(seconds: attempt + 1)); } if (apnsToken == null) { debugPrint('[FCM] APNS token not available after retries (simulator?), skipping FCM token registration'); return; } } final token = await _messaging!.getToken(); if (token != null) { _currentToken = token; debugPrint('[FCM] Token: $token'); await _registerTokenWithBackend(token); } } catch (e) { debugPrint('[FCM] Failed to get token: $e'); } } Future _onTokenRefresh(String token) async { debugPrint('[FCM] Token refreshed: $token'); _currentToken = token; await _registerTokenWithBackend(token); } Future _registerTokenWithBackend(String token) async { try { await _repo.registerFcmToken(token, Platform.isIOS ? 'ios' : 'android'); debugPrint('[FCM] Token registered with backend'); } catch (e) { debugPrint('[FCM] Failed to register token: $e'); } } /// Callback to refresh notification count when FCM arrives void Function()? onNotificationReceived; void _onForegroundMessage(RemoteMessage message) { debugPrint('[FCM] Foreground message: ${message.notification?.title}'); final notification = message.notification; if (notification == null) return; // Suppress message push notifications when user is viewing that conversation final msgType = message.data['type'] as String?; final convId = message.data['conversationId'] as String?; if (msgType == 'message' && convId != null && convId == activeConversationId) { debugPrint('[FCM] Suppressed notification — user is viewing conversation $convId'); return; } // Refresh notification count immediately onNotificationReceived?.call(); // Show local notification on Android (iOS handles it natively) if (Platform.isAndroid) { _localNotifications?.show( notification.hashCode, notification.title, notification.body, const NotificationDetails( android: AndroidNotificationDetails( 'high_importance_channel', 'High Importance Notifications', channelDescription: 'Used for important notifications', importance: Importance.high, priority: Priority.high, icon: '@mipmap/ic_launcher', ), ), // Encode the full FCM data as JSON so the tap handler can route // by `type` (e.g. open the specific chat for message notifications). payload: jsonEncode(message.data), ); } } void _onMessageOpenedApp(RemoteMessage message) { debugPrint('[FCM] Notification tap: ${message.data}'); _dispatchTap(Map.from(message.data)); } /// Unregister FCM token from backend (call on logout). Future unregister() async { if (_currentToken == null) return; try { await _repo.removeFcmToken(_currentToken!); debugPrint('[FCM] Token unregistered from backend'); } catch (e) { debugPrint('[FCM] Failed to unregister token: $e'); } _currentToken = null; } /// Re-register token after login. Future registerAfterLogin() async { await _getAndRegisterToken(); } }