313 lines
12 KiB
Dart
313 lines
12 KiB
Dart
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<void> 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<void> 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<void> loadPendingNotificationPayload() async {
|
|
try {
|
|
final stashed = await HomeWidget.getWidgetData<String>(
|
|
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<void> consumePendingNotificationTap() async {
|
|
try {
|
|
final stashed = await HomeWidget.getWidgetData<String>(
|
|
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<void> _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<void> 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<String?> _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<void> 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);
|
|
}
|
|
}
|
|
}
|