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
+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),
],
);
}
}