import 'dart:convert'; import 'dart:developer'; import 'dart:io'; import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:mc_common_app/classes/app_state.dart'; import 'package:mc_common_app/config/dependency_injection.dart'; import 'package:mc_common_app/config/routes.dart'; import 'package:mc_common_app/main.dart'; import 'package:mc_common_app/services/chat_navigation_service.dart'; import 'package:mc_common_app/utils/enums.dart'; import 'package:mc_common_app/utils/navigator.dart'; import 'package:mc_common_app/utils/utils.dart'; import 'package:mc_common_app/view_models/chat_view_model.dart'; import 'package:mc_common_app/view_models/dashboard_view_model_customer.dart'; import 'package:mc_common_app/view_models/dashboard_view_model_provider.dart'; import 'package:mc_common_app/models/requests_models/request_model.dart'; import 'package:mc_common_app/view_models/requests_view_model.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:provider/provider.dart'; abstract class FirebaseMessagingService { Future getFirebaseToken(); Future initializeNotifications(); // Replays a notification tap that arrived before the user was logged in (cold start, // expired session, etc.) -- call this right after login/OTP success finishes // navigating to the dashboard, so the original notification target isn't lost. Future resumePendingNotificationIfAny(); } class FirebaseMessagingServiceImp implements FirebaseMessagingService { final FlutterLocalNotificationsPlugin flutterLocalNotifications; final FirebaseMessaging firebaseMessaging; FirebaseMessagingServiceImp({required this.firebaseMessaging, required this.flutterLocalNotifications}); @override Future getFirebaseToken() async => await firebaseMessaging.getToken() ?? ""; Future requestPermissions() async { if (Platform.isIOS) { await flutterLocalNotifications.resolvePlatformSpecificImplementation()?.requestPermissions(alert: true, badge: true, sound: true); } else if (Platform.isAndroid) { AndroidFlutterLocalNotificationsPlugin? androidImplementation = flutterLocalNotifications.resolvePlatformSpecificImplementation(); bool? granted = await androidImplementation?.requestNotificationsPermission(); if (granted == false) { await Permission.notification.request(); } } } @override Future initializeNotifications() async { await requestPermissions(); AppState().setDeviceToken = await getFirebaseToken(); AppState().setDeviceType = Platform.isAndroid ? "1" : "2"; await Permission.notification.isDenied.then((bool value) { if (value) { Permission.notification.request(); } }); // Create high-priority notification channel for Android (required for terminated state) if (Platform.isAndroid) { const AndroidNotificationChannel channel = AndroidNotificationChannel( 'mowater_chat_channel', // More descriptive channel ID 'Chat Notifications', description: 'Notifications for chat messages', importance: Importance.max, // Changed to max for Android 14 playSound: true, enableVibration: true, showBadge: true, ); await flutterLocalNotifications.resolvePlatformSpecificImplementation()?.createNotificationChannel(channel); log("✅ Android notification channel created: ${channel.id}"); } // initialise the plugin. app_icon needs to be a added as a drawable resource to the Android head project const AndroidInitializationSettings initializationSettingsAndroid = AndroidInitializationSettings('ic_launcher'); DarwinInitializationSettings initializationSettingsDarwin = const DarwinInitializationSettings( requestAlertPermission: true, requestBadgePermission: true, requestCriticalPermission: true, requestProvisionalPermission: true, requestSoundPermission: true, defaultPresentAlert: true, defaultPresentBadge: true, defaultPresentBanner: true, defaultPresentList: true, defaultPresentSound: true, ); final InitializationSettings initializationSettings = InitializationSettings(android: initializationSettingsAndroid, iOS: initializationSettingsDarwin); await flutterLocalNotifications.initialize( initializationSettings, onDidReceiveNotificationResponse: (NotificationResponse response) async { // Handle notification tap when app is in foreground/background log("========== Notification Tapped =========="); log("Notification ID: ${response.id}"); log("Action ID: ${response.actionId}"); log("Input: ${response.input}"); log("Payload: ${response.payload}"); log("========================================="); if (response.payload != null && response.payload!.isNotEmpty) { try { Map data = jsonDecode(response.payload!); log("Parsed payload data: $data"); _handleNotificationNavigation(data); } catch (e) { logger.e("Error parsing notification payload: $e"); } } }, ); RemoteMessage? initialMessage = await FirebaseMessaging.instance.getInitialMessage(); if (initialMessage != null) _handleNotification(initialMessage); FirebaseMessaging.onMessage.listen((RemoteMessage message) { // Foreground notification - just display, don't navigate automatically if (message.notification != null) _handleForegroundNotification(message); }); FirebaseMessaging.onMessageOpenedApp.listen(_handleNotificationOpenedApp); FirebaseMessaging.onBackgroundMessage(backgroundMessageHandler); FirebaseMessaging.instance.onTokenRefresh.listen((String token) async { AppState().setDeviceToken = token; }); } void _onDidReceiveLocalNotification(int id, String? title, String? body, String? payload) async { logger.i(body); } void _handleForegroundNotification(RemoteMessage message) async { log("========== Foreground Notification Received =========="); log("Title: ${message.notification!.title}"); log("Body: ${message.notification!.body}"); log("Data payload: ${message.data}"); log("Message ID: ${message.messageId}"); log("Sent time: ${message.sentTime}"); log("Android config: ${message.toMap()}"); log("======================================================"); // Just display notification - DON'T navigate automatically // Navigation happens only when user taps the notification displayNotification( title: message.notification!.title ?? '', body: message.notification!.body ?? '', payload: jsonEncode(message.data), ); } void _handleNotificationOpenedApp(RemoteMessage message) async { log("========== Notification Opened App =========="); log("Title: ${message.notification!.title}"); log("Body: ${message.notification!.body}"); log("Data payload: ${message.data}"); log("Message ID: ${message.messageId}"); log("Sent time: ${message.sentTime}"); log("============================================="); // User tapped notification - navigate to chat _handleNotificationNavigation(message.data); } void _handleNotification(RemoteMessage message) async { // This is called when app is terminated and opened by notification log("========== App Terminated Notification =========="); log("Title: ${message.notification!.title}"); log("Body: ${message.notification!.body}"); log("Data payload: ${message.data}"); log("Message ID: ${message.messageId}"); log("Sent time: ${message.sentTime}"); log("================================================="); // User opened app from notification - navigate to chat _handleNotificationNavigation(message.data); } @override Future resumePendingNotificationIfAny() async { final pendingData = AppState().getPendingNotificationData; if (pendingData == null) return; // Clear before replaying -- _handleNotificationNavigation re-stashes it again if the // user still isn't authenticated for some reason, so this can't lose data either way. AppState().setPendingNotificationData = null; log("Resuming notification navigation that was pending login: $pendingData"); _handleNotificationNavigation(pendingData); } void _handleNotificationNavigation(Map data) async { log("========== Handling Notification Navigation =========="); log("Full data received: $data"); if (data.isEmpty) { log("Data is empty, skipping navigation"); log("======================================================"); return; } // Not logged in yet (cold start landing on login, expired session, etc.) -- every // navigation target below needs an authenticated VM call or a dashboard that only // exists post-login, so there's nothing to navigate to right now. Stash it instead // of silently dropping it; resumePendingNotificationIfAny() replays this exact call // once login completes. if ((AppState().getUser.data?.accessToken ?? "").isEmpty) { log("User not authenticated yet, stashing notification data to resume after login"); AppState().setPendingNotificationData = data; log("======================================================"); return; } // Backend sends NotiType as string. Full mapping per backend: // 1 - Request Offer Chat // 2 - Advertisement Chat // 3 - General Chat // 4 - Others // 5 - Road Side // 6 - Special car & spare parts requests // Only 6 has a screen to land on (Requests tab); 4 and 5 are upcoming features with // nowhere to navigate yet, so they're just logged and skipped. String? notiTypeStr = data['NotiType']; int? notiType = int.tryParse(notiTypeStr ?? ''); log("NotiType from backend: $notiTypeStr (parsed: $notiType)"); if (notiType == null || notiType < 1 || notiType > 6) { log("Invalid or missing NotiType, skipping navigation"); log("======================================================"); return; } // Map NotiType to chat type String chatType; switch (notiType) { case 1: chatType = 'request_offer'; break; case 2: chatType = 'ad_chat'; break; case 3: chatType = 'general_chat'; break; case 4: case 5: // Others / Road Side -- upcoming features, no screen to navigate to yet. chatType = 'upcoming_feature'; break; case 6: chatType = 'requests_tab'; break; default: log("Unknown NotiType: $notiType"); log("======================================================"); return; } log("Mapped chat type: $chatType"); // Wait a bit for context to be available await Future.delayed(Duration(milliseconds: 300)); BuildContext? context = navigatorKey.currentContext; if (context == null) { logger.e("Context not available for navigation"); log("======================================================"); return; } // Log all available data fields to understand backend format log("Available data fields:"); data.forEach((key, value) { log(" $key: $value"); }); switch (chatType) { case 'request_offer': log("Navigating to Request Offer Chat..."); await _navigateToRequestOfferChat(context, data); break; case 'ad_chat': log("Navigating to Advertisement Chat..."); await _navigateToAdChat(context, data); break; case 'general_chat': log("Navigating to General Chat..."); await _navigateToGeneralChat(context, data); break; case 'requests_tab': log("Navigating to Requests tab..."); await _navigateToRequestsTab(context, data); break; case 'upcoming_feature': log("NotiType $notiType (Others/Road Side) is for an upcoming feature -- not yet implemented, skipping navigation"); break; default: logger.e("Unknown chat type: $chatType"); } log("======================================================"); } Future _navigateToRequestOfferChat( BuildContext context, Map data, ) async { try { // Try both backend format and our format int requestId = int.tryParse(data['RequestID']?.toString() ?? data['request_id']?.toString() ?? '0') ?? 0; if (requestId == 0) { log("❌ Invalid or missing RequestID in notification data"); return; } if (AppState().currentAppType == AppType.customer) { // Customer notification tap: land on this request's offers list, matching // the normal (non-notification) customer flow in requests_fragment.dart. // ProvidersChatListPage below is provider-only (it reads the current // user's own providerId), so routing a customer there fails. log("✅ Navigating to offers list for request: $requestId"); navigateWithName(context, AppRoutes.offersListPage, arguments: requestId); } else { log("✅ Navigating to providers chat list for request: $requestId"); navigateWithName(context, AppRoutes.providersChatListPage); } // TODO: If backend sends all required data (ProviderId, CustomerId), // we can navigate directly to the chat screen } catch (e) { logger.e("Error navigating to request chat: $e"); } } Future _navigateToAdChat( BuildContext context, Map data, ) async { try { // Try both backend format and our format int adId = int.tryParse(data['AdID']?.toString() ?? data['ad_id']?.toString() ?? '0') ?? 0; // The "other participant" for the chat we're about to open/keep messaging in is // whoever SENT the message that triggered this notification — NOT whatever the // payload calls the "receiver". Backend has repeatedly mixed up sender/receiver // fields in this payload (see firebase_notification_payload_format.md); if we // blindly trust a "receiver" field that turns out to be OUR OWN id, every message // we send afterwards gets addressed back to ourselves, and we start getting // pushed notifications for our own messages. Prefer sender_id; fall back to the // old receiver fields only if sender fields are missing entirely. String? receiverUserId = data['SenderUserID'] ?? data['sender_id'] ?? data['ReceiverUserID'] ?? data['receiver_id']; log("📦 Ad chat notification payload fields — " "AdID: ${data['AdID']}, ad_id: ${data['ad_id']}, " "SenderUserID: ${data['SenderUserID']}, sender_id: ${data['sender_id']}, " "ReceiverUserID: ${data['ReceiverUserID']}, receiver_id: ${data['receiver_id']} " "-> resolved receiverUserId: $receiverUserId"); final String? myUserId = AppState().getUser.data?.userInfo?.userId?.toString(); if (myUserId != null && receiverUserId == myUserId) { log("⚠️ Resolved receiverUserId ($receiverUserId) matches the current user's own id — " "the notification payload is likely still misidentifying sender/receiver. " "Proceeding anyway, but messages sent from this chat may misroute."); } if (adId == 0 || receiverUserId == null) { log("❌ Invalid AdID or sender/receiver id in notification data"); log(" AdID: ${data['AdID']}, resolved receiverUserId: $receiverUserId"); return; } // Check if user is already on this specific chat // Try to get ChatVM from Provider, but don't fail if not available try { final chatVM = Provider.of(context, listen: false); if (chatVM.isOnAdChat(adId, receiverUserId)) { log("✅ User already on ad chat (adId=$adId), skipping navigation"); return; } } catch (e) { // ChatVM not available in context, proceed with navigation log("⚠️ Could not check chat context: $e"); } log("✅ Navigating to ad chat: adId=$adId"); final chatNavService = injector.get(); await chatNavService.navigateToAdChat( context: context, adsId: adId, receiverUserId: receiverUserId, ); } catch (e) { logger.e("Error navigating to ad chat: $e"); } } Future _navigateToGeneralChat( BuildContext context, Map data, ) async { try { // Try both backend format and our format int requestId = int.tryParse(data['RequestID']?.toString() ?? data['request_id']?.toString() ?? '0') ?? 0; if (requestId == 0) { log("❌ Invalid or missing RequestID in notification data"); return; } log("✅ Navigating to general chat list for request: $requestId"); navigateWithName( context, AppRoutes.generalChatsListForProvider, arguments: requestId, ); } catch (e) { logger.e("Error navigating to general chat: $e"); } } // Requests tab is index 4 in both apps' fragment lists. void _switchToRequestsTab(BuildContext context) { if (AppState().currentAppType == AppType.customer) { Provider.of(context, listen: false).onNavbarTapped(4); } else { Provider.of(context, listen: false).onNavbarTapped(4); } } Future _navigateToRequestsTab(BuildContext context, Map data) async { try { // Switch tabs first so the Requests tab is already showing underneath by the time // the loader (if any) comes down or the detail page opens -- matches what tapping // the tab manually would leave behind. _switchToRequestsTab(context); // NotiType 4 (Others) and 5 (Road Side) land here with no RequestID to resolve -- // the Requests tab is as specific as we can get for them. int requestId = int.tryParse(data['RequestID']?.toString() ?? data['request_id']?.toString() ?? '0') ?? 0; if (requestId == 0) { log("No RequestID in payload, just switching to Requests tab"); return; } final requestsVM = Provider.of(context, listen: false); int requestIndex = requestsVM.myFilteredRequests.indexWhere((request) => request.id == requestId); if (requestIndex == -1) { // Cold start, or the wrong request-type filter is active. Backend doesn't send // requestType in the notification payload yet (asked, pending) -- until then, // guess in this specific order: spare parts first, then special cars, then // others, stopping as soon as the request turns up. Replace this loop with a // direct filter lookup once backend adds requestType to the payload. // This can take a few seconds (up to 3 sequential network calls) -- show a // full-screen loader so the tap doesn't look like it did nothing. Utils.showLoading(context); try { const searchOrder = [RequestsTypeEnum.serviceRequest, RequestsTypeEnum.specialCarRequest, RequestsTypeEnum.other]; for (final requestsTypeEnum in searchOrder) { await requestsVM.applyFilterOnRequestsVM(requestsTypeEnum: requestsTypeEnum); requestIndex = requestsVM.myFilteredRequests.indexWhere((request) => request.id == requestId); if (requestIndex != -1) break; } } finally { Utils.hideLoading(context); } } if (requestIndex == -1) { log("⚠️ Request $requestId not found after refresh, falling back to Requests tab"); return; } RequestModel request = requestsVM.myFilteredRequests[requestIndex]; // Same gate as the manual tap in requests_fragment.dart -- these statuses have // nothing to show on the detail page, so land on the tab instead. if (request.requestStatus == RequestStatusEnum.pending || request.requestStatus == RequestStatusEnum.cancelled || request.requestStatus == RequestStatusEnum.expired) { log("Request $requestId status (${request.requestStatus}) has no detail view, falling back to Requests tab"); return; } requestsVM.updateCurrentSelectedRequest(request); if (AppState().currentAppType == AppType.customer) { log("✅ Navigating to offers list for request: $requestId"); navigateWithName(context, AppRoutes.offersListPage, arguments: request.id); } else { log("✅ Navigating to request detail for request: $requestId"); RequestDetailPageArguments requestDetailPageArguments = RequestDetailPageArguments(requestIndex: requestIndex, requestModel: request); navigateWithName(context, AppRoutes.requestsDetailPage, arguments: requestDetailPageArguments); } } catch (e) { logger.e("Error navigating to request detail: $e"); } } Future displayNotification({ required String title, required String body, String? payload, }) async { const AndroidNotificationDetails androidNotificationDetails = AndroidNotificationDetails( 'mowater_chat_channel', // Match the channel ID we created 'Chat Notifications', channelDescription: 'Notifications for chat messages', importance: Importance.max, priority: Priority.high, ticker: 'ticker', ); const DarwinNotificationDetails iOSPlatformChannelSpecifics = DarwinNotificationDetails(threadIdentifier: 'thread_id'); const NotificationDetails notificationDetails = NotificationDetails(android: androidNotificationDetails, iOS: iOSPlatformChannelSpecifics); await flutterLocalNotifications.show(0, title, body, notificationDetails, payload: payload ?? 'item x'); } } @pragma('vm:entry-point') Future backgroundMessageHandler(RemoteMessage message) async { await Firebase.initializeApp(); }