From 57135819923f25b3ea5aee49d8cb4d94d59bff14 Mon Sep 17 00:00:00 2001 From: Marc Rejohn Castillano Date: Mon, 2 Mar 2026 21:09:38 +0800 Subject: [PATCH] Push notification tap redirect to corressponding ticket or task --- lib/main.dart | 41 ++++++------ lib/providers/tasks_provider.dart | 3 + lib/services/notification_bridge.dart | 89 +++++++++++++++------------ 3 files changed, 75 insertions(+), 58 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index d638d190..46448b84 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -482,8 +482,13 @@ Future main() async { >() ?.createNotificationChannel(channel); - // global navigator key used for snackbars/navigation from notification - final navigatorKey = GlobalKey(); + // Create the global provider container BEFORE initializing local + // notifications, because the tap callback needs to write to it and + // flutter_local_notifications may fire the callback synchronously if + // the app was launched by tapping a notification. + _globalProviderContainer = ProviderContainer( + observers: [NotificationSoundObserver()], + ); // initialize the local notifications plugin so we can post alerts later await NotificationService.initialize( @@ -505,36 +510,32 @@ Future main() async { } } - // Update the pending navigation provider - if (ticketId != null && ticketId.isNotEmpty) { - _globalProviderContainer - .read(pendingNotificationNavigationProvider.notifier) - .state = ( - type: 'ticket', - id: ticketId, - ); - } else if (taskId != null && taskId.isNotEmpty) { + // Update the pending navigation provider. + // Prefer task over ticket — assignment notifications include both + // IDs but the primary entity is the task. + if (taskId != null && taskId.isNotEmpty) { _globalProviderContainer .read(pendingNotificationNavigationProvider.notifier) .state = ( type: 'task', id: taskId, ); + } else if (ticketId != null && ticketId.isNotEmpty) { + _globalProviderContainer + .read(pendingNotificationNavigationProvider.notifier) + .state = ( + type: 'ticket', + id: ticketId, + ); } } }, ); - // Create the global provider container - _globalProviderContainer = ProviderContainer(); - runApp( - ProviderScope( - observers: [NotificationSoundObserver()], - child: NotificationBridge( - navigatorKey: navigatorKey, - child: const TasqApp(), - ), + UncontrolledProviderScope( + container: _globalProviderContainer, + child: const NotificationBridge(child: TasqApp()), ), ); diff --git a/lib/providers/tasks_provider.dart b/lib/providers/tasks_provider.dart index c7999dab..0f79bbc4 100644 --- a/lib/providers/tasks_provider.dart +++ b/lib/providers/tasks_provider.dart @@ -984,6 +984,7 @@ class TasksController { final dataPayload = { 'type': 'created', + 'task_id': taskId, 'task_number': ?taskNumber, 'office_id': ?officeId, 'office_name': ?officeName, @@ -1548,6 +1549,7 @@ class TasksController { final dataPayload = { 'type': 'assignment', + 'task_id': taskId, 'task_number': ?taskNumber, 'office_id': ?officeId, 'office_name': ?officeName, @@ -1781,6 +1783,7 @@ class TaskAssignmentsController { final dataPayload = { 'type': 'assignment', + 'task_id': taskId, 'task_number': ?taskNumber, 'ticket_id': ?ticketId, 'office_id': ?officeId, diff --git a/lib/services/notification_bridge.dart b/lib/services/notification_bridge.dart index 1cfc7684..2679c34c 100644 --- a/lib/services/notification_bridge.dart +++ b/lib/services/notification_bridge.dart @@ -1,25 +1,21 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; -import 'package:go_router/go_router.dart'; import '../models/notification_item.dart'; import '../providers/notifications_provider.dart'; import '../providers/notification_navigation_provider.dart'; +import '../routing/app_router.dart'; /// Wraps the app and installs both a Supabase realtime listener and the /// FCM handlers described in the frontend design. /// -/// The navigator key is required so that snackbars and navigation can be -/// triggered from outside the widget tree (e.g. from realtime callbacks). +/// Navigation is performed via the [GoRouter] instance obtained from +/// [appRouterProvider], so this widget does **not** require an external +/// navigator key. class NotificationBridge extends ConsumerStatefulWidget { - const NotificationBridge({ - required this.navigatorKey, - required this.child, - super.key, - }); + const NotificationBridge({required this.child, super.key}); - final GlobalKey navigatorKey; final Widget child; @override @@ -55,26 +51,36 @@ class _NotificationBridgeState extends ConsumerState } } - void _showBanner(String type, NotificationItem item) { - final ctx = widget.navigatorKey.currentState?.overlay?.context; - if (ctx == null) return; - - ScaffoldMessenger.of(ctx).showSnackBar( - SnackBar( - content: Text('New $type received!'), - action: SnackBarAction( - label: 'View', - onPressed: () => _navigateToNotification(item), - ), - ), - ); + /// Navigate to the task or ticket detail screen using GoRouter. + void _goToDetail({String? taskId, String? ticketId}) { + final router = ref.read(appRouterProvider); + if (taskId != null && taskId.isNotEmpty) { + router.go('/tasks/$taskId'); + } else if (ticketId != null && ticketId.isNotEmpty) { + router.go('/tickets/$ticketId'); + } } - void _navigateToNotification(NotificationItem item) { - widget.navigatorKey.currentState?.pushNamed( - '/notification-detail', - arguments: item, - ); + void _showBanner(String type, NotificationItem item) { + // Use a post-frame callback so that the ScaffoldMessenger from + // MaterialApp is available in the element tree. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + try { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('New $type received!'), + action: SnackBarAction( + label: 'View', + onPressed: () => + _goToDetail(taskId: item.taskId, ticketId: item.ticketId), + ), + ), + ); + } catch (_) { + // ScaffoldMessenger not available yet (before first frame) — ignore. + } + }); } void _setupFcmHandlers() { @@ -90,10 +96,20 @@ class _NotificationBridgeState extends ConsumerState }); } + /// Extract task/ticket IDs from the FCM data payload and navigate. + /// + /// The FCM data map contains keys such as `task_id` / `taskId` / + /// `ticket_id` / `ticketId` — these are the same keys used in the + /// background handler ([_firebaseMessagingBackgroundHandler]). void _handleMessageTap(RemoteMessage message) { - final data = message.data.cast(); - final item = NotificationItem.fromMap(data); - _navigateToNotification(item); + final data = message.data; + + final String? taskId = (data['task_id'] ?? data['taskId'] ?? data['task']) + ?.toString(); + final String? ticketId = + (data['ticket_id'] ?? data['ticketId'] ?? data['ticket'])?.toString(); + + _goToDetail(taskId: taskId, ticketId: ticketId); } @override @@ -115,19 +131,16 @@ class _NotificationBridgeState extends ConsumerState _prevList = nextList; }); - // Listen for pending navigation from notification taps + // Listen for pending navigation from local-notification taps ref.listen(pendingNotificationNavigationProvider, ( previous, next, ) { if (next != null) { - final type = next.type; - final id = next.id; - if (type == 'ticket') { - GoRouter.of(context).go('/tickets/$id'); - } else if (type == 'task') { - GoRouter.of(context).go('/tasks/$id'); - } + _goToDetail( + taskId: next.type == 'task' ? next.id : null, + ticketId: next.type == 'ticket' ? next.id : null, + ); // Clear the pending navigation after handling ref.read(pendingNotificationNavigationProvider.notifier).state = null; }