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>
165 lines
5.3 KiB
Dart
165 lines
5.3 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/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),
|
|
],
|
|
);
|
|
}
|
|
}
|