3cb980b629
Security — enforce write authorization server-side (was UI/RPC-only): - it_service_requests RLS: block cross-office read/edit + self-approve (QA-015) - pass_slips RLS: owner can complete but not self-approve (QA-046) - swap_requests RLS: scope select/update to participants + admin (QA-047) - storage: tighten it_service_attachments + task_attachments write/delete (QA-027) - admin_user_management edge function: allow programmers to manage users (QA-016) Fixes: - workforce generator "uncovered shifts" false alarms (QA-043/044) - network-map VLAN + New-location dialog validation, disabled-until-valid (QA-048) - de-flake time-of-day-dependent dashboard metrics test (QA-045) Toolchain: - upgrade to Flutter 3.47.5 / Dart 3.13.4; font_awesome_flutter 11.0.0, flutter_quill 11.6.0, pdfrx 2.6.5; clear resulting deprecations (QA-002) analyze clean; 139 tests pass; web build succeeds. Report + evidence in docs/qa/. Note: also carries the in-progress Brick model cleanup already present in the working tree. QA-001 (AI keys public in the build) is deferred by owner decision. Co-Authored-By: claude-flow <ruv@ruv.net>
214 lines
7.3 KiB
Dart
214 lines
7.3 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',
|
|
_ => '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' : 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;
|
|
}
|
|
}
|