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:
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user