From 53dfd92cecafe468bbd83fbdce114642603bb5ef Mon Sep 17 00:00:00 2001 From: Menno van Leeuwen Date: Tue, 26 Aug 2025 18:15:35 +0200 Subject: [PATCH] feat: Implement pending notifications screen and enhance notification handling --- lib/providers/supplement_provider.dart | 16 + lib/screens/home_screen.dart | 10 +- lib/screens/pending_notifications_screen.dart | 368 ++++++++++++++++++ lib/screens/settings_screen.dart | 44 +++ lib/services/database_helper.dart | 12 +- lib/services/notification_service.dart | 21 +- 6 files changed, 457 insertions(+), 14 deletions(-) create mode 100644 lib/screens/pending_notifications_screen.dart diff --git a/lib/providers/supplement_provider.dart b/lib/providers/supplement_provider.dart index 8f056b6..9a1a2da 100644 --- a/lib/providers/supplement_provider.dart +++ b/lib/providers/supplement_provider.dart @@ -99,6 +99,7 @@ class SupplementProvider with ChangeNotifier { required int reminderRetryInterval, required int maxRetryAttempts, }) async { + print('πŸ“± πŸ”„ MANUAL CHECK: Persistent reminders called from UI'); await _notificationService.checkPersistentReminders( persistentReminders, reminderRetryInterval, @@ -106,6 +107,16 @@ class SupplementProvider with ChangeNotifier { ); } + // Add a manual trigger method for testing + Future triggerRetryCheck() async { + print('πŸ“± 🚨 MANUAL TRIGGER: Forcing retry check...'); + await checkPersistentRemindersWithSettings( + persistentReminders: true, + reminderRetryInterval: 5, // Force 5 minute interval for testing + maxRetryAttempts: 3, + ); + } + @override void dispose() { _persistentReminderTimer?.cancel(); @@ -363,6 +374,11 @@ class SupplementProvider with ChangeNotifier { return await _notificationService.getPendingNotifications(); } + // Get pending notifications with retry information from database + Future>> getTrackedNotifications() async { + return await DatabaseHelper.instance.getPendingNotifications(); + } + // Debug method to test notification persistence Future rescheduleAllNotifications() async { await _rescheduleAllNotifications(); diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index a65e98c..14273cb 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -33,12 +33,12 @@ class _HomeScreenState extends State { } void _startPersistentReminderCheck() { - // Check immediately and then every 10 minutes + // Check immediately and then every 3 minutes (faster than any retry interval) _checkPersistentReminders(); - // Set up periodic checking + // Set up periodic checking every 3 minutes to ensure we catch all retry intervals Future.doWhile(() async { - await Future.delayed(const Duration(minutes: 10)); + await Future.delayed(const Duration(minutes: 3)); if (mounted) { await _checkPersistentReminders(); return true; @@ -51,14 +51,18 @@ class _HomeScreenState extends State { if (!mounted) return; try { + print('πŸ“± === HOME SCREEN: Checking persistent reminders ==='); final supplementProvider = context.read(); final settingsProvider = context.read(); + print('πŸ“± Settings: persistent=${settingsProvider.persistentReminders}, interval=${settingsProvider.reminderRetryInterval}, max=${settingsProvider.maxRetryAttempts}'); + await supplementProvider.checkPersistentRemindersWithSettings( persistentReminders: settingsProvider.persistentReminders, reminderRetryInterval: settingsProvider.reminderRetryInterval, maxRetryAttempts: settingsProvider.maxRetryAttempts, ); + print('πŸ“± === HOME SCREEN: Persistent reminder check complete ==='); } catch (e) { print('Error checking persistent reminders: $e'); } diff --git a/lib/screens/pending_notifications_screen.dart b/lib/screens/pending_notifications_screen.dart new file mode 100644 index 0000000..2548f74 --- /dev/null +++ b/lib/screens/pending_notifications_screen.dart @@ -0,0 +1,368 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import '../services/notification_service.dart'; +import '../services/database_helper.dart'; + +class PendingNotificationsScreen extends StatefulWidget { + const PendingNotificationsScreen({super.key}); + + @override + State createState() => _PendingNotificationsScreenState(); +} + +class _PendingNotificationsScreenState extends State { + List _pendingNotifications = []; + List> _trackedNotifications = []; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadNotifications(); + } + + Future _loadNotifications() async { + setState(() { + _isLoading = true; + }); + + try { + // 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(); + + // Combine and sort by scheduled time (soonest first) + final allNotifications = >[]; + + // 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'}', + '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'], + }); + } + + // 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('Error loading notifications: $e'); + setState(() { + _isLoading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Pending Notifications'), + actions: [ + 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, + color: Colors.grey, + ), + ), + SizedBox(height: 8), + Text( + 'All caught up!', + style: TextStyle( + color: Colors.grey, + ), + ), + ], + ), + ); + } + + // Combine and sort all notifications + final allNotifications = >[]; + + // 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'}', + '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'], + }); + } + + // 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 notification) { + final scheduledTime = notification['scheduledTime'] as DateTime; + final now = DateTime.now(); + final isOverdue = scheduledTime.isBefore(now); + final isRetry = notification['isRetry'] as bool; + final retryCount = notification['retryCount'] as int; + final type = notification['type'] as String; + + final timeUntil = scheduledTime.difference(now); + 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'; + } else { + timeText = 'Overdue by ${overdue.inMinutes}m'; + } + } 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 { + timeText = 'In ${timeUntil.inMinutes}m'; + } + } + + 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: Colors.orange.withOpacity(0.2), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.orange.withOpacity(0.5)), + ), + child: Text( + 'Retry #$retryCount', + style: TextStyle( + fontSize: 12, + color: Colors.orange.shade700, + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(height: 4), + ], + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: type == 'system' + ? Colors.blue.withOpacity(0.2) + : Colors.green.withOpacity(0.2), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: type == 'system' + ? Colors.blue.withOpacity(0.5) + : Colors.green.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, + ), + ), + ), + ], + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Icon( + isOverdue ? Icons.schedule_outlined : Icons.access_time, + size: 16, + color: isOverdue ? Colors.red : Colors.grey, + ), + const SizedBox(width: 8), + Text( + '${_formatTime(scheduledTime)} β€’ $timeText', + style: TextStyle( + color: isOverdue ? Colors.red : Colors.grey.shade700, + fontWeight: isOverdue ? FontWeight.bold : FontWeight.normal, + ), + ), + ], + ), + 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, + ), + ), + ], + ), + ], + ], + ), + ), + ); + } + + 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'; + } + } +} diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index ce343be..74829ce 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -3,6 +3,7 @@ import 'package:provider/provider.dart'; import '../providers/settings_provider.dart'; import '../providers/supplement_provider.dart'; import '../services/notification_service.dart'; +import 'pending_notifications_screen.dart'; class SettingsScreen extends StatelessWidget { const SettingsScreen({super.key}); @@ -212,6 +213,49 @@ class SettingsScreen extends StatelessWidget { ), ), const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.notifications_outlined), + const SizedBox(width: 8), + Text( + 'Notifications', + style: Theme.of(context).textTheme.titleMedium, + ), + ], + ), + const SizedBox(height: 8), + Text( + 'View and manage pending notifications', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const PendingNotificationsScreen(), + ), + ); + }, + icon: const Icon(Icons.list), + label: const Text('View Pending Notifications'), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 16), if (Theme.of(context).brightness == Brightness.dark) // Only show in debug mode for now Card( child: Padding( diff --git a/lib/services/database_helper.dart b/lib/services/database_helper.dart index 93de080..8d748a2 100644 --- a/lib/services/database_helper.dart +++ b/lib/services/database_helper.dart @@ -431,11 +431,13 @@ class DatabaseHelper { Future>> getPendingNotifications() async { Database db = await database; - return await db.query( - notificationTrackingTable, - where: 'status IN (?, ?)', - whereArgs: ['pending', 'retrying'], - ); + return await db.rawQuery(''' + SELECT nt.*, s.name as supplementName + FROM $notificationTrackingTable nt + LEFT JOIN $supplementsTable s ON nt.supplementId = s.id + WHERE nt.status IN (?, ?) + ORDER BY nt.scheduledTime ASC + ''', ['pending', 'retrying']); } Future markNotificationExpired(int notificationId) async { diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index e5d70db..da48f7e 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -148,7 +148,7 @@ class NotificationService { } } - void _handleTakeAction(String? payload, int? notificationId) { + Future _handleTakeAction(String? payload, int? notificationId) async { print('πŸ“± === HANDLING TAKE ACTION ==='); print('πŸ“± Payload received: $payload'); @@ -182,7 +182,7 @@ class NotificationService { // Mark notification as taken in database (this will cancel any pending retries) if (notificationId != null) { print('πŸ“± Marking notification $notificationId as taken'); - DatabaseHelper.instance.markNotificationTaken(notificationId); + await DatabaseHelper.instance.markNotificationTaken(notificationId); // Cancel any pending retry notifications for this notification _cancelRetryNotifications(notificationId); @@ -316,16 +316,24 @@ class NotificationService { final now = DateTime.now(); for (final notification in pendingNotifications) { - final scheduledTime = DateTime.parse(notification['scheduledTime']); + final scheduledTime = DateTime.parse(notification['scheduledTime']).toLocal(); final retryCount = notification['retryCount'] as int; final lastRetryTime = notification['lastRetryTime'] != null - ? DateTime.parse(notification['lastRetryTime']) + ? DateTime.parse(notification['lastRetryTime']).toLocal() : null; // Check if notification is overdue final timeSinceScheduled = now.difference(scheduledTime).inMinutes; final shouldRetry = timeSinceScheduled >= reminderRetryInterval; + print('πŸ“± Checking notification ${notification['notificationId']}:'); + print('πŸ“± Scheduled: $scheduledTime (local)'); + print('πŸ“± Now: $now'); + print('πŸ“± Time since scheduled: $timeSinceScheduled minutes'); + print('πŸ“± Retry interval: $reminderRetryInterval minutes'); + print('πŸ“± Should retry: $shouldRetry'); + print('πŸ“± Retry count: $retryCount / $maxRetryAttempts'); + // Check if we haven't exceeded max retry attempts if (retryCount >= maxRetryAttempts) { print('πŸ“± Notification ${notification['notificationId']} exceeded max attempts ($maxRetryAttempts)'); @@ -342,7 +350,10 @@ class NotificationService { } if (shouldRetry) { + print('πŸ“± ⚑ SCHEDULING RETRY for notification ${notification['notificationId']}'); await _scheduleRetryNotification(notification, retryCount + 1); + } else { + print('πŸ“± ⏸️ NOT READY FOR RETRY: ${notification['notificationId']}'); } } } catch (e) { @@ -381,13 +392,11 @@ class NotificationService { 'take_supplement', 'Take', showsUserInterface: true, - icon: DrawableResourceAndroidBitmap('@drawable/ic_check'), ), AndroidNotificationAction( 'snooze_10', 'Snooze 10min', showsUserInterface: true, - icon: DrawableResourceAndroidBitmap('@drawable/ic_snooze'), ), ], ),