Implement push notification reminder system with 9 notification types
Adds comprehensive push notification reminders using pg_cron + pg_net: - Shift check-in reminder (15 min before, with countdown banner) - Shift check-out reminder (hourly, persistent until checkout) - Overtime idle reminder (15 min without task) - Overtime checkout reminder (30 min after task completion) - IT service request event reminder (1 hour before event) - IT service request evidence reminder (daily) - Paused task reminder (daily) - Backlog reminder (15 min before shift end) - Pass slip expiry reminder (15 min before 1-hour limit, with countdown banner) Database: Extended scheduled_notifications table to support polymorphic references (schedule_id, task_id, it_service_request_id, pass_slip_id) with unique constraint and epoch column for deduplication. Implemented 8 enqueue functions + master dispatcher. Uses pg_cron every minute to enqueue and pg_net to trigger process_scheduled_notifications edge function, eliminating need for external cron job. Credentials stored in vault with GUC fallback for flexibility. Flutter: Added ShiftCountdownBanner and PassSlipCountdownBanner widgets that display persistent countdown timers for active shifts and pass slips. Both auto-dismiss when user completes the action. FCM handler triggers shift countdown on start_15 messages. navigate_to field in data payload enables flexible routing to any screen. Edge function: Updated process_scheduled_notifications to handle all 9 types with appropriate titles, bodies, and routing. Includes pass_slip_id in idempotency tracking. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
+50
-9
@@ -14,6 +14,7 @@ import 'app.dart';
|
||||
import 'theme/app_theme.dart';
|
||||
import 'providers/notifications_provider.dart';
|
||||
import 'providers/notification_navigation_provider.dart';
|
||||
import 'providers/shift_countdown_provider.dart';
|
||||
import 'utils/app_time.dart';
|
||||
import 'utils/notification_permission.dart';
|
||||
import 'utils/location_permission.dart';
|
||||
@@ -197,7 +198,7 @@ Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
||||
// Create a unique ID for the notification display
|
||||
final int id = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
|
||||
// Build payload string with ticket/task information for navigation
|
||||
// Build payload string with ticket/task/route information for navigation
|
||||
final payloadParts = <String>[];
|
||||
final taskId =
|
||||
(message.data['task_id'] ??
|
||||
@@ -209,7 +210,11 @@ Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
||||
message.data['ticketId'] ??
|
||||
message.data['ticket']?.toString() ??
|
||||
'';
|
||||
final navigateTo = message.data['navigate_to']?.toString() ?? '';
|
||||
|
||||
if (navigateTo.isNotEmpty) {
|
||||
payloadParts.add('route:$navigateTo');
|
||||
}
|
||||
if (taskId != null && taskId.isNotEmpty) {
|
||||
payloadParts.add('task:$taskId');
|
||||
}
|
||||
@@ -424,6 +429,25 @@ Future<void> main() async {
|
||||
} catch (_) {}
|
||||
|
||||
final formatted = _formatNotificationFromData(dataForFormatting);
|
||||
|
||||
// Activate the shift countdown banner for start_15 notifications
|
||||
final msgType = message.data['type']?.toString() ?? '';
|
||||
if (msgType == 'start_15') {
|
||||
// The shift starts 15 minutes from when the notification is sent.
|
||||
// Use scheduled_for from data if available, otherwise estimate.
|
||||
final scheduledFor = message.data['scheduled_for']?.toString();
|
||||
DateTime shiftStart;
|
||||
if (scheduledFor != null && scheduledFor.isNotEmpty) {
|
||||
shiftStart = DateTime.tryParse(scheduledFor) ??
|
||||
DateTime.now().add(const Duration(minutes: 15));
|
||||
} else {
|
||||
shiftStart = DateTime.now().add(const Duration(minutes: 15));
|
||||
}
|
||||
_globalProviderContainer
|
||||
.read(shiftCountdownProvider.notifier)
|
||||
.state = shiftStart;
|
||||
}
|
||||
|
||||
String? stableId =
|
||||
(message.data['notification_id'] as String?) ?? message.messageId;
|
||||
if (stableId == null) {
|
||||
@@ -483,7 +507,7 @@ Future<void> main() async {
|
||||
recent[stableId] = now;
|
||||
await prefs.setString('recent_notifs', jsonEncode(recent));
|
||||
|
||||
// Build payload string with ticket/task information for navigation
|
||||
// Build payload string with route/ticket/task information for navigation
|
||||
final payloadParts = <String>[];
|
||||
final taskId =
|
||||
(message.data['task_id'] ??
|
||||
@@ -495,7 +519,12 @@ Future<void> main() async {
|
||||
message.data['ticketId'] ??
|
||||
message.data['ticket']?.toString() ??
|
||||
'';
|
||||
final navigateTo =
|
||||
message.data['navigate_to']?.toString() ?? '';
|
||||
|
||||
if (navigateTo.isNotEmpty) {
|
||||
payloadParts.add('route:$navigateTo');
|
||||
}
|
||||
if (taskId != null && taskId.isNotEmpty) {
|
||||
payloadParts.add('task:$taskId');
|
||||
}
|
||||
@@ -554,17 +583,20 @@ Future<void> main() async {
|
||||
// initialize the local notifications plugin so we can post alerts later
|
||||
await NotificationService.initialize(
|
||||
onDidReceiveNotificationResponse: (response) {
|
||||
// handle user tapping a notification; the payload format is "ticket:ID",
|
||||
// "task:ID", "tasknum:NUMBER", or a combination separated by "|"
|
||||
// handle user tapping a notification; the payload format is "route:PATH",
|
||||
// "ticket:ID", "task:ID", or a combination separated by "|"
|
||||
final payload = response.payload;
|
||||
if (payload != null && payload.isNotEmpty) {
|
||||
// Parse the payload to extract ticket and task information
|
||||
// Parse the payload to extract navigation information
|
||||
final parts = payload.split('|');
|
||||
String? ticketId;
|
||||
String? taskId;
|
||||
String? route;
|
||||
|
||||
for (final part in parts) {
|
||||
if (part.startsWith('ticket:')) {
|
||||
if (part.startsWith('route:')) {
|
||||
route = part.substring('route:'.length);
|
||||
} else if (part.startsWith('ticket:')) {
|
||||
ticketId = part.substring('ticket:'.length);
|
||||
} else if (part.startsWith('task:')) {
|
||||
taskId = part.substring('task:'.length);
|
||||
@@ -572,14 +604,22 @@ Future<void> main() async {
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// Prefer route (from scheduled reminders), then task, then ticket.
|
||||
if (route != null && route.isNotEmpty) {
|
||||
_globalProviderContainer
|
||||
.read(pendingNotificationNavigationProvider.notifier)
|
||||
.state = (
|
||||
type: 'route',
|
||||
id: '',
|
||||
route: route,
|
||||
);
|
||||
} else if (taskId != null && taskId.isNotEmpty) {
|
||||
_globalProviderContainer
|
||||
.read(pendingNotificationNavigationProvider.notifier)
|
||||
.state = (
|
||||
type: 'task',
|
||||
id: taskId,
|
||||
route: null,
|
||||
);
|
||||
} else if (ticketId != null && ticketId.isNotEmpty) {
|
||||
_globalProviderContainer
|
||||
@@ -587,6 +627,7 @@ Future<void> main() async {
|
||||
.state = (
|
||||
type: 'ticket',
|
||||
id: ticketId,
|
||||
route: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user