import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:timezone/data/latest.dart' as tz_data; import 'package:timezone/timezone.dart' as tz; /// Platform-agnostic contract for scheduling local (offline) reminders. /// /// Presentation code depends only on this interface; the concrete /// [PlatformNotificationService] wraps `flutter_local_notifications` so the /// package never leaks into feature layers (architecture rule 1/2). abstract class INotificationService { /// Prepares the underlying plugin. Safe to call repeatedly. Future initialize(); /// Requests OS-level notification permission. /// /// Returns `true` when alerts may be shown, `false` when the platform is /// unsupported or the user declined. Future requestPermissions(); /// Schedules a one-shot reminder at [scheduledAt] and returns whether the /// platform accepted it. Times in the past are rejected. Future scheduleDoseReminder({ required DateTime scheduledAt, required String title, required String body, int id = kDoseReminderNotificationId, String? payload, }); /// Cancels the reminder registered under [id]. Future cancelReminder(int id); /// Cancels every pending reminder. Future cancelAllReminders(); } /// Stable notification id for the recurring dose reminder slot; re-using one /// id replaces the previous scheduling instead of stacking duplicates. const int kDoseReminderNotificationId = 7401; /// `flutter_local_notifications`-backed implementation. /// /// Support matrix (graceful degradation per architecture rule 3): /// * **Android / iOS / macOS:** full one-shot scheduling via `zonedSchedule`. /// * **Windows / Linux / Web:** scheduling is a logged no-op - the plugin has /// no scheduler on those targets yet, and the domain keeps functioning /// (in-app schedule view) without notifications. class PlatformNotificationService implements INotificationService { PlatformNotificationService({FlutterLocalNotificationsPlugin? plugin}) : _plugin = plugin ?? FlutterLocalNotificationsPlugin(); static const String _channelId = 'medication_reminders'; static const String _channelName = 'Medication Reminders'; static const String _channelDescription = 'Dose reminders for GLP-1 injections and site rotation.'; static const NotificationDetails _notificationDetails = NotificationDetails( android: AndroidNotificationDetails( _channelId, _channelName, channelDescription: _channelDescription, importance: Importance.max, priority: Priority.high, ), iOS: DarwinNotificationDetails(), macOS: DarwinNotificationDetails(), ); final FlutterLocalNotificationsPlugin _plugin; Future? _initialization; /// Targets where the plugin can schedule future notifications. bool get _supportsScheduling => !kIsWeb && (Platform.isAndroid || Platform.isIOS || Platform.isMacOS); @override Future initialize() => _initialization ??= _initialize(); Future _initialize() async { if (!_supportsScheduling) return; // Time zones are initialized once; scheduling uses absolute UTC instants, // which the OS converts to the device's local time zone for display. tz_data.initializeTimeZones(); // Permissions are requested explicitly via [requestPermissions] instead // of implicitly during initialize(). const settings = InitializationSettings( android: AndroidInitializationSettings('@mipmap/ic_launcher'), iOS: DarwinInitializationSettings( requestAlertPermission: false, requestBadgePermission: false, requestSoundPermission: false, ), macOS: DarwinInitializationSettings( requestAlertPermission: false, requestBadgePermission: false, requestSoundPermission: false, ), ); try { await _plugin.initialize( settings, onDidReceiveNotificationResponse: (response) { debugPrint('Notification tapped: ${response.payload}'); }, ); } catch (error) { debugPrint('Notification initialization failed: $error'); } } @override Future requestPermissions() async { if (!_supportsScheduling) return false; try { await initialize(); if (Platform.isAndroid) { return await _plugin .resolvePlatformSpecificImplementation< AndroidFlutterLocalNotificationsPlugin>() ?.requestNotificationsPermission() ?? false; } // iOS and macOS share the Darwin permission surface. return await _plugin .resolvePlatformSpecificImplementation< IOSFlutterLocalNotificationsPlugin>() ?.requestPermissions(alert: true, badge: true, sound: true) ?? false; } catch (error) { debugPrint('Notification permission request failed: $error'); return false; } } @override Future scheduleDoseReminder({ required DateTime scheduledAt, required String title, required String body, int id = kDoseReminderNotificationId, String? payload, }) async { if (!_supportsScheduling) return false; if (!scheduledAt.isAfter(DateTime.now())) return false; try { await initialize(); await _plugin.zonedSchedule( id, title, body, // Absolute instants; the tray renders them in device-local time. tz.TZDateTime.from(scheduledAt.toUtc(), tz.UTC), _notificationDetails, uiLocalNotificationDateInterpretation: UILocalNotificationDateInterpretation.absoluteTime, androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle, payload: payload, ); return true; } catch (error) { debugPrint('Dose reminder scheduling failed: $error'); return false; } } @override Future cancelReminder(int id) async { if (!_supportsScheduling) return; try { await initialize(); await _plugin.cancel(id); } catch (error) { debugPrint('Notification cancel failed: $error'); } } @override Future cancelAllReminders() async { if (!_supportsScheduling) return; try { await initialize(); await _plugin.cancelAll(); } catch (error) { debugPrint('Notification cancel-all failed: $error'); } } }