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>
171 lines
5.0 KiB
Dart
171 lines
5.0 KiB
Dart
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),
|
|
],
|
|
);
|
|
}
|
|
}
|