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:
2026-03-20 18:26:48 +08:00
parent 74197c525d
commit d484f62cbd
9 changed files with 1023 additions and 37 deletions
+50 -9
View File
@@ -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,
);
}
}
@@ -1,6 +1,6 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
typedef PendingNavigation = ({String type, String id})?;
typedef PendingNavigation = ({String type, String id, String? route})?;
final pendingNotificationNavigationProvider = StateProvider<PendingNavigation>(
(ref) => null,
@@ -0,0 +1,8 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// Holds the target shift start time when a `start_15` notification is received.
///
/// Set to the shift start [DateTime] to activate the countdown banner.
/// Set to `null` to dismiss (e.g., when the user checks in or the countdown
/// reaches zero).
final shiftCountdownProvider = StateProvider<DateTime?>((ref) => null);
+39 -10
View File
@@ -51,13 +51,25 @@ class _NotificationBridgeState extends ConsumerState<NotificationBridge>
}
}
/// Navigate to the task or ticket detail screen using GoRouter.
void _goToDetail({String? taskId, String? ticketId}) {
/// 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 (taskId != null && taskId.isNotEmpty) {
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');
}
}
@@ -96,20 +108,34 @@ class _NotificationBridgeState extends ConsumerState<NotificationBridge>
});
}
/// Extract task/ticket IDs from the FCM data payload and navigate.
/// Extract navigation target from 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]).
/// 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;
final String? taskId = (data['task_id'] ?? data['taskId'] ?? data['task'])
?.toString();
// 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);
_goToDetail(
taskId: taskId,
ticketId: ticketId,
itServiceRequestId: isrId,
);
}
@override
@@ -138,8 +164,11 @@ class _NotificationBridgeState extends ConsumerState<NotificationBridge>
) {
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;
+5 -1
View File
@@ -10,6 +10,8 @@ import '../providers/notifications_provider.dart';
import '../providers/profile_provider.dart';
import 'app_breakpoints.dart';
import 'profile_avatar.dart';
import 'pass_slip_countdown_banner.dart';
import 'shift_countdown_banner.dart';
final GlobalKey notificationBellKey = GlobalKey();
@@ -328,7 +330,9 @@ class _ShellBackground extends StatelessWidget {
Widget build(BuildContext context) {
return ColoredBox(
color: Theme.of(context).scaffoldBackgroundColor,
child: child,
child: ShiftCountdownBanner(
child: PassSlipCountdownBanner(child: child),
),
);
}
}
+170
View File
@@ -0,0 +1,170 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../providers/pass_slip_provider.dart';
/// A persistent banner that shows a countdown to the pass slip 1-hour expiry.
///
/// Watches [activePassSlipProvider] for the current user's active pass slip.
/// When active, calculates remaining time until `slipStart + 1 hour`.
/// Auto-dismisses when the pass slip is completed.
/// Shows "EXCEEDED" when time has passed the 1-hour mark.
class PassSlipCountdownBanner extends ConsumerStatefulWidget {
const PassSlipCountdownBanner({required this.child, super.key});
final Widget child;
@override
ConsumerState<PassSlipCountdownBanner> createState() =>
_PassSlipCountdownBannerState();
}
class _PassSlipCountdownBannerState
extends ConsumerState<PassSlipCountdownBanner> {
Timer? _timer;
Duration _remaining = Duration.zero;
bool _exceeded = false;
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
void _startTimer(DateTime expiresAt) {
_timer?.cancel();
_updateRemaining(expiresAt);
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
_updateRemaining(expiresAt);
});
}
void _stopTimer() {
_timer?.cancel();
_timer = null;
if (mounted) {
setState(() {
_remaining = Duration.zero;
_exceeded = false;
});
}
}
void _updateRemaining(DateTime expiresAt) {
final now = DateTime.now();
final diff = expiresAt.difference(now);
if (mounted) {
setState(() {
if (diff.isNegative || diff == Duration.zero) {
_remaining = Duration.zero;
_exceeded = true;
} else {
_remaining = diff;
_exceeded = false;
}
});
}
}
String _formatDuration(Duration d) {
final minutes = d.inMinutes.remainder(60).toString().padLeft(2, '0');
final seconds = d.inSeconds.remainder(60).toString().padLeft(2, '0');
if (d.inHours > 0) {
return '${d.inHours}:$minutes:$seconds';
}
return '$minutes:$seconds';
}
@override
Widget build(BuildContext context) {
final activeSlip = ref.watch(activePassSlipProvider);
// Start/stop timer based on active pass slip state
if (activeSlip != null && activeSlip.slipStart != null) {
final expiresAt =
activeSlip.slipStart!.add(const Duration(hours: 1));
if (_timer == null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_startTimer(expiresAt);
});
}
} else if (_timer != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_stopTimer();
});
}
final showBanner =
activeSlip != null && activeSlip.slipStart != null;
if (!showBanner) {
return widget.child;
}
final isUrgent = !_exceeded && _remaining.inMinutes < 5;
final bgColor = _exceeded || isUrgent
? Theme.of(context).colorScheme.errorContainer
: Theme.of(context).colorScheme.tertiaryContainer;
final fgColor = _exceeded || isUrgent
? Theme.of(context).colorScheme.onErrorContainer
: Theme.of(context).colorScheme.onTertiaryContainer;
final String message;
final IconData icon;
if (_exceeded) {
message = 'Pass slip time EXCEEDED — Please return and complete it';
icon = Icons.warning_amber_rounded;
} else {
message = 'Pass slip expires in ${_formatDuration(_remaining)}';
icon = Icons.directions_walk_rounded;
}
return Column(
children: [
Material(
child: InkWell(
onTap: () => context.go('/attendance'),
child: Container(
width: double.infinity,
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(color: bgColor),
child: Row(
children: [
Icon(icon, size: 20, color: fgColor),
const SizedBox(width: 8),
Expanded(
child: Text(
message,
style:
Theme.of(context).textTheme.bodyMedium?.copyWith(
color: fgColor,
fontWeight: FontWeight.w600,
),
),
),
Text(
'Tap to complete',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: fgColor.withValues(alpha: 0.7),
),
),
const SizedBox(width: 4),
Icon(
Icons.chevron_right,
size: 18,
color: fgColor.withValues(alpha: 0.7),
),
],
),
),
),
),
Expanded(child: widget.child),
],
);
}
}
+164
View File
@@ -0,0 +1,164 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../providers/attendance_provider.dart';
import '../providers/shift_countdown_provider.dart';
/// A persistent banner that shows a countdown to the shift start time.
///
/// Activated when [shiftCountdownProvider] is set to a non-null [DateTime].
/// Auto-dismisses when the countdown reaches zero or the provider is cleared
/// (e.g., when the user checks in).
class ShiftCountdownBanner extends ConsumerStatefulWidget {
const ShiftCountdownBanner({required this.child, super.key});
final Widget child;
@override
ConsumerState<ShiftCountdownBanner> createState() =>
_ShiftCountdownBannerState();
}
class _ShiftCountdownBannerState extends ConsumerState<ShiftCountdownBanner> {
Timer? _timer;
Duration _remaining = Duration.zero;
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
void _startTimer(DateTime target) {
_timer?.cancel();
_updateRemaining(target);
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
_updateRemaining(target);
});
}
void _updateRemaining(DateTime target) {
final now = DateTime.now();
final diff = target.difference(now);
if (diff.isNegative || diff == Duration.zero) {
_timer?.cancel();
// Clear the provider when countdown finishes
ref.read(shiftCountdownProvider.notifier).state = null;
if (mounted) setState(() => _remaining = Duration.zero);
return;
}
if (mounted) setState(() => _remaining = diff);
}
String _formatDuration(Duration d) {
final minutes = d.inMinutes.remainder(60).toString().padLeft(2, '0');
final seconds = d.inSeconds.remainder(60).toString().padLeft(2, '0');
if (d.inHours > 0) {
return '${d.inHours}:$minutes:$seconds';
}
return '$minutes:$seconds';
}
@override
Widget build(BuildContext context) {
final target = ref.watch(shiftCountdownProvider);
// Start/stop timer when target changes
ref.listen<DateTime?>(shiftCountdownProvider, (previous, next) {
if (next != null) {
_startTimer(next);
} else {
_timer?.cancel();
if (mounted) setState(() => _remaining = Duration.zero);
}
});
// Auto-dismiss when user checks in today (attendance stream updates)
ref.listen(attendanceLogsProvider, (previous, next) {
if (target == null) return;
final logs = next.valueOrNull ?? [];
final today = DateTime.now();
final checkedInToday = logs.any(
(log) =>
log.checkInAt.year == today.year &&
log.checkInAt.month == today.month &&
log.checkInAt.day == today.day,
);
if (checkedInToday) {
ref.read(shiftCountdownProvider.notifier).state = null;
}
});
// Initialize timer on first build if target is already set
if (target != null && _timer == null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_startTimer(target);
});
}
final showBanner = target != null && _remaining > Duration.zero;
return Column(
children: [
if (showBanner)
Material(
child: InkWell(
onTap: () => context.go('/attendance'),
child: Container(
width: double.infinity,
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
),
child: Row(
children: [
Icon(
Icons.timer_outlined,
size: 20,
color:
Theme.of(context).colorScheme.onPrimaryContainer,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Shift starts in ${_formatDuration(_remaining)}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context)
.colorScheme
.onPrimaryContainer,
fontWeight: FontWeight.w600,
),
),
),
Text(
'Tap to check in',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context)
.colorScheme
.onPrimaryContainer
.withValues(alpha: 0.7),
),
),
const SizedBox(width: 4),
Icon(
Icons.chevron_right,
size: 18,
color: Theme.of(context)
.colorScheme
.onPrimaryContainer
.withValues(alpha: 0.7),
),
],
),
),
),
),
Expanded(child: widget.child),
],
);
}
}