Files
mobile-app/lib/core/services/push_notification_service.dart

284 lines
9.5 KiB
Dart

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<void> _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<String, dynamic> data)? _onNotificationTap;
Map<String, dynamic>? _pendingTap;
set onNotificationTap(void Function(Map<String, dynamic> 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<String, dynamic> data)? get onNotificationTap =>
_onNotificationTap;
void _dispatchTap(Map<String, dynamic> 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<void> 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<void> _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<String, dynamic>;
_dispatchTap(data);
} catch (_) {
// Legacy payloads may be a bare actionUrl string
_dispatchTap({'actionUrl': payload});
}
},
);
}
Future<void> _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<void> _onTokenRefresh(String token) async {
debugPrint('[FCM] Token refreshed: $token');
_currentToken = token;
await _registerTokenWithBackend(token);
}
Future<void> _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<String, dynamic>.from(message.data));
}
/// Unregister FCM token from backend and delete locally (call on logout).
Future<void> unregister() async {
// Remove from backend so it stops sending push to this device
if (_currentToken != null) {
try {
await _repo.removeFcmToken(_currentToken!);
debugPrint('[FCM] Token unregistered from backend');
} catch (e) {
debugPrint('[FCM] Failed to unregister token: $e');
}
}
_currentToken = null;
// Delete the local FCM token so Firebase stops delivering notifications
try {
await _messaging?.deleteToken();
debugPrint('[FCM] Local FCM token deleted');
} catch (e) {
debugPrint('[FCM] Failed to delete local token: $e');
}
}
/// Re-register token after login.
Future<void> registerAfterLogin() async {
await _getAndRegisterToken();
}
}