notification overhaul

This commit is contained in:
2025-08-30 00:12:29 +02:00
parent 9ae2bb5654
commit 6dccac6124
25 changed files with 1313 additions and 3947 deletions

View File

@@ -1,823 +1,58 @@
import 'package:flutter/material.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:provider/provider.dart';
import '../services/notification_service.dart';
import '../services/database_helper.dart';
import '../providers/settings_provider.dart';
import '../providers/supplement_provider.dart';
class PendingNotificationsScreen extends StatefulWidget {
/// Simple placeholder screen for pending notifications.
/// In the simplified notification setup, we no longer track or retry notifications,
/// so there is no "pending notifications" list to display.
///
/// Keep this screen minimal to avoid heavy logic and dependencies.
class PendingNotificationsScreen extends StatelessWidget {
const PendingNotificationsScreen({super.key});
@override
State<PendingNotificationsScreen> createState() => _PendingNotificationsScreenState();
}
class _PendingNotificationsScreenState extends State<PendingNotificationsScreen> {
List<PendingNotificationRequest> _pendingNotifications = [];
List<Map<String, dynamic>> _trackedNotifications = [];
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadNotifications();
}
Future<void> _loadNotifications() async {
setState(() {
_isLoading = true;
});
try {
// Get settings for retry interval calculation
final settingsProvider = Provider.of<SettingsProvider>(context, listen: false);
final reminderRetryInterval = settingsProvider.reminderRetryInterval;
final maxRetryAttempts = settingsProvider.maxRetryAttempts;
// Get system pending notifications
final notificationService = NotificationService();
final systemPending = await notificationService.getPendingNotifications();
// Get tracked notifications from database (including retries)
final trackedNotifications = await DatabaseHelper.instance.getPendingNotifications();
// Create a more intelligent matching system
final allNotifications = <Map<String, dynamic>>[];
final matchedSystemIds = <int>{};
// First, try to match tracked notifications with system notifications
for (final trackedNotification in trackedNotifications) {
final scheduledTime = DateTime.parse(trackedNotification['scheduledTime']).toLocal();
final lastRetryTime = trackedNotification['lastRetryTime'] != null
? DateTime.parse(trackedNotification['lastRetryTime']).toLocal()
: null;
final retryCount = trackedNotification['retryCount'] ?? 0;
final isRetrying = trackedNotification['status'] == 'retrying';
final notificationId = trackedNotification['notificationId'] as int;
// Try to find matching system notification(s)
final matchingSystemNotifications = systemPending.where((systemNotification) {
return _isMatchingNotification(systemNotification, trackedNotification, retryCount);
}).toList();
// Calculate next retry time if this is a retry notification
DateTime? nextRetryTime;
bool hasReachedMaxRetries = retryCount >= maxRetryAttempts;
if (isRetrying && !hasReachedMaxRetries) {
if (lastRetryTime != null) {
// Next retry is based on last retry time + interval
nextRetryTime = lastRetryTime.add(Duration(minutes: reminderRetryInterval));
} else {
// First retry is based on original scheduled time + interval
nextRetryTime = scheduledTime.add(Duration(minutes: reminderRetryInterval));
}
}
// Create the notification entry
final notificationEntry = {
'id': notificationId,
'title': 'Time for ${trackedNotification['supplementName'] ?? 'Supplement'}',
'body': 'Take your supplement',
'scheduledTime': scheduledTime,
'nextRetryTime': nextRetryTime,
'lastRetryTime': lastRetryTime,
'type': matchingSystemNotifications.isNotEmpty ? 'matched' : 'tracked_only',
'status': trackedNotification['status'],
'isRetry': isRetrying,
'retryCount': retryCount,
'hasReachedMaxRetries': hasReachedMaxRetries,
'maxRetryAttempts': maxRetryAttempts,
'supplementName': trackedNotification['supplementName'],
'supplementId': trackedNotification['supplementId'],
'systemNotificationCount': matchingSystemNotifications.length,
};
allNotifications.add(notificationEntry);
// Mark these system notifications as matched
for (final systemNotification in matchingSystemNotifications) {
matchedSystemIds.add(systemNotification.id);
}
}
// Add unmatched system notifications
for (final systemNotification in systemPending) {
if (!matchedSystemIds.contains(systemNotification.id)) {
allNotifications.add({
'id': systemNotification.id,
'title': systemNotification.title ?? 'System Notification',
'body': systemNotification.body ?? '',
'scheduledTime': DateTime.now(), // PendingNotificationRequest doesn't have scheduledTime
'type': 'system_only',
'isRetry': false,
'retryCount': 0,
'hasReachedMaxRetries': false,
'maxRetryAttempts': maxRetryAttempts,
});
}
}
// Sort by scheduled time (soonest first)
allNotifications.sort((a, b) {
final timeA = a['scheduledTime'] as DateTime;
final timeB = b['scheduledTime'] as DateTime;
return timeA.compareTo(timeB);
});
setState(() {
_pendingNotifications = systemPending;
_trackedNotifications = trackedNotifications;
_isLoading = false;
});
} catch (e) {
print('SupplementsLog: Error loading notifications: $e');
setState(() {
_isLoading = false;
});
}
}
Future<void> _showCleanupDialog() async {
final result = await showDialog<String>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Cleanup Old Notifications'),
content: const Text(
'This will help clean up old or duplicate notifications:\n\n'
'• Clear stale system notifications\n'
'• Remove very old tracked notifications (>24h overdue)\n'
'• Reschedule fresh notifications for active supplements\n\n'
'Choose an option:',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop('cancel'),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop('clear_old'),
child: const Text('Clear Old Only'),
),
TextButton(
onPressed: () => Navigator.of(context).pop('clear_all'),
child: const Text('Clear All & Reschedule'),
),
],
);
},
);
if (result != null && result != 'cancel') {
await _performCleanup(result);
}
}
Future<void> _performCleanup(String action) async {
try {
// Show loading indicator
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Cleaning up notifications...'),
duration: Duration(seconds: 2),
),
);
}
final notificationService = NotificationService();
if (action == 'clear_all') {
// Clear all notifications and reschedule fresh ones
await notificationService.cancelAllReminders();
await DatabaseHelper.instance.cleanupOldNotificationTracking();
// Reschedule notifications for all active supplements
final supplementProvider = Provider.of<SupplementProvider>(context, listen: false);
await supplementProvider.rescheduleAllNotifications();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('All notifications cleared and rescheduled!'),
backgroundColor: Colors.green,
),
);
}
} else if (action == 'clear_old') {
// Clear only very old notifications (>24 hours overdue)
await _clearOldNotifications();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Old notifications cleared!'),
backgroundColor: Colors.orange,
),
);
}
}
// Refresh the list
await _loadNotifications();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error during cleanup: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
Future<void> _clearOldNotifications() async {
final now = DateTime.now();
final cutoff = now.subtract(const Duration(hours: 24));
// Clear very old tracked notifications
final db = await DatabaseHelper.instance.database;
await db.delete(
'notification_tracking',
where: 'scheduledTime < ? AND status IN (?, ?)',
whereArgs: [cutoff.toIso8601String(), 'pending', 'retrying'],
);
// Note: We can't selectively clear system notifications as we don't have their scheduled times
// This is a limitation of the flutter_local_notifications plugin
}
bool _isMatchingNotification(PendingNotificationRequest systemNotification, Map<String, dynamic> trackedNotification, int retryCount) {
final trackedId = trackedNotification['notificationId'] as int;
final supplementId = trackedNotification['supplementId'] as int;
// Check for exact ID match (original notification)
if (systemNotification.id == trackedId) {
return true;
}
// Check for retry notification IDs (200000 + original_id * 10 + retry_attempt)
for (int attempt = 1; attempt <= retryCount; attempt++) {
final retryId = 200000 + (trackedId * 10) + attempt;
if (systemNotification.id == retryId) {
return true;
}
}
// Check for snooze notification IDs (supplementId * 1000 + minutes)
// Common snooze intervals: 5, 10, 15, 30 minutes
final snoozeIds = [5, 10, 15, 30].map((minutes) => supplementId * 1000 + minutes);
if (snoozeIds.contains(systemNotification.id)) {
return true;
}
// Check if it's within the supplement's notification ID range (supplementId * 100 + reminderIndex)
final baseId = supplementId * 100;
if (systemNotification.id >= baseId && systemNotification.id < baseId + 10) {
return true;
}
return false;
}
Color _getTypeColor(String type) {
switch (type) {
case 'matched':
return Colors.green;
case 'tracked_only':
return Colors.orange;
case 'system_only':
return Colors.blue;
default:
return Colors.grey;
}
}
Color _getTypeDarkColor(String type) {
switch (type) {
case 'matched':
return Colors.green.shade700;
case 'tracked_only':
return Colors.orange.shade700;
case 'system_only':
return Colors.blue.shade700;
default:
return Colors.grey.shade700;
}
}
String _getTypeLabel(String type, Map<String, dynamic> notification) {
final systemCount = notification['systemNotificationCount'] as int? ?? 0;
switch (type) {
case 'matched':
return systemCount > 1 ? 'Matched' : 'Synced';
case 'tracked_only':
return 'Tracking Only';
case 'system_only':
return 'System Only';
default:
return 'Unknown';
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Pending Notifications'),
actions: [
IconButton(
icon: const Icon(Icons.cleaning_services),
onPressed: _showCleanupDialog,
tooltip: 'Cleanup old notifications',
),
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _loadNotifications,
tooltip: 'Refresh',
),
],
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: RefreshIndicator(
onRefresh: _loadNotifications,
child: _buildNotificationsList(),
),
);
}
Widget _buildNotificationsList() {
if (_pendingNotifications.isEmpty && _trackedNotifications.isEmpty) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.notifications_off,
size: 64,
color: Colors.grey,
),
SizedBox(height: 16),
Text(
'No pending notifications',
style: TextStyle(
fontSize: 18,
body: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.notifications_none,
size: 72,
color: Colors.grey,
),
),
SizedBox(height: 8),
Text(
'All caught up!',
style: TextStyle(
color: Colors.grey,
const SizedBox(height: 16),
Text(
'No pending notifications UI in simple mode',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Colors.grey.shade800,
fontWeight: FontWeight.w600,
),
),
),
],
),
);
}
// Get settings for retry interval calculation
final settingsProvider = Provider.of<SettingsProvider>(context, listen: false);
final reminderRetryInterval = settingsProvider.reminderRetryInterval;
final maxRetryAttempts = settingsProvider.maxRetryAttempts;
// Create a more intelligent matching system
final allNotifications = <Map<String, dynamic>>[];
final matchedSystemIds = <int>{};
// First, try to match tracked notifications with system notifications
for (final trackedNotification in _trackedNotifications) {
final scheduledTime = DateTime.parse(trackedNotification['scheduledTime']).toLocal();
final lastRetryTime = trackedNotification['lastRetryTime'] != null
? DateTime.parse(trackedNotification['lastRetryTime']).toLocal()
: null;
final retryCount = trackedNotification['retryCount'] ?? 0;
final isRetrying = trackedNotification['status'] == 'retrying';
final notificationId = trackedNotification['notificationId'] as int;
// Try to find matching system notification(s)
final matchingSystemNotifications = _pendingNotifications.where((systemNotification) {
return _isMatchingNotification(systemNotification, trackedNotification, retryCount);
}).toList();
// Calculate next retry time if this is a retry notification
DateTime? nextRetryTime;
bool hasReachedMaxRetries = retryCount >= maxRetryAttempts;
if (isRetrying && !hasReachedMaxRetries) {
if (lastRetryTime != null) {
// Next retry is based on last retry time + interval
nextRetryTime = lastRetryTime.add(Duration(minutes: reminderRetryInterval));
} else {
// First retry is based on original scheduled time + interval
nextRetryTime = scheduledTime.add(Duration(minutes: reminderRetryInterval));
}
}
// Create the notification entry
final notificationEntry = {
'id': notificationId,
'title': 'Time for ${trackedNotification['supplementName'] ?? 'Supplement'}',
'body': 'Take your supplement',
'scheduledTime': scheduledTime,
'nextRetryTime': nextRetryTime,
'lastRetryTime': lastRetryTime,
'type': matchingSystemNotifications.isNotEmpty ? 'matched' : 'tracked_only',
'status': trackedNotification['status'],
'isRetry': isRetrying,
'retryCount': retryCount,
'hasReachedMaxRetries': hasReachedMaxRetries,
'maxRetryAttempts': maxRetryAttempts,
'supplementName': trackedNotification['supplementName'],
'supplementId': trackedNotification['supplementId'],
'systemNotificationCount': matchingSystemNotifications.length,
};
allNotifications.add(notificationEntry);
// Mark these system notifications as matched
for (final systemNotification in matchingSystemNotifications) {
matchedSystemIds.add(systemNotification.id);
}
}
// Add unmatched system notifications
for (final systemNotification in _pendingNotifications) {
if (!matchedSystemIds.contains(systemNotification.id)) {
allNotifications.add({
'id': systemNotification.id,
'title': systemNotification.title ?? 'System Notification',
'body': systemNotification.body ?? '',
'scheduledTime': DateTime.now(), // PendingNotificationRequest doesn't have scheduledTime
'type': 'system_only',
'isRetry': false,
'retryCount': 0,
'hasReachedMaxRetries': false,
'maxRetryAttempts': maxRetryAttempts,
});
}
}
// Sort by scheduled time (soonest first)
allNotifications.sort((a, b) {
final timeA = a['scheduledTime'] as DateTime;
final timeB = b['scheduledTime'] as DateTime;
return timeA.compareTo(timeB);
});
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: allNotifications.length,
itemBuilder: (context, index) {
final notification = allNotifications[index];
return _buildNotificationCard(notification);
},
);
}
Widget _buildNotificationCard(Map<String, dynamic> notification) {
final scheduledTime = notification['scheduledTime'] as DateTime;
final nextRetryTime = notification['nextRetryTime'] as DateTime?;
final lastRetryTime = notification['lastRetryTime'] as DateTime?;
final now = DateTime.now();
final isOverdue = scheduledTime.isBefore(now);
final isRetry = notification['isRetry'] as bool;
final retryCount = notification['retryCount'] as int;
final hasReachedMaxRetries = notification['hasReachedMaxRetries'] as bool? ?? false;
final maxRetryAttempts = notification['maxRetryAttempts'] as int? ?? 3;
final type = notification['type'] as String;
// Determine which time to show and calculate time text
DateTime displayTime = scheduledTime;
String timePrefix = '';
bool showAsNextRetry = false;
String timeText = '';
if (isRetry && nextRetryTime != null && !hasReachedMaxRetries) {
// For retry notifications that haven't reached max retries, show next retry time
final timeDifference = nextRetryTime.difference(now);
if (timeDifference.inSeconds.abs() < 30) {
// If within 30 seconds, show "due now"
displayTime = nextRetryTime;
timePrefix = 'Next retry: ';
showAsNextRetry = true;
timeText = 'due now';
} else if (nextRetryTime.isAfter(now)) {
displayTime = nextRetryTime;
timePrefix = 'Next retry: ';
showAsNextRetry = true;
} else {
// If next retry time has passed, show it's overdue for retry
displayTime = nextRetryTime;
timePrefix = 'Retry overdue: ';
showAsNextRetry = true;
}
} else if (isRetry && hasReachedMaxRetries) {
// For notifications that have reached max retries, don't show next retry time
showAsNextRetry = false;
}
// Calculate time text if not already set
if (timeText.isEmpty) {
final isDisplayTimeOverdue = displayTime.isBefore(now);
final timeUntil = displayTime.difference(now);
if (isDisplayTimeOverdue) {
final overdue = now.difference(displayTime);
if (overdue.inDays > 0) {
timeText = 'Overdue by ${overdue.inDays}d ${overdue.inHours % 24}h';
} else if (overdue.inHours > 0) {
timeText = 'Overdue by ${overdue.inHours}h ${overdue.inMinutes % 60}m';
} else if (overdue.inMinutes > 0) {
timeText = 'Overdue by ${overdue.inMinutes}m';
} else {
timeText = 'Overdue by ${overdue.inSeconds}s';
}
} else {
if (timeUntil.inDays > 0) {
timeText = 'In ${timeUntil.inDays}d ${timeUntil.inHours % 24}h';
} else if (timeUntil.inHours > 0) {
timeText = 'In ${timeUntil.inHours}h ${timeUntil.inMinutes % 60}m';
} else if (timeUntil.inMinutes > 0) {
timeText = 'In ${timeUntil.inMinutes}m';
} else {
timeText = 'In ${timeUntil.inSeconds}s';
}
}
}
// Determine if display time is overdue (needed for icon colors later)
final isDisplayTimeOverdue = displayTime.isBefore(now);
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
notification['title'] ?? 'Notification',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
if (notification['body'] != null) ...[
const SizedBox(height: 4),
Text(
notification['body'],
style: Theme.of(context).textTheme.bodyMedium,
),
],
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (isRetry) ...[
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: hasReachedMaxRetries
? Colors.red.withOpacity(0.2)
: Colors.orange.withOpacity(0.2),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: hasReachedMaxRetries
? Colors.red.withOpacity(0.5)
: Colors.orange.withOpacity(0.5),
),
),
child: Text(
hasReachedMaxRetries
? 'Max retries ($retryCount/$maxRetryAttempts)'
: 'Retry #$retryCount',
style: TextStyle(
fontSize: 12,
color: hasReachedMaxRetries
? Colors.red.shade700
: Colors.orange.shade700,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(height: 4),
],
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: _getTypeColor(type).withOpacity(0.2),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: _getTypeColor(type).withOpacity(0.5),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_getTypeLabel(type, notification),
style: TextStyle(
fontSize: 12,
color: _getTypeDarkColor(type),
fontWeight: FontWeight.bold,
),
),
if (type == 'matched' && (notification['systemNotificationCount'] as int? ?? 0) > 1) ...[
const SizedBox(width: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.8),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'${notification['systemNotificationCount']}',
style: TextStyle(
fontSize: 10,
color: _getTypeDarkColor(type),
fontWeight: FontWeight.bold,
),
),
),
],
],
),
),
],
),
],
),
const SizedBox(height: 12),
// Show original scheduled time
Row(
children: [
Icon(
isOverdue ? Icons.schedule_outlined : Icons.access_time,
size: 16,
color: isOverdue ? Colors.red : Colors.grey,
),
const SizedBox(width: 8),
Text(
'Scheduled: ${_formatTime(scheduledTime)}',
style: TextStyle(
color: isOverdue ? Colors.red : Colors.grey.shade700,
fontWeight: isOverdue ? FontWeight.bold : FontWeight.normal,
),
),
if (isOverdue) ...[
const SizedBox(width: 8),
Text(
'(${_formatOverdueTime(scheduledTime)})',
style: TextStyle(
color: Colors.red,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
],
],
),
// Show next retry time if applicable
if (showAsNextRetry && nextRetryTime != null) ...[
const SizedBox(height: 6),
Row(
children: [
Icon(
isDisplayTimeOverdue ? Icons.warning_outlined : Icons.refresh,
size: 16,
color: isDisplayTimeOverdue ? Colors.red : Colors.orange,
),
const SizedBox(width: 8),
Text(
'${timePrefix}${_formatTime(displayTime)}$timeText',
style: TextStyle(
color: isDisplayTimeOverdue ? Colors.red : Colors.orange.shade700,
fontWeight: FontWeight.bold,
fontSize: 13,
),
),
],
),
],
// Show max retries reached message
if (isRetry && hasReachedMaxRetries) ...[
const SizedBox(height: 6),
Row(
children: [
const Icon(
Icons.block,
size: 16,
color: Colors.red,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Maximum retry attempts reached. No more reminders will be sent.',
style: TextStyle(
color: Colors.red.shade700,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
],
),
],
// Show last retry time if available
if (isRetry && lastRetryTime != null) ...[
const SizedBox(height: 6),
Row(
children: [
const Icon(
Icons.history,
size: 16,
color: Colors.grey,
),
const SizedBox(width: 8),
Text(
'Last retry: ${_formatTime(lastRetryTime)}',
style: TextStyle(
color: Colors.grey.shade600,
fontSize: 12,
fontStyle: FontStyle.italic,
),
),
],
),
],
if (type == 'tracked' && notification['supplementName'] != null) ...[
const SizedBox(height: 8),
Row(
children: [
const Icon(
Icons.medication,
size: 16,
color: Colors.grey,
),
const SizedBox(width: 8),
Text(
notification['supplementName'],
style: TextStyle(
color: Colors.grey.shade700,
fontStyle: FontStyle.italic,
Text(
'Notifications are now scheduled daily at the selected times '
'without retries or tracking.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey.shade600,
),
),
],
),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: () => Navigator.of(context).maybePop(),
icon: const Icon(Icons.close),
label: const Text('Close'),
),
],
],
),
),
),
);
}
String _formatTime(DateTime dateTime) {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final tomorrow = today.add(const Duration(days: 1));
final notificationDate = DateTime(dateTime.year, dateTime.month, dateTime.day);
String timeStr = '${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}';
if (notificationDate == today) {
return 'Today $timeStr';
} else if (notificationDate == tomorrow) {
return 'Tomorrow $timeStr';
} else {
return '${dateTime.day}/${dateTime.month} $timeStr';
}
}
String _formatOverdueTime(DateTime scheduledTime) {
final now = DateTime.now();
final overdue = now.difference(scheduledTime);
if (overdue.inDays > 0) {
return '${overdue.inDays}d ${overdue.inHours % 24}h overdue';
} else if (overdue.inHours > 0) {
return '${overdue.inHours}h ${overdue.inMinutes % 60}m overdue';
} else {
return '${overdue.inMinutes}m overdue';
}
}
}