import 'dart:convert'; import 'dart:io'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:didvan/models/notification_message.dart'; import 'package:didvan/services/app_home_widget/home_widget_repository.dart'; import 'package:didvan/services/app_initalizer.dart'; import 'package:didvan/services/storage/storage.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/foundation.dart'; import 'package:get/get.dart'; import 'package:home_widget/home_widget.dart'; import 'package:http/http.dart' as http; import 'package:path_provider/path_provider.dart'; /// Storage key shared between the background notification-tap handler /// (running in a separate isolate) and the main isolate, which reads the /// payload back on resume and performs the actual navigation. const String pendingNotificationPayloadKey = 'pending_notif_payload'; /// Top-level handler invoked by flutter_local_notifications when the user /// taps a notification while the app is in the background or killed. Runs /// in its own isolate, so it cannot touch the Navigator directly. Instead /// it stashes the payload via HomeWidget storage (cross-isolate) so the /// main isolate can pick it up and route on resume. @pragma('vm:entry-point') void backgroundNotificationTapHandler(NotificationResponse response) { final payload = response.payload; if (payload == null || payload.isEmpty) return; try { HomeWidget.saveWidgetData(pendingNotificationPayloadKey, payload); } catch (e) { if (kDebugMode) { print('backgroundNotificationTapHandler: failed to stash payload: $e'); } } } class NotificationService { static final FlutterLocalNotificationsPlugin _flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); static Future initializeNotification() async { const AndroidInitializationSettings initializationSettingsAndroid = AndroidInitializationSettings('@mipmap/ic_launcher'); const InitializationSettings initializationSettings = InitializationSettings(android: initializationSettingsAndroid); await _flutterLocalNotificationsPlugin.initialize( initializationSettings, onDidReceiveNotificationResponse: _onNotificationResponse, // Background tap callback — runs in a separate isolate when the user // taps a notification while the app is in the background or killed. // It stashes the payload; the main isolate consumes it on resume. onDidReceiveBackgroundNotificationResponse: backgroundNotificationTapHandler, ); if (!kIsWeb) { const AndroidNotificationChannel channel = AndroidNotificationChannel( 'content', 'Content Notification', description: 'Notification channel', importance: Importance.max, playSound: true, ); await _flutterLocalNotificationsPlugin .resolvePlatformSpecificImplementation< AndroidFlutterLocalNotificationsPlugin>() ?.createNotificationChannel(channel); } } /// Save a notification payload into cross-isolate storage so it can be /// replayed later (e.g. after the user logs in). Used by splash when the /// user tapped a notification while logged out — we don't want to drop /// the destination just because they need to sign in first. static Future stashPendingNotificationPayload( NotificationMessage data) async { try { await HomeWidget.saveWidgetData( pendingNotificationPayloadKey, jsonEncode(data.toJson()), ); } catch (e) { if (kDebugMode) { print('stashPendingNotificationPayload failed: $e'); } } } /// Read a pending notification payload (if any) from cross-isolate /// storage and hand it off to [HomeWidgetRepository.data] without /// navigating. Use this during splash where you want the existing splash /// routing logic to make the navigation call. static Future loadPendingNotificationPayload() async { try { final stashed = await HomeWidget.getWidgetData( pendingNotificationPayloadKey, defaultValue: '', ); if (stashed == null || stashed.isEmpty) return; await HomeWidget.saveWidgetData(pendingNotificationPayloadKey, ''); HomeWidgetRepository.data = NotificationMessage.fromJson(jsonDecode(stashed)); } catch (e) { if (kDebugMode) { print('loadPendingNotificationPayload failed: $e'); } } } /// Pull a pending notification payload (stored by the background tap /// handler) out of cross-isolate storage and route to the right screen. /// Called from the main isolate on app resume / startup. static Future consumePendingNotificationTap() async { try { final stashed = await HomeWidget.getWidgetData( pendingNotificationPayloadKey, defaultValue: '', ); if (stashed == null || stashed.isEmpty) return; // Clear immediately so we don't re-route on every resume. await HomeWidget.saveWidgetData(pendingNotificationPayloadKey, ''); final data = NotificationMessage.fromJson(jsonDecode(stashed)); HomeWidgetRepository.data = data; await HomeWidgetRepository.decideWhereToGoNotif(); } catch (e) { if (kDebugMode) { print('consumePendingNotificationTap failed: $e'); } } } static Future _onNotificationResponse( NotificationResponse response) async { if (kDebugMode) { print("=== LOCAL NOTIFICATION CLICKED ==="); print("Notification ID: ${response.id}"); print("Action ID: ${response.actionId}"); print("Input: ${response.input}"); print("Response Type: ${response.notificationResponseType}"); print("Payload: ${response.payload}"); print("==================================="); } try { final payload = response.payload; if (payload != null) { NotificationMessage data = NotificationMessage.fromJson(jsonDecode(payload)); HomeWidgetRepository.data = data; if (kDebugMode) { print("Parsed payload data: ${data.toJson()}"); } await HomeWidgetRepository.decideWhereToGoNotif(); await StorageService.delete( key: 'notification${AppInitializer.createNotificationId(data)}'); } } catch (e) { if (kDebugMode) { print("Error in _onNotificationResponse: $e"); } e.printError(); } } static Future showFirebaseNotification(RemoteMessage message) async { if (kDebugMode) { print("=== SHOWING FIREBASE NOTIFICATION ==="); print("Message Data: ${message.data}"); print( "Notification: ${message.notification?.title} - ${message.notification?.body}"); } try { final data = NotificationMessage.fromJson(message.data); if (kDebugMode) { print("Parsed NotificationMessage: ${data.toJson()}"); print("Notification Type: ${data.notificationType}"); } // Widget-refresh pushes carry notificationType=3 and skip the UI. if (data.notificationType == '3' || (data.notificationType?.contains('3') ?? false)) { if (kDebugMode) { print("Widget notification - calling fetchWidget()"); } HomeWidgetRepository.fetchWidget(); return; } NotificationService.showNotification(data); } catch (e) { if (kDebugMode) { print("Error in showFirebaseNotification: $e"); } e.printError(); } } /// Downloads [url] into a temp file and returns its absolute path. Used by /// `BigPictureStyleInformation` which needs a local file path. Returns /// null on any failure so we degrade to a no-image notification rather /// than crashing. static Future _downloadImageToTemp(String url) async { try { final res = await http.get(Uri.parse(url)); if (res.statusCode != 200) { if (kDebugMode) { print('image download failed: HTTP ${res.statusCode}'); } return null; } final dir = await getTemporaryDirectory(); final file = File( '${dir.path}/notif_${DateTime.now().millisecondsSinceEpoch}.img', ); await file.writeAsBytes(res.bodyBytes, flush: true); return file.path; } catch (e) { if (kDebugMode) print('image download exception: $e'); return null; } } static Future showNotification(NotificationMessage data) async { final notifType = data.notificationType ?? ''; if (notifType.contains('2')) { final String? storedValue = await StorageService.getValue( key: 'notification${AppInitializer.createNotificationId(data)}'); if (storedValue != null) { _flutterLocalNotificationsPlugin .cancel(AppInitializer.createNotificationId(data)); data.body = '${data.body}\n$storedValue'; } } // Resolve the image we want on the notification. // - `data.imageUrl` is the new content-service broadcast field (remote // URL — must be downloaded into the cache before Android can render // it via BigPictureStyle). // - `data.image` is the legacy widget-update field (already a local // path). String? bigPicturePath; if (data.imageUrl != null && data.imageUrl!.isNotEmpty) { bigPicturePath = await _downloadImageToTemp(data.imageUrl!); } else if (data.image != null && data.image!.isNotEmpty) { bigPicturePath = data.image; } // Pick the Android style: // - notificationType '1' (legacy "big news" push) → BigPictureStyle // - any push with a resolved image (content broadcasts) → // BigPictureStyle so the cover image shows up // - otherwise → MessagingStyle (compact) final useBigPicture = notifType.contains('1') || bigPicturePath != null; // Use the same tag the backend put on the FCM notification so this // local notification REPLACES the bare one Android already drew when // the push arrived — no duplicate, just the cover image. final notifTag = (data.id != null && data.id!.isNotEmpty) ? 'didvan-content-${data.id}' : null; final AndroidNotificationDetails androidPlatformChannelSpecifics = AndroidNotificationDetails( 'content', 'Content Notification', channelDescription: 'Notification channel', importance: Importance.max, priority: Priority.high, playSound: true, tag: notifTag, styleInformation: useBigPicture && bigPicturePath != null ? BigPictureStyleInformation( FilePathAndroidBitmap(bigPicturePath), contentTitle: data.title, summaryText: data.body ?? '', ) : MessagingStyleInformation( Person(name: data.title), messages: [Message(data.body ?? '', DateTime.now(), null)], ), ); final NotificationDetails platformChannelSpecifics = NotificationDetails(android: androidPlatformChannelSpecifics); // Pass the full data map (incl. id/type/link/imageUrl) through the // payload so the onTap handler can route correctly. final payloadJson = jsonEncode(data.toJson()); await _flutterLocalNotificationsPlugin.show( useBigPicture ? DateTime.now().millisecondsSinceEpoch ~/ 1000 : AppInitializer.createNotificationId(data), data.title, data.body, platformChannelSpecifics, payload: payloadJson, ); if (notifType.contains('2')) { await StorageService.setValue( key: 'notification${AppInitializer.createNotificationId(data)}', value: data.body); } } }