Files
tasq/lib/services/notification_bridge.dart
T
redz1029 5af0ce62d9 feat(notifications): day sheet notification types
Add daySheetId field to NotificationItem model (Brick-managed), extract
notificationTitle/notificationIcon to top-level testable functions, add
five day-sheet notification cases, and wire tap/banner routing for
day-sheet detail and approvals tab.
Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-28 14:54:19 +08:00

225 lines
7.8 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import '../models/notification_item.model.dart';
import '../providers/notifications_provider.dart';
import '../providers/notification_navigation_provider.dart';
import '../routing/app_router.dart';
import '../utils/snackbar.dart';
/// The newest unread item in [next] that wasn't in [prev], or null.
///
/// Returns null for the first snapshot ([prev] == null) so the existing inbox
/// doesn't raise a banner on startup. [next] is sorted newest-first.
@visibleForTesting
NotificationItem? newestArrival(
List<NotificationItem>? prev,
List<NotificationItem> next,
) {
if (prev == null) return null;
final seen = {for (final n in prev) n.id};
for (final n in next) {
if (n.isUnread && !seen.contains(n.id)) return n;
}
return null;
}
/// Wraps the app and installs both a Supabase realtime listener and the
/// FCM handlers described in the frontend design.
///
/// 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.child, super.key});
final Widget child;
@override
ConsumerState<NotificationBridge> createState() => _NotificationBridgeState();
}
class _NotificationBridgeState extends ConsumerState<NotificationBridge>
with WidgetsBindingObserver {
// Last loaded snapshot to diff against; null until the inbox first loads.
List<NotificationItem>? _prevList;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_setupFcmHandlers();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
// App lifecycle is now monitored, but individual streams handle their own
// recovery via StreamRecoveryWrapper. This no longer forces a global reconnect,
// which was the blocking behavior users complained about.
if (state == AppLifecycleState.resumed) {
// Invalidate on resume so that iOS PWA users (who may not receive push
// notifications when the app is in the background) see fresh notifications
// as soon as they bring the app to the foreground.
ref.invalidate(notificationsProvider);
}
}
/// Navigate to the appropriate screen using GoRouter.
///
/// Supports generic [route] for new notification types, plus legacy
/// [taskId], [ticketId], and [itServiceRequestId] fallbacks.
void _goToDetail({
String? taskId,
String? ticketId,
String? itServiceRequestId,
String? route,
}) {
final router = ref.read(appRouterProvider);
if (route != null && route.isNotEmpty) {
router.go(route);
} else if (taskId != null && taskId.isNotEmpty) {
router.go('/tasks/$taskId');
} else if (ticketId != null && ticketId.isNotEmpty) {
router.go('/tickets/$ticketId');
} else if (itServiceRequestId != null && itServiceRequestId.isNotEmpty) {
router.go('/it-service-requests/$itServiceRequestId');
}
}
String _bannerLabel(String type) => switch (type) {
'mention' => 'New mention',
'assignment' => 'New assignment',
'created' => 'New item created',
'announcement' => 'New announcement',
'announcement_comment' => 'New announcement comment',
'swap_request' => 'New shift swap request',
'swap_update' => 'Shift swap updated',
'it_job_reminder' => 'IT Job reminder',
'isr_approved' => 'IT Service Request approved',
'isr_status_changed' => 'IT Service Request updated',
'isr_assigned' => 'New IT Service Request assigned',
'day_sheet_submitted' => 'Day sheet submitted',
'day_sheet_disapproved' => 'Day sheet disapproved',
'day_sheet_justified' => 'Day sheet justified',
'day_sheet_approved' => 'Day sheet approved',
'day_sheet_digest' => 'Day sheets awaiting review',
_ => 'New notification',
};
void _showBanner(String type, NotificationItem item) {
// This widget sits above TasqApp's MaterialApp.router, so
// ScaffoldMessenger.of(context) would resolve to a messenger with no
// Scaffold. Use the app-level messenger key instead.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
scaffoldMessengerKey.currentState?.showSnackBar(
SnackBar(
content: Text(_bannerLabel(type)),
action: SnackBarAction(
label: 'View',
onPressed: () => _goToDetail(
route: item.announcementId != null
? '/announcements'
: item.type == 'day_sheet_digest'
? '/programmer-tasks?tab=approvals'
: item.daySheetId != null
? '/programmer-tasks/day-sheets/${item.daySheetId}'
: null,
taskId: item.taskId,
ticketId: item.ticketId,
itServiceRequestId: item.itServiceRequestId,
),
),
),
);
});
}
void _setupFcmHandlers() {
// ignore foreground messages; realtime websocket will surface them
FirebaseMessaging.onMessage.listen((_) {});
// handle taps when the app is backgrounded
FirebaseMessaging.onMessageOpenedApp.listen(_handleMessageTap);
// handle a tap that launched the app from a terminated state
FirebaseMessaging.instance.getInitialMessage().then((msg) {
if (msg != null) _handleMessageTap(msg);
});
}
/// Extract navigation target from FCM data payload and navigate.
///
/// Prefers the `navigate_to` field (used by scheduled notification
/// reminders) and falls back to legacy `task_id` / `ticket_id` keys.
void _handleMessageTap(RemoteMessage message) {
final data = message.data;
// New: use navigate_to route if present (scheduled reminder notifications)
final String? navigateTo = data['navigate_to']?.toString();
if (navigateTo != null && navigateTo.isNotEmpty) {
_goToDetail(route: navigateTo);
return;
}
// Legacy fallback for task_id / ticket_id
final String? taskId =
(data['task_id'] ?? data['taskId'] ?? data['task'])?.toString();
final String? ticketId =
(data['ticket_id'] ?? data['ticketId'] ?? data['ticket'])?.toString();
final String? isrId =
(data['it_service_request_id'] ?? data['itServiceRequestId'])
?.toString();
_goToDetail(
taskId: taskId,
ticketId: ticketId,
itServiceRequestId: isrId,
);
}
@override
Widget build(BuildContext context) {
// listen inside build; safe with ConsumerState
ref.listen<AsyncValue<List<NotificationItem>>>(notificationsProvider, (
previous,
next,
) {
// Loading/error emissions (e.g. invalidate on resume) keep the snapshot.
final nextList = next.valueOrNull;
if (nextList == null) return;
final item = newestArrival(_prevList, nextList);
_prevList = nextList;
if (item != null) _showBanner(item.type, item);
});
// Listen for pending navigation from local-notification taps
ref.listen<PendingNavigation?>(pendingNotificationNavigationProvider, (
previous,
next,
) {
if (next != null) {
_goToDetail(
route: next.route,
taskId: next.type == 'task' ? next.id : null,
ticketId: next.type == 'ticket' ? next.id : null,
itServiceRequestId:
next.type == 'it_service_request' ? next.id : null,
);
// Clear the pending navigation after handling
ref.read(pendingNotificationNavigationProvider.notifier).state = null;
}
});
return widget.child;
}
}