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:
+50
-9
@@ -14,6 +14,7 @@ import 'app.dart';
|
||||
import 'theme/app_theme.dart';
|
||||
import 'providers/notifications_provider.dart';
|
||||
import 'providers/notification_navigation_provider.dart';
|
||||
import 'providers/shift_countdown_provider.dart';
|
||||
import 'utils/app_time.dart';
|
||||
import 'utils/notification_permission.dart';
|
||||
import 'utils/location_permission.dart';
|
||||
@@ -197,7 +198,7 @@ Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
||||
// Create a unique ID for the notification display
|
||||
final int id = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
|
||||
// Build payload string with ticket/task information for navigation
|
||||
// Build payload string with ticket/task/route information for navigation
|
||||
final payloadParts = <String>[];
|
||||
final taskId =
|
||||
(message.data['task_id'] ??
|
||||
@@ -209,7 +210,11 @@ Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
||||
message.data['ticketId'] ??
|
||||
message.data['ticket']?.toString() ??
|
||||
'';
|
||||
final navigateTo = message.data['navigate_to']?.toString() ?? '';
|
||||
|
||||
if (navigateTo.isNotEmpty) {
|
||||
payloadParts.add('route:$navigateTo');
|
||||
}
|
||||
if (taskId != null && taskId.isNotEmpty) {
|
||||
payloadParts.add('task:$taskId');
|
||||
}
|
||||
@@ -424,6 +429,25 @@ Future<void> main() async {
|
||||
} catch (_) {}
|
||||
|
||||
final formatted = _formatNotificationFromData(dataForFormatting);
|
||||
|
||||
// Activate the shift countdown banner for start_15 notifications
|
||||
final msgType = message.data['type']?.toString() ?? '';
|
||||
if (msgType == 'start_15') {
|
||||
// The shift starts 15 minutes from when the notification is sent.
|
||||
// Use scheduled_for from data if available, otherwise estimate.
|
||||
final scheduledFor = message.data['scheduled_for']?.toString();
|
||||
DateTime shiftStart;
|
||||
if (scheduledFor != null && scheduledFor.isNotEmpty) {
|
||||
shiftStart = DateTime.tryParse(scheduledFor) ??
|
||||
DateTime.now().add(const Duration(minutes: 15));
|
||||
} else {
|
||||
shiftStart = DateTime.now().add(const Duration(minutes: 15));
|
||||
}
|
||||
_globalProviderContainer
|
||||
.read(shiftCountdownProvider.notifier)
|
||||
.state = shiftStart;
|
||||
}
|
||||
|
||||
String? stableId =
|
||||
(message.data['notification_id'] as String?) ?? message.messageId;
|
||||
if (stableId == null) {
|
||||
@@ -483,7 +507,7 @@ Future<void> main() async {
|
||||
recent[stableId] = now;
|
||||
await prefs.setString('recent_notifs', jsonEncode(recent));
|
||||
|
||||
// Build payload string with ticket/task information for navigation
|
||||
// Build payload string with route/ticket/task information for navigation
|
||||
final payloadParts = <String>[];
|
||||
final taskId =
|
||||
(message.data['task_id'] ??
|
||||
@@ -495,7 +519,12 @@ Future<void> main() async {
|
||||
message.data['ticketId'] ??
|
||||
message.data['ticket']?.toString() ??
|
||||
'';
|
||||
final navigateTo =
|
||||
message.data['navigate_to']?.toString() ?? '';
|
||||
|
||||
if (navigateTo.isNotEmpty) {
|
||||
payloadParts.add('route:$navigateTo');
|
||||
}
|
||||
if (taskId != null && taskId.isNotEmpty) {
|
||||
payloadParts.add('task:$taskId');
|
||||
}
|
||||
@@ -554,17 +583,20 @@ Future<void> main() async {
|
||||
// initialize the local notifications plugin so we can post alerts later
|
||||
await NotificationService.initialize(
|
||||
onDidReceiveNotificationResponse: (response) {
|
||||
// handle user tapping a notification; the payload format is "ticket:ID",
|
||||
// "task:ID", "tasknum:NUMBER", or a combination separated by "|"
|
||||
// handle user tapping a notification; the payload format is "route:PATH",
|
||||
// "ticket:ID", "task:ID", or a combination separated by "|"
|
||||
final payload = response.payload;
|
||||
if (payload != null && payload.isNotEmpty) {
|
||||
// Parse the payload to extract ticket and task information
|
||||
// Parse the payload to extract navigation information
|
||||
final parts = payload.split('|');
|
||||
String? ticketId;
|
||||
String? taskId;
|
||||
String? route;
|
||||
|
||||
for (final part in parts) {
|
||||
if (part.startsWith('ticket:')) {
|
||||
if (part.startsWith('route:')) {
|
||||
route = part.substring('route:'.length);
|
||||
} else if (part.startsWith('ticket:')) {
|
||||
ticketId = part.substring('ticket:'.length);
|
||||
} else if (part.startsWith('task:')) {
|
||||
taskId = part.substring('task:'.length);
|
||||
@@ -572,14 +604,22 @@ Future<void> main() async {
|
||||
}
|
||||
|
||||
// Update the pending navigation provider.
|
||||
// Prefer task over ticket — assignment notifications include both
|
||||
// IDs but the primary entity is the task.
|
||||
if (taskId != null && taskId.isNotEmpty) {
|
||||
// Prefer route (from scheduled reminders), then task, then ticket.
|
||||
if (route != null && route.isNotEmpty) {
|
||||
_globalProviderContainer
|
||||
.read(pendingNotificationNavigationProvider.notifier)
|
||||
.state = (
|
||||
type: 'route',
|
||||
id: '',
|
||||
route: route,
|
||||
);
|
||||
} else if (taskId != null && taskId.isNotEmpty) {
|
||||
_globalProviderContainer
|
||||
.read(pendingNotificationNavigationProvider.notifier)
|
||||
.state = (
|
||||
type: 'task',
|
||||
id: taskId,
|
||||
route: null,
|
||||
);
|
||||
} else if (ticketId != null && ticketId.isNotEmpty) {
|
||||
_globalProviderContainer
|
||||
@@ -587,6 +627,7 @@ Future<void> main() async {
|
||||
.state = (
|
||||
type: 'ticket',
|
||||
id: ticketId,
|
||||
route: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
typedef PendingNavigation = ({String type, String id})?;
|
||||
typedef PendingNavigation = ({String type, String id, String? route})?;
|
||||
|
||||
final pendingNotificationNavigationProvider = StateProvider<PendingNavigation>(
|
||||
(ref) => null,
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
/// Holds the target shift start time when a `start_15` notification is received.
|
||||
///
|
||||
/// Set to the shift start [DateTime] to activate the countdown banner.
|
||||
/// Set to `null` to dismiss (e.g., when the user checks in or the countdown
|
||||
/// reaches zero).
|
||||
final shiftCountdownProvider = StateProvider<DateTime?>((ref) => null);
|
||||
@@ -51,13 +51,25 @@ class _NotificationBridgeState extends ConsumerState<NotificationBridge>
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigate to the task or ticket detail screen using GoRouter.
|
||||
void _goToDetail({String? taskId, String? ticketId}) {
|
||||
/// Navigate to the appropriate screen using GoRouter.
|
||||
///
|
||||
/// Supports generic [route] for new notification types, plus legacy
|
||||
/// [taskId], [ticketId], and [itServiceRequestId] fallbacks.
|
||||
void _goToDetail({
|
||||
String? taskId,
|
||||
String? ticketId,
|
||||
String? itServiceRequestId,
|
||||
String? route,
|
||||
}) {
|
||||
final router = ref.read(appRouterProvider);
|
||||
if (taskId != null && taskId.isNotEmpty) {
|
||||
if (route != null && route.isNotEmpty) {
|
||||
router.go(route);
|
||||
} else if (taskId != null && taskId.isNotEmpty) {
|
||||
router.go('/tasks/$taskId');
|
||||
} else if (ticketId != null && ticketId.isNotEmpty) {
|
||||
router.go('/tickets/$ticketId');
|
||||
} else if (itServiceRequestId != null && itServiceRequestId.isNotEmpty) {
|
||||
router.go('/it-service-requests/$itServiceRequestId');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,20 +108,34 @@ class _NotificationBridgeState extends ConsumerState<NotificationBridge>
|
||||
});
|
||||
}
|
||||
|
||||
/// Extract task/ticket IDs from the FCM data payload and navigate.
|
||||
/// Extract navigation target from FCM data payload and navigate.
|
||||
///
|
||||
/// The FCM data map contains keys such as `task_id` / `taskId` /
|
||||
/// `ticket_id` / `ticketId` — these are the same keys used in the
|
||||
/// background handler ([_firebaseMessagingBackgroundHandler]).
|
||||
/// Prefers the `navigate_to` field (used by scheduled notification
|
||||
/// reminders) and falls back to legacy `task_id` / `ticket_id` keys.
|
||||
void _handleMessageTap(RemoteMessage message) {
|
||||
final data = message.data;
|
||||
|
||||
final String? taskId = (data['task_id'] ?? data['taskId'] ?? data['task'])
|
||||
?.toString();
|
||||
// New: use navigate_to route if present (scheduled reminder notifications)
|
||||
final String? navigateTo = data['navigate_to']?.toString();
|
||||
if (navigateTo != null && navigateTo.isNotEmpty) {
|
||||
_goToDetail(route: navigateTo);
|
||||
return;
|
||||
}
|
||||
|
||||
// Legacy fallback for task_id / ticket_id
|
||||
final String? taskId =
|
||||
(data['task_id'] ?? data['taskId'] ?? data['task'])?.toString();
|
||||
final String? ticketId =
|
||||
(data['ticket_id'] ?? data['ticketId'] ?? data['ticket'])?.toString();
|
||||
final String? isrId =
|
||||
(data['it_service_request_id'] ?? data['itServiceRequestId'])
|
||||
?.toString();
|
||||
|
||||
_goToDetail(taskId: taskId, ticketId: ticketId);
|
||||
_goToDetail(
|
||||
taskId: taskId,
|
||||
ticketId: ticketId,
|
||||
itServiceRequestId: isrId,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -138,8 +164,11 @@ class _NotificationBridgeState extends ConsumerState<NotificationBridge>
|
||||
) {
|
||||
if (next != null) {
|
||||
_goToDetail(
|
||||
route: next.route,
|
||||
taskId: next.type == 'task' ? next.id : null,
|
||||
ticketId: next.type == 'ticket' ? next.id : null,
|
||||
itServiceRequestId:
|
||||
next.type == 'it_service_request' ? next.id : null,
|
||||
);
|
||||
// Clear the pending navigation after handling
|
||||
ref.read(pendingNotificationNavigationProvider.notifier).state = null;
|
||||
|
||||
@@ -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