import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:supplements/logging.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 '../models/supplement.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() 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'), ], ), DarwinNotificationCategory( 'group', actions: [ DarwinNotificationAction.plain('take_group', 'Take All'), ], ), ], ); 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); }, ); _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. /// - Stable and predictable for cancel/update operations. Future scheduleDailyGroupedReminders(List supplements) 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; final String payloadStr = isSingle ? jsonEncode({"type": "single", "id": items.first.id}) : jsonEncode({"type": "group", "time": timeKey}); 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, ), ], ); 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, ); printLog('✅ Scheduled group $timeKey with ID $id'); } // 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) async { // For now, just schedule today’s recurring groups. await scheduleDailyGroupedReminders(supplements); } /// 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(); } /// Show an immediate notification. Useful for quick diagnostics. Future showInstant({ required String title, required String body, String? payload, }) async { if (!_initialized) { await initialize(); } await _plugin.show( DateTime.now().millisecondsSinceEpoch ~/ 1000, title, body, const NotificationDetails( android: AndroidNotificationDetails( 'instant_notifications', 'Instant Notifications', channelDescription: 'One-off or immediate notifications', importance: Importance.high, priority: Priority.high, ), iOS: DarwinNotificationDetails(), ), payload: payload, ); } 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; } }