diff --git a/lib/screens/pending_notifications_screen.dart b/lib/screens/pending_notifications_screen.dart index 2548f74..73508e3 100644 --- a/lib/screens/pending_notifications_screen.dart +++ b/lib/screens/pending_notifications_screen.dart @@ -1,7 +1,10 @@ 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 { const PendingNotificationsScreen({super.key}); @@ -27,6 +30,11 @@ class _PendingNotificationsScreenState extends State }); try { + // Get settings for retry interval calculation + final settingsProvider = Provider.of(context, listen: false); + final reminderRetryInterval = settingsProvider.reminderRetryInterval; + final maxRetryAttempts = settingsProvider.maxRetryAttempts; + // Get system pending notifications final notificationService = NotificationService(); final systemPending = await notificationService.getPendingNotifications(); @@ -34,37 +42,81 @@ class _PendingNotificationsScreenState extends State // Get tracked notifications from database (including retries) final trackedNotifications = await DatabaseHelper.instance.getPendingNotifications(); - // Combine and sort by scheduled time (soonest first) + // Create a more intelligent matching system final allNotifications = >[]; + final matchedSystemIds = {}; - // Add system notifications - for (final notification in systemPending) { - allNotifications.add({ - 'id': notification.id, - 'title': notification.title ?? 'Notification', - 'body': notification.body ?? '', - 'scheduledTime': DateTime.now(), // PendingNotificationRequest doesn't have scheduledTime - 'type': 'system', - 'isRetry': false, - 'retryCount': 0, - }); - } - - // Add tracked notifications from database - for (final notification in trackedNotifications) { - final scheduledTime = DateTime.parse(notification['scheduledTime']).toLocal(); - allNotifications.add({ - 'id': notification['notificationId'], - 'title': 'Time for ${notification['supplementName'] ?? 'Supplement'}', + // 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, - 'type': 'tracked', - 'status': notification['status'], - 'isRetry': notification['status'] == 'retrying', - 'retryCount': notification['retryCount'] ?? 0, - 'supplementName': notification['supplementName'], - 'supplementId': notification['supplementId'], - }); + '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) @@ -87,12 +139,202 @@ class _PendingNotificationsScreenState extends State } } + Future _showCleanupDialog() async { + final result = await showDialog( + 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 _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(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 _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 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 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, @@ -140,37 +382,86 @@ class _PendingNotificationsScreenState extends State ); } - // Combine and sort all notifications + // Get settings for retry interval calculation + final settingsProvider = Provider.of(context, listen: false); + final reminderRetryInterval = settingsProvider.reminderRetryInterval; + final maxRetryAttempts = settingsProvider.maxRetryAttempts; + + // Create a more intelligent matching system final allNotifications = >[]; + final matchedSystemIds = {}; - // Add system notifications - for (final notification in _pendingNotifications) { - allNotifications.add({ - 'id': notification.id, - 'title': notification.title ?? 'Notification', - 'body': notification.body ?? '', - 'scheduledTime': DateTime.now(), // PendingNotificationRequest doesn't have scheduledTime - 'type': 'system', - 'isRetry': false, - 'retryCount': 0, - }); - } - - // Add tracked notifications from database - for (final notification in _trackedNotifications) { - final scheduledTime = DateTime.parse(notification['scheduledTime']).toLocal(); - allNotifications.add({ - 'id': notification['notificationId'], - 'title': 'Time for ${notification['supplementName'] ?? 'Supplement'}', + // 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, - 'type': 'tracked', - 'status': notification['status'], - 'isRetry': notification['status'] == 'retrying', - 'retryCount': notification['retryCount'] ?? 0, - 'supplementName': notification['supplementName'], - 'supplementId': notification['supplementId'], - }); + '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) @@ -192,33 +483,77 @@ class _PendingNotificationsScreenState extends State Widget _buildNotificationCard(Map 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; - final timeUntil = scheduledTime.difference(now); - String timeText; + // Determine which time to show and calculate time text + DateTime displayTime = scheduledTime; + String timePrefix = ''; + bool showAsNextRetry = false; + String timeText = ''; - if (isOverdue) { - final overdue = now.difference(scheduledTime); - 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'; + 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 { - timeText = 'Overdue by ${overdue.inMinutes}m'; + // If next retry time has passed, show it's overdue for retry + displayTime = nextRetryTime; + timePrefix = 'Retry overdue: '; + showAsNextRetry = true; } - } 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 (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 { - timeText = 'In ${timeUntil.inMinutes}m'; + 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), @@ -259,15 +594,25 @@ class _PendingNotificationsScreenState extends State vertical: 4, ), decoration: BoxDecoration( - color: Colors.orange.withOpacity(0.2), + color: hasReachedMaxRetries + ? Colors.red.withOpacity(0.2) + : Colors.orange.withOpacity(0.2), borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.orange.withOpacity(0.5)), + border: Border.all( + color: hasReachedMaxRetries + ? Colors.red.withOpacity(0.5) + : Colors.orange.withOpacity(0.5), + ), ), child: Text( - 'Retry #$retryCount', + hasReachedMaxRetries + ? 'Max retries ($retryCount/$maxRetryAttempts)' + : 'Retry #$retryCount', style: TextStyle( fontSize: 12, - color: Colors.orange.shade700, + color: hasReachedMaxRetries + ? Colors.red.shade700 + : Colors.orange.shade700, fontWeight: FontWeight.bold, ), ), @@ -280,25 +625,42 @@ class _PendingNotificationsScreenState extends State vertical: 4, ), decoration: BoxDecoration( - color: type == 'system' - ? Colors.blue.withOpacity(0.2) - : Colors.green.withOpacity(0.2), + color: _getTypeColor(type).withOpacity(0.2), borderRadius: BorderRadius.circular(12), border: Border.all( - color: type == 'system' - ? Colors.blue.withOpacity(0.5) - : Colors.green.withOpacity(0.5), + color: _getTypeColor(type).withOpacity(0.5), ), ), - child: Text( - type == 'system' ? 'System' : 'Tracked', - style: TextStyle( - fontSize: 12, - color: type == 'system' - ? Colors.blue.shade700 - : Colors.green.shade700, - fontWeight: FontWeight.bold, - ), + 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, + ), + ), + ), + ], + ], ), ), ], @@ -306,6 +668,7 @@ class _PendingNotificationsScreenState extends State ], ), const SizedBox(height: 12), + // Show original scheduled time Row( children: [ Icon( @@ -315,14 +678,93 @@ class _PendingNotificationsScreenState extends State ), const SizedBox(width: 8), Text( - '${_formatTime(scheduledTime)} • $timeText', + '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( @@ -365,4 +807,17 @@ class _PendingNotificationsScreenState extends State 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'; + } + } }