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 '../models/supplement.dart'; import '../providers/settings_provider.dart'; /// A minimal notification scheduler focused purely on: /// - Initialization /// - Permission requests /// - Scheduling daily notifications for supplements /// - Canceling scheduled notifications /// /// No retries, no snooze, no database logic. class SimpleNotificationService { SimpleNotificationService._internal(); static final SimpleNotificationService instance = SimpleNotificationService._internal(); final FlutterLocalNotificationsPlugin _plugin = FlutterLocalNotificationsPlugin(); bool _initialized = false; // Channel IDs static const String _channelDailyId = 'supplement_reminders'; static const String _channelDailyName = 'Supplement Reminders'; static const String _channelDailyDescription = 'Daily supplement intake reminders'; /// Initialize timezone data and the notifications plugin. /// /// Note: This does not request runtime permissions. Call [requestPermissions] /// to prompt the user for notification permissions. Future initialize({ DidReceiveBackgroundNotificationResponseCallback? onDidReceiveBackgroundNotificationResponse, }) async { if (_initialized) return; // Initialize timezone database and set a sane default. // If you prefer, replace 'Europe/Amsterdam' with your preferred default, // or integrate a platform timezone resolver. tzdata.initializeTimeZones(); tz.setLocalLocation(tz.getLocation('Europe/Amsterdam')); const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher'); final iosSettings = DarwinInitializationSettings( requestAlertPermission: false, requestBadgePermission: false, requestSoundPermission: false, notificationCategories: [ DarwinNotificationCategory( 'single', actions: [ DarwinNotificationAction.plain('take_single', 'Take'), DarwinNotificationAction.plain('snooze_single', 'Snooze'), ], ), DarwinNotificationCategory( 'group', actions: [ DarwinNotificationAction.plain('take_group', 'Take All'), DarwinNotificationAction.plain('snooze_group', 'Snooze'), ], ), ], ); const linuxSettings = LinuxInitializationSettings( defaultActionName: 'Open notification', ); final initSettings = InitializationSettings( android: androidSettings, iOS: iosSettings, linux: linuxSettings, ); await _plugin.initialize( initSettings, onDidReceiveNotificationResponse: (response) { NotificationRouter.instance.handleNotificationResponse(response); }, onDidReceiveBackgroundNotificationResponse: onDidReceiveBackgroundNotificationResponse, ); _initialized = true; } /// Request runtime notification permissions. /// /// On Android 13+, this will prompt for POST_NOTIFICATIONS. On older Android, /// this is a no-op. On iOS, it requests alert/badge/sound. Future requestPermissions() async { // Ensure the plugin is ready before requesting permissions. if (!_initialized) { await initialize(); } bool granted = true; final androidPlugin = _plugin .resolvePlatformSpecificImplementation< AndroidFlutterLocalNotificationsPlugin>(); if (androidPlugin != null) { final ok = await androidPlugin.requestNotificationsPermission(); granted = granted && (ok == true); } final iosPlugin = _plugin .resolvePlatformSpecificImplementation< IOSFlutterLocalNotificationsPlugin>(); if (iosPlugin != null) { final ok = await iosPlugin.requestPermissions( alert: true, badge: true, sound: true, ); granted = granted && (ok == true); } return granted; } /// Schedule grouped daily reminders for a list of supplements. /// /// - Groups supplements by HH:mm and schedules one notification per time slot. /// - Uses daily recurrence via matchDateTimeComponents: DateTimeComponents.time. /// - Keeps iOS pending notifications well below the 64 limit. /// /// 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, { SettingsProvider? settingsProvider, }) async { if (!_initialized) { await initialize(); } printLog('๐Ÿ›  scheduleDailyGroupedReminders -> ${supplements.length} supplements'); // Clear everything first to avoid duplicates or stale schedules await cancelAll(); printLog('๐Ÿงน Cleared all existing notifications before scheduling groups'); // Build groups: HH:mm -> list final Map> groups = {}; for (final s in supplements.where((s) => s.isActive && s.reminderTimes.isNotEmpty && s.id != null)) { for (final timeStr in s.reminderTimes) { final parts = timeStr.split(':'); if (parts.length != 2) continue; final hour = int.tryParse(parts[0]); final minute = int.tryParse(parts[1]); if (hour == null || minute == null) continue; final key = '${hour.toString().padLeft(2, '0')}:${minute.toString().padLeft(2, '0')}'; groups.putIfAbsent(key, () => []).add(s); } } printLog('โฑ Found ${groups.length} time group(s): ${groups.keys.toList()}'); if (groups.isEmpty) { printLog('โš ๏ธ No groups to schedule (no active supplements with reminder times)'); return; } // Schedule one notification per time group for (final entry in groups.entries) { final timeKey = entry.key; // HH:mm final items = entry.value; final parts = timeKey.split(':'); final hour = int.parse(parts[0]); final minute = int.parse(parts[1]); final when = _nextInstanceOfTime(hour, minute); final id = 40000 + (hour * 60) + minute; final count = items.length; final title = count == 1 ? 'Time for ${items.first.name}' : 'Time for $count supplements'; // Build body that lists each supplement concisely final bodyLines = items.map((s) { final units = s.numberOfUnits; final unitType = s.unitType; final perUnit = s.ingredientsPerUnit; return '${s.name} โ€” $units $unitType ($perUnit)'; }).toList(); final body = bodyLines.join('\n'); printLog('๐Ÿ“… Scheduling group $timeKey (count=$count) id=$id'); printLog('๐Ÿ•’ Now=${tz.TZDateTime.now(tz.local)} | When=$when'); // Use BigTextStyle/InboxStyle for Android to show multiple lines final bool isSingle = count == 1; // Tag payload with origin meta for debug/inspection final Map payloadMap = isSingle ? {"type": "single", "id": items.first.id} : {"type": "group", "time": timeKey}; payloadMap["meta"] = {"kind": "daily"}; final String payloadStr = jsonEncode(payloadMap); final androidDetails = AndroidNotificationDetails( _channelDailyId, _channelDailyName, channelDescription: _channelDailyDescription, importance: Importance.high, priority: Priority.high, styleInformation: BigTextStyleInformation( body, contentTitle: title, 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, // Removed cancelNotification: true for debugging ) else AndroidNotificationAction( 'snooze_group', 'Snooze', showsUserInterface: false, // Removed cancelNotification: true for debugging ), ], ); final iosDetails = DarwinNotificationDetails( categoryIdentifier: isSingle ? 'single' : 'group', ); await _plugin.zonedSchedule( id, title, isSingle ? body : 'Tap to see details', when, NotificationDetails( android: androidDetails, iOS: iosDetails, ), androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle, matchDateTimeComponents: DateTimeComponents.time, payload: payloadStr, ); // Log to debug store final createdAtMs = DateTime.now().millisecondsSinceEpoch; await NotificationDebugStore.instance.add( NotificationLogEntry( id: id, kind: 'daily', type: isSingle ? 'single' : 'group', whenEpochMs: when.millisecondsSinceEpoch, createdAtEpochMs: createdAtMs, title: title, payload: payloadStr, singleId: isSingle ? items.first.id : null, timeKey: isSingle ? null : timeKey, ), ); 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 try { final pending = await _plugin.pendingNotificationRequests(); printLog('๐Ÿ“‹ Pending notifications after scheduling: ${pending.length}'); for (final p in pending) { printLog(' - ID=${p.id}, Title=${p.title}, BodyLen=${p.body?.length ?? 0}'); } } catch (e) { printLog('โš ๏ธ Could not fetch pending notifications: $e'); } } /// Convenience to schedule grouped reminders for today and tomorrow. /// /// 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, /// but with daily components this is unnecessary and risks hitting iOS limits. 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]. /// /// We assume up to 100 slots per supplement (00-99). This is simple and safe. Future cancelSupplementReminders(int supplementId) async { if (!_initialized) { await initialize(); } for (int i = 0; i < 100; i++) { await _plugin.cancel(supplementId * 100 + i); } } /// Cancel all scheduled notifications. Future cancelAll() async { if (!_initialized) { await initialize(); } await _plugin.cancelAll(); } /// Cancel a specific notification by ID. Future cancelById(int id) async { if (!_initialized) { await initialize(); } await _plugin.cancel(id); } /// Show an immediate notification. Useful for quick diagnostics. Future showInstant({ required String title, required String body, String? payload, bool includeSnoozeActions = false, // New parameter bool isSingle = true, // New parameter, defaults to single for instant }) async { if (!_initialized) { await initialize(); } final androidDetails = AndroidNotificationDetails( 'instant_notifications', 'Instant Notifications', channelDescription: 'One-off or immediate notifications', importance: Importance.high, priority: Priority.high, actions: includeSnoozeActions ? [ 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, cancelNotification: true, ) else AndroidNotificationAction( 'snooze_group', 'Snooze', showsUserInterface: false, cancelNotification: true, ), ] : [], // No actions by default ); final iosDetails = DarwinNotificationDetails( categoryIdentifier: includeSnoozeActions ? (isSingle ? 'single' : 'group') : null, // Use category for actions ); await _plugin.show( DateTime.now().millisecondsSinceEpoch ~/ 1000, title, body, NotificationDetails( android: androidDetails, iOS: iosDetails, ), payload: payload, ); } /// Schedule a one-off (non-repeating) reminder, typically used for Snooze. Future scheduleOneOffReminder({ required String title, required String body, required String payload, required bool isSingle, required Duration delay, }) async { if (!_initialized) { await initialize(); } final when = tz.TZDateTime.now(tz.local).add(delay); final id = DateTime.now().millisecondsSinceEpoch ~/ 1000; final androidDetails = AndroidNotificationDetails( _channelDailyId, _channelDailyName, channelDescription: _channelDailyDescription, importance: Importance.high, priority: Priority.high, styleInformation: BigTextStyleInformation( body, contentTitle: title, 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, // Removed cancelNotification: true for debugging ) else AndroidNotificationAction( 'snooze_group', 'Snooze', showsUserInterface: false, // Removed cancelNotification: true for debugging ), ], ); final iosDetails = DarwinNotificationDetails( categoryIdentifier: isSingle ? 'single' : 'group', ); // Enrich payload with meta for snooze; also capture linkage for logging Map? pmap; try { pmap = jsonDecode(payload) as Map; } catch (_) { pmap = null; } final createdAtMs = DateTime.now().millisecondsSinceEpoch; String payloadFinal = payload; int? logSingleId; String? logTimeKey; if (pmap != null) { final meta = { 'kind': 'snooze', 'createdAt': createdAtMs, 'delayMin': delay.inMinutes, }; pmap['meta'] = meta; if (pmap['type'] == 'single') { final v = pmap['id']; logSingleId = v is int ? v : null; } else if (pmap['type'] == 'group') { logTimeKey = pmap['time'] as String?; } payloadFinal = jsonEncode(pmap); } await _plugin.zonedSchedule( id, title, isSingle ? body : 'Tap to see details', when, NotificationDetails( android: androidDetails, iOS: iosDetails, ), androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle, payload: payloadFinal, ); // Log to debug store await NotificationDebugStore.instance.add( NotificationLogEntry( id: id, kind: 'snooze', type: isSingle ? 'single' : 'group', whenEpochMs: when.millisecondsSinceEpoch, createdAtEpochMs: createdAtMs, title: title, payload: payloadFinal, singleId: logSingleId, timeKey: logTimeKey, ), ); printLog('โฐ Scheduled one-off reminder (id=$id) at $when, isSingle=$isSingle'); } Future getLaunchDetails() async { if (!_initialized) { await initialize(); } try { final details = await _plugin.getNotificationAppLaunchDetails(); return details; } catch (e) { printLog('โš ๏ธ getLaunchDetails error: $e'); return null; } } /// Helper to compute the next instance of [hour]:[minute] in the local tz. tz.TZDateTime _nextInstanceOfTime(int hour, int minute) { final now = tz.TZDateTime.now(tz.local); var scheduled = tz.TZDateTime(tz.local, now.year, now.month, now.day, hour, minute); if (scheduled.isBefore(now)) { scheduled = scheduled.add(const Duration(days: 1)); printLog('โญ Scheduling for tomorrow at ${scheduled.toString()} (${scheduled.timeZoneName})'); } else { printLog('โฒ Scheduling for today at ${scheduled.toString()} (${scheduled.timeZoneName})'); } return scheduled; } /// Debug helper to fetch and log all pending notifications. Future> getPendingNotifications() async { if (!_initialized) { await initialize(); } final list = await _plugin.pendingNotificationRequests(); printLog('๐Ÿงพ getPendingNotifications -> ${list.length} pending'); for (final p in list) { printLog(' โ€ข ID=${p.id}, Title=${p.title}, Payload=${p.payload}'); } 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'); } }