Push notification tap redirect to corressponding ticket or task

This commit is contained in:
Marc Rejohn Castillano 2026-03-02 21:09:38 +08:00
parent eb49329b16
commit 5713581992
3 changed files with 75 additions and 58 deletions

View File

@ -482,8 +482,13 @@ Future<void> main() async {
>() >()
?.createNotificationChannel(channel); ?.createNotificationChannel(channel);
// global navigator key used for snackbars/navigation from notification // Create the global provider container BEFORE initializing local
final navigatorKey = GlobalKey<NavigatorState>(); // 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 // initialize the local notifications plugin so we can post alerts later
await NotificationService.initialize( await NotificationService.initialize(
@ -505,36 +510,32 @@ Future<void> main() async {
} }
} }
// Update the pending navigation provider // Update the pending navigation provider.
if (ticketId != null && ticketId.isNotEmpty) { // Prefer task over ticket assignment notifications include both
_globalProviderContainer // IDs but the primary entity is the task.
.read(pendingNotificationNavigationProvider.notifier) if (taskId != null && taskId.isNotEmpty) {
.state = (
type: 'ticket',
id: ticketId,
);
} else if (taskId != null && taskId.isNotEmpty) {
_globalProviderContainer _globalProviderContainer
.read(pendingNotificationNavigationProvider.notifier) .read(pendingNotificationNavigationProvider.notifier)
.state = ( .state = (
type: 'task', type: 'task',
id: taskId, 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( runApp(
ProviderScope( UncontrolledProviderScope(
observers: [NotificationSoundObserver()], container: _globalProviderContainer,
child: NotificationBridge( child: const NotificationBridge(child: TasqApp()),
navigatorKey: navigatorKey,
child: const TasqApp(),
),
), ),
); );

View File

@ -984,6 +984,7 @@ class TasksController {
final dataPayload = <String, dynamic>{ final dataPayload = <String, dynamic>{
'type': 'created', 'type': 'created',
'task_id': taskId,
'task_number': ?taskNumber, 'task_number': ?taskNumber,
'office_id': ?officeId, 'office_id': ?officeId,
'office_name': ?officeName, 'office_name': ?officeName,
@ -1548,6 +1549,7 @@ class TasksController {
final dataPayload = <String, dynamic>{ final dataPayload = <String, dynamic>{
'type': 'assignment', 'type': 'assignment',
'task_id': taskId,
'task_number': ?taskNumber, 'task_number': ?taskNumber,
'office_id': ?officeId, 'office_id': ?officeId,
'office_name': ?officeName, 'office_name': ?officeName,
@ -1781,6 +1783,7 @@ class TaskAssignmentsController {
final dataPayload = <String, dynamic>{ final dataPayload = <String, dynamic>{
'type': 'assignment', 'type': 'assignment',
'task_id': taskId,
'task_number': ?taskNumber, 'task_number': ?taskNumber,
'ticket_id': ?ticketId, 'ticket_id': ?ticketId,
'office_id': ?officeId, 'office_id': ?officeId,

View File

@ -1,25 +1,21 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:go_router/go_router.dart';
import '../models/notification_item.dart'; import '../models/notification_item.dart';
import '../providers/notifications_provider.dart'; import '../providers/notifications_provider.dart';
import '../providers/notification_navigation_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 /// Wraps the app and installs both a Supabase realtime listener and the
/// FCM handlers described in the frontend design. /// FCM handlers described in the frontend design.
/// ///
/// The navigator key is required so that snackbars and navigation can be /// Navigation is performed via the [GoRouter] instance obtained from
/// triggered from outside the widget tree (e.g. from realtime callbacks). /// [appRouterProvider], so this widget does **not** require an external
/// navigator key.
class NotificationBridge extends ConsumerStatefulWidget { class NotificationBridge extends ConsumerStatefulWidget {
const NotificationBridge({ const NotificationBridge({required this.child, super.key});
required this.navigatorKey,
required this.child,
super.key,
});
final GlobalKey<NavigatorState> navigatorKey;
final Widget child; final Widget child;
@override @override
@ -55,26 +51,36 @@ class _NotificationBridgeState extends ConsumerState<NotificationBridge>
} }
} }
void _showBanner(String type, NotificationItem item) { /// Navigate to the task or ticket detail screen using GoRouter.
final ctx = widget.navigatorKey.currentState?.overlay?.context; void _goToDetail({String? taskId, String? ticketId}) {
if (ctx == null) return; final router = ref.read(appRouterProvider);
if (taskId != null && taskId.isNotEmpty) {
router.go('/tasks/$taskId');
} else if (ticketId != null && ticketId.isNotEmpty) {
router.go('/tickets/$ticketId');
}
}
ScaffoldMessenger.of(ctx).showSnackBar( 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( SnackBar(
content: Text('New $type received!'), content: Text('New $type received!'),
action: SnackBarAction( action: SnackBarAction(
label: 'View', label: 'View',
onPressed: () => _navigateToNotification(item), onPressed: () =>
_goToDetail(taskId: item.taskId, ticketId: item.ticketId),
), ),
), ),
); );
} catch (_) {
// ScaffoldMessenger not available yet (before first frame) ignore.
} }
});
void _navigateToNotification(NotificationItem item) {
widget.navigatorKey.currentState?.pushNamed(
'/notification-detail',
arguments: item,
);
} }
void _setupFcmHandlers() { void _setupFcmHandlers() {
@ -90,10 +96,20 @@ class _NotificationBridgeState extends ConsumerState<NotificationBridge>
}); });
} }
/// 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) { void _handleMessageTap(RemoteMessage message) {
final data = message.data.cast<String, dynamic>(); final data = message.data;
final item = NotificationItem.fromMap(data);
_navigateToNotification(item); 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 @override
@ -115,19 +131,16 @@ class _NotificationBridgeState extends ConsumerState<NotificationBridge>
_prevList = nextList; _prevList = nextList;
}); });
// Listen for pending navigation from notification taps // Listen for pending navigation from local-notification taps
ref.listen<PendingNavigation?>(pendingNotificationNavigationProvider, ( ref.listen<PendingNavigation?>(pendingNotificationNavigationProvider, (
previous, previous,
next, next,
) { ) {
if (next != null) { if (next != null) {
final type = next.type; _goToDetail(
final id = next.id; taskId: next.type == 'task' ? next.id : null,
if (type == 'ticket') { ticketId: next.type == 'ticket' ? next.id : null,
GoRouter.of(context).go('/tickets/$id'); );
} else if (type == 'task') {
GoRouter.of(context).go('/tasks/$id');
}
// Clear the pending navigation after handling // Clear the pending navigation after handling
ref.read(pendingNotificationNavigationProvider.notifier).state = null; ref.read(pendingNotificationNavigationProvider.notifier).state = null;
} }