tasq/lib/widgets/shift_countdown_banner.dart
Marc Rejohn Castillano d484f62cbd 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>
2026-03-20 18:26:48 +08:00

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