diff --git a/lib/main.dart b/lib/main.dart index 5a015e7..0264bb1 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,9 +1,9 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; // Import this -import 'package:supplements/logging.dart'; +import 'package:provider/provider.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:supplements/logging.dart'; import 'providers/settings_provider.dart'; import 'providers/simple_sync_provider.dart'; @@ -46,7 +46,7 @@ class MyApp extends StatelessWidget { return MultiProvider( providers: [ ChangeNotifierProvider( - create: (context) => SupplementProvider()..initialize(), + create: (context) => SupplementProvider()..initialize(context), ), ChangeNotifierProvider.value( value: settingsProvider, diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 1a4f6ad..2ffdee5 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -23,6 +23,11 @@ class SettingsProvider extends ChangeNotifier { // Notifications int _snoozeMinutes = 10; + // Notification retry settings + bool _notificationRetryEnabled = true; + int _notificationRetryCount = 3; + int _notificationRetryDelayMinutes = 5; + // Auto-sync settings bool _autoSyncEnabled = false; int _autoSyncDebounceSeconds = 5; @@ -42,6 +47,11 @@ class SettingsProvider extends ChangeNotifier { // Notifications int get snoozeMinutes => _snoozeMinutes; + // Notification retry getters + bool get notificationRetryEnabled => _notificationRetryEnabled; + int get notificationRetryCount => _notificationRetryCount; + int get notificationRetryDelayMinutes => _notificationRetryDelayMinutes; + // Auto-sync getters bool get autoSyncEnabled => _autoSyncEnabled; int get autoSyncDebounceSeconds => _autoSyncDebounceSeconds; @@ -85,6 +95,11 @@ class SettingsProvider extends ChangeNotifier { // Load snooze setting _snoozeMinutes = prefs.getInt('snooze_minutes') ?? 10; + // Load notification retry settings + _notificationRetryEnabled = prefs.getBool('notification_retry_enabled') ?? true; + _notificationRetryCount = prefs.getInt('notification_retry_count') ?? 3; + _notificationRetryDelayMinutes = prefs.getInt('notification_retry_delay_minutes') ?? 5; + // Load auto-sync settings _autoSyncEnabled = prefs.getBool('auto_sync_enabled') ?? false; _autoSyncDebounceSeconds = prefs.getInt('auto_sync_debounce_seconds') ?? 30; @@ -259,6 +274,37 @@ class SettingsProvider extends ChangeNotifier { await prefs.setInt('snooze_minutes', minutes); } + Future setNotificationRetryEnabled(bool enabled) async { + _notificationRetryEnabled = enabled; + notifyListeners(); + + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('notification_retry_enabled', enabled); + } + + Future setNotificationRetryCount(int count) async { + if (count < 0 || count > 10) { + throw ArgumentError('Retry count must be between 0 and 10'); + } + _notificationRetryCount = count; + notifyListeners(); + + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt('notification_retry_count', count); + } + + Future setNotificationRetryDelayMinutes(int minutes) async { + const allowed = [1, 2, 3, 5, 10, 15, 20, 30]; + if (!allowed.contains(minutes)) { + throw ArgumentError('Retry delay must be one of ${allowed.join(", ")} minutes'); + } + _notificationRetryDelayMinutes = minutes; + notifyListeners(); + + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt('notification_retry_delay_minutes', minutes); + } + // Auto-sync setters Future setAutoSyncEnabled(bool enabled) async { _autoSyncEnabled = enabled; diff --git a/lib/providers/supplement_provider.dart b/lib/providers/supplement_provider.dart index 902060b..443778b 100644 --- a/lib/providers/supplement_provider.dart +++ b/lib/providers/supplement_provider.dart @@ -3,10 +3,12 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:provider/provider.dart'; import 'package:supplements/logging.dart'; import '../models/supplement.dart'; import '../models/supplement_intake.dart'; +import '../providers/settings_provider.dart'; import '../services/database_helper.dart'; import '../services/database_sync_service.dart'; import '../services/simple_notification_service.dart'; @@ -27,6 +29,9 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver { // Callback for triggering sync when data changes VoidCallback? _onDataChanged; + // Context for accessing other providers + BuildContext? _context; + List get supplements => _supplements; List> get todayIntakes => _todayIntakes; List> get monthlyIntakes => _monthlyIntakes; @@ -42,11 +47,12 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver { _onDataChanged?.call(); } - Future initialize() async { + Future initialize([BuildContext? context]) async { if (_initialized) { return; } _initialized = true; + _context = context; // Add this provider as an observer for app lifecycle changes WidgetsBinding.instance.addObserver(this); @@ -135,7 +141,21 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver { } try { - await _notificationService.scheduleDailyGroupedRemindersSafe(_supplements); + SettingsProvider? settingsProvider; + if (_context != null && _context!.mounted) { + try { + settingsProvider = Provider.of(_context!, listen: false); + } catch (e) { + if (kDebugMode) { + printLog('📱 Could not access SettingsProvider: $e'); + } + } + } + + await _notificationService.scheduleDailyGroupedRemindersSafe( + _supplements, + settingsProvider: settingsProvider, + ); await _notificationService.getPendingNotifications(); } catch (e) { if (kDebugMode) { diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 77accc4..c4a3920 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -106,23 +106,96 @@ class SettingsScreen extends StatelessWidget { const SizedBox(height: 16), // Notifications Card( - child: ListTile( - leading: const Icon(Icons.snooze), - title: const Text('Snooze duration'), - subtitle: const Text('Delay for Snooze action'), - trailing: DropdownButton( - value: settingsProvider.snoozeMinutes, - items: const [ - DropdownMenuItem(value: 5, child: Text('5 min')), - DropdownMenuItem(value: 10, child: Text('10 min')), - DropdownMenuItem(value: 15, child: Text('15 min')), - DropdownMenuItem(value: 20, child: Text('20 min')), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Notifications', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 16), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.snooze), + title: const Text('Snooze duration'), + subtitle: const Text('Delay for Snooze action'), + trailing: DropdownButton( + value: settingsProvider.snoozeMinutes, + items: const [ + DropdownMenuItem(value: 5, child: Text('5 min')), + DropdownMenuItem(value: 10, child: Text('10 min')), + DropdownMenuItem(value: 15, child: Text('15 min')), + DropdownMenuItem(value: 20, child: Text('20 min')), + ], + onChanged: (value) { + if (value != null) { + settingsProvider.setSnoozeMinutes(value); + } + }, + ), + ), + const Divider(), + SwitchListTile( + contentPadding: EdgeInsets.zero, + secondary: const Icon(Icons.repeat), + title: const Text('Notification Retries'), + subtitle: const Text('Automatically retry missed notifications'), + value: settingsProvider.notificationRetryEnabled, + onChanged: (value) { + settingsProvider.setNotificationRetryEnabled(value); + }, + ), + if (settingsProvider.notificationRetryEnabled) ...[ + const SizedBox(height: 8), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.format_list_numbered), + title: const Text('Retry count'), + subtitle: const Text('Number of retry attempts'), + trailing: DropdownButton( + value: settingsProvider.notificationRetryCount, + items: const [ + DropdownMenuItem(value: 1, child: Text('1')), + DropdownMenuItem(value: 2, child: Text('2')), + DropdownMenuItem(value: 3, child: Text('3')), + DropdownMenuItem(value: 4, child: Text('4')), + DropdownMenuItem(value: 5, child: Text('5')), + ], + onChanged: (value) { + if (value != null) { + settingsProvider.setNotificationRetryCount(value); + } + }, + ), + ), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.schedule), + title: const Text('Retry delay'), + subtitle: const Text('Time between retry attempts'), + trailing: DropdownButton( + value: settingsProvider.notificationRetryDelayMinutes, + items: const [ + DropdownMenuItem(value: 1, child: Text('1 min')), + DropdownMenuItem(value: 2, child: Text('2 min')), + DropdownMenuItem(value: 3, child: Text('3 min')), + DropdownMenuItem(value: 5, child: Text('5 min')), + DropdownMenuItem(value: 10, child: Text('10 min')), + DropdownMenuItem(value: 15, child: Text('15 min')), + DropdownMenuItem(value: 20, child: Text('20 min')), + DropdownMenuItem(value: 30, child: Text('30 min')), + ], + onChanged: (value) { + if (value != null) { + settingsProvider.setNotificationRetryDelayMinutes(value); + } + }, + ), + ), + ], ], - onChanged: (value) { - if (value != null) { - settingsProvider.setSnoozeMinutes(value); - } - }, ), ), ), diff --git a/lib/services/notification_router.dart b/lib/services/notification_router.dart index bfa6a80..3515003 100644 --- a/lib/services/notification_router.dart +++ b/lib/services/notification_router.dart @@ -4,14 +4,14 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:supplements/logging.dart'; +import 'package:supplements/services/simple_notification_service.dart'; import '../models/supplement.dart'; import '../providers/supplement_provider.dart'; import '../widgets/dialogs/bulk_take_dialog.dart'; import '../widgets/dialogs/take_supplement_dialog.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -import 'package:supplements/services/simple_notification_service.dart'; /// Centralizes routing from notification actions/taps to in-app UI. /// Handles both foreground/background taps and terminated-launch scenarios. @@ -32,6 +32,9 @@ class NotificationRouter { printLog('🔔 handleNotificationResponse: Received actionId: $actionId'); printLog('🔔 handleNotificationResponse: Decoded payloadMap: $payloadMap'); + // Cancel retry notifications for any interaction (take, snooze, or tap) + await _cancelRetryNotificationsForResponse(payloadMap); + // Handle Snooze actions without surfacing UI if (actionId == 'snooze_single' || actionId == 'snooze_group') { try { @@ -162,13 +165,14 @@ class NotificationRouter { } // Try to wait for providers to be ready to build rich content. - BuildContext? ctx = _navigatorKey?.currentContext; final ready = await _waitUntilReady(timeout: const Duration(seconds: 5)); - if (ctx != null && !ctx.mounted) ctx = null; SupplementProvider? provider; - if (ready && ctx != null) { - provider = Provider.of(ctx, listen: false); + if (ready) { + final ctx = _navigatorKey?.currentContext; + if (ctx != null && ctx.mounted) { + provider = Provider.of(ctx, listen: false); + } } String title = 'Supplement reminder'; @@ -295,4 +299,44 @@ class NotificationRouter { ); }); } + + /// Cancel retry notifications when user responds to any notification. + /// This prevents redundant notifications after user interaction. + Future _cancelRetryNotificationsForResponse(Map? payloadMap) async { + if (payloadMap == null) return; + + try { + final type = payloadMap['type']; + + if (type == 'single') { + // For single notifications, we need to find the time slot to cancel retries + // We can extract this from the meta if it's a retry, or find it from the supplement + final meta = payloadMap['meta'] as Map?; + if (meta != null && meta['originalTime'] != null) { + // This is a retry notification, cancel remaining retries for the original time + final originalTime = meta['originalTime'] as String; + await SimpleNotificationService.instance.cancelRetryNotificationsForTimeSlot(originalTime); + printLog('🚫 Cancelled retries for original time slot: $originalTime'); + } else { + // This is an original notification, find the time slot from the supplement + final supplementId = payloadMap['id']; + if (supplementId is int) { + // We need to find which time slots this supplement has and cancel retries for all of them + // For now, we'll use the broader cancellation method + await SimpleNotificationService.instance.cancelRetryNotificationsForSupplement(supplementId); + printLog('🚫 Cancelled retries for supplement ID: $supplementId'); + } + } + } else if (type == 'group') { + // For group notifications, we have the time key directly + final timeKey = payloadMap['time'] as String?; + if (timeKey != null) { + await SimpleNotificationService.instance.cancelRetryNotificationsForTimeSlot(timeKey); + printLog('🚫 Cancelled retries for time slot: $timeKey'); + } + } + } catch (e) { + printLog('⚠️ Failed to cancel retry notifications: $e'); + } + } } diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart deleted file mode 100644 index c41fbc2..0000000 --- a/lib/services/notification_service.dart +++ /dev/null @@ -1,11 +0,0 @@ -/* - Deprecated/removed: notification_service.dart - - This legacy notification service has been intentionally removed. - The app now uses a minimal scheduler in: - services/simple_notification_service.dart - - All retry/snooze/database-tracking logic has been dropped to keep things simple. - This file is left empty to ensure any lingering references fail at compile time, - prompting migration to the new SimpleNotificationService. -*/ diff --git a/lib/services/simple_notification_service.dart b/lib/services/simple_notification_service.dart index a5b89a6..a13d518 100644 --- a/lib/services/simple_notification_service.dart +++ b/lib/services/simple_notification_service.dart @@ -1,12 +1,14 @@ +import 'dart:convert'; + import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:supplements/logging.dart'; +import 'package:supplements/services/notification_debug_store.dart'; +import 'package:supplements/services/notification_router.dart'; import 'package:timezone/data/latest.dart' as tzdata; import 'package:timezone/timezone.dart' as tz; -import 'dart:convert'; -import 'package:supplements/services/notification_router.dart'; -import 'package:supplements/services/notification_debug_store.dart'; import '../models/supplement.dart'; +import '../providers/settings_provider.dart'; /// A minimal notification scheduler focused purely on: /// - Initialization @@ -131,8 +133,12 @@ class SimpleNotificationService { /// /// IDs: /// - Group ID per time slot: 40000 + hour*60 + minute. + /// - Retry IDs: 50000 + (hour*60 + minute)*100 + retryIndex. /// - Stable and predictable for cancel/update operations. - Future scheduleDailyGroupedReminders(List supplements) async { + Future scheduleDailyGroupedReminders( + List supplements, { + SettingsProvider? settingsProvider, + }) async { if (!_initialized) { await initialize(); } @@ -281,6 +287,20 @@ class SimpleNotificationService { ); printLog('✅ Scheduled group $timeKey with ID $id'); + + // Schedule retry notifications if enabled + if (settingsProvider != null && settingsProvider.notificationRetryEnabled) { + await _scheduleRetryNotifications( + timeKey: timeKey, + supplements: items, + isSingle: isSingle, + title: title, + body: body, + payloadStr: payloadStr, + retryCount: settingsProvider.notificationRetryCount, + retryDelayMinutes: settingsProvider.notificationRetryDelayMinutes, + ); + } } // Log what the system reports as pending @@ -297,13 +317,16 @@ class SimpleNotificationService { /// Convenience to schedule grouped reminders for today and tomorrow. /// - /// For iOS’s 64 limit, we stick to one day for recurring (matchDateTimeComponents) + /// For iOS's 64 limit, we stick to one day for recurring (matchDateTimeComponents) /// which already repeats every day without needing to schedule future dates. - /// If you want an extra safety net, you could schedule tomorrow’s one-offs, + /// If you want an extra safety net, you could schedule tomorrow's one-offs, /// but with daily components this is unnecessary and risks hitting iOS limits. - Future scheduleDailyGroupedRemindersSafe(List supplements) async { - // For now, just schedule today’s recurring groups. - await scheduleDailyGroupedReminders(supplements); + Future scheduleDailyGroupedRemindersSafe( + List supplements, { + SettingsProvider? settingsProvider, + }) async { + // For now, just schedule today's recurring groups. + await scheduleDailyGroupedReminders(supplements, settingsProvider: settingsProvider); } /// Cancel all scheduled reminders for a given [supplementId]. @@ -565,4 +588,176 @@ class SimpleNotificationService { } return list; } + + /// Schedule retry notifications for a specific time group. + Future _scheduleRetryNotifications({ + required String timeKey, + required List supplements, + required bool isSingle, + required String title, + required String body, + required String payloadStr, + required int retryCount, + required int retryDelayMinutes, + }) async { + if (retryCount <= 0) return; + + final parts = timeKey.split(':'); + final hour = int.parse(parts[0]); + final minute = int.parse(parts[1]); + final baseTime = _nextInstanceOfTime(hour, minute); + + for (int retryIndex = 1; retryIndex <= retryCount; retryIndex++) { + final retryDelay = Duration(minutes: retryDelayMinutes * retryIndex); + final retryTime = baseTime.add(retryDelay); + final retryId = 50000 + ((hour * 60) + minute) * 100 + retryIndex; + + // Parse and modify payload to mark as retry + Map retryPayload; + try { + retryPayload = Map.from(jsonDecode(payloadStr)); + retryPayload['meta'] = { + 'kind': 'retry', + 'originalTime': timeKey, + 'retryIndex': retryIndex, + 'retryOf': 40000 + (hour * 60) + minute, + }; + } catch (e) { + retryPayload = { + 'type': isSingle ? 'single' : 'group', + if (isSingle) 'id': supplements.first.id else 'time': timeKey, + 'meta': { + 'kind': 'retry', + 'originalTime': timeKey, + 'retryIndex': retryIndex, + 'retryOf': 40000 + (hour * 60) + minute, + }, + }; + } + + final retryPayloadStr = jsonEncode(retryPayload); + final retryTitle = 'Reminder: $title'; + + final androidDetails = AndroidNotificationDetails( + _channelDailyId, + _channelDailyName, + channelDescription: _channelDailyDescription, + importance: Importance.high, + priority: Priority.high, + styleInformation: BigTextStyleInformation( + body, + contentTitle: retryTitle, + htmlFormatContentTitle: false, + ), + actions: [ + if (isSingle) + AndroidNotificationAction( + 'take_single', + 'Take', + showsUserInterface: true, + cancelNotification: true, + ) + else + AndroidNotificationAction( + 'take_group', + 'Take All', + showsUserInterface: true, + cancelNotification: true, + ), + if (isSingle) + AndroidNotificationAction( + 'snooze_single', + 'Snooze', + showsUserInterface: false, + ) + else + AndroidNotificationAction( + 'snooze_group', + 'Snooze', + showsUserInterface: false, + ), + ], + ); + + final iosDetails = DarwinNotificationDetails( + categoryIdentifier: isSingle ? 'single' : 'group', + ); + + await _plugin.zonedSchedule( + retryId, + retryTitle, + isSingle ? body : 'Tap to see details', + retryTime, + NotificationDetails( + android: androidDetails, + iOS: iosDetails, + ), + androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle, + payload: retryPayloadStr, + ); + + // Log to debug store + final createdAtMs = DateTime.now().millisecondsSinceEpoch; + await NotificationDebugStore.instance.add( + NotificationLogEntry( + id: retryId, + kind: 'retry', + type: isSingle ? 'single' : 'group', + whenEpochMs: retryTime.millisecondsSinceEpoch, + createdAtEpochMs: createdAtMs, + title: retryTitle, + payload: retryPayloadStr, + singleId: isSingle ? supplements.first.id : null, + timeKey: isSingle ? null : timeKey, + ), + ); + + printLog('🔄 Scheduled retry $retryIndex/$retryCount for $timeKey (ID: $retryId) at $retryTime'); + } + } + + /// Cancel retry notifications for a specific time slot. + /// This should be called when a user responds to a notification. + Future cancelRetryNotificationsForTimeSlot(String timeKey) async { + if (!_initialized) { + await initialize(); + } + + final parts = timeKey.split(':'); + if (parts.length != 2) return; + + final hour = int.tryParse(parts[0]); + final minute = int.tryParse(parts[1]); + if (hour == null || minute == null) return; + + // Calculate base retry ID range for this time slot + final baseRetryId = 50000 + ((hour * 60) + minute) * 100; + + // Cancel up to 10 possible retries (generous upper bound) + for (int retryIndex = 1; retryIndex <= 10; retryIndex++) { + final retryId = baseRetryId + retryIndex; + await _plugin.cancel(retryId); + printLog('🚫 Cancelled retry notification ID: $retryId for time slot $timeKey'); + } + } + + /// Cancel retry notifications for a specific supplement ID. + /// This iterates through all possible time slots. + Future cancelRetryNotificationsForSupplement(int supplementId) async { + if (!_initialized) { + await initialize(); + } + + // Cancel retries for all possible time slots (24 hours * 60 minutes) + for (int hour = 0; hour < 24; hour++) { + for (int minute = 0; minute < 60; minute += 5) { // Assume 5-minute intervals + final baseRetryId = 50000 + ((hour * 60) + minute) * 100; + for (int retryIndex = 1; retryIndex <= 10; retryIndex++) { + final retryId = baseRetryId + retryIndex; + await _plugin.cancel(retryId); + } + } + } + printLog('🚫 Cancelled all retry notifications for supplement ID: $supplementId'); + } }