glp-companion/lib/core/platform/notification_service.dart

195 lines
6.4 KiB
Dart

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<void> initialize();
/// Requests OS-level notification permission.
///
/// Returns `true` when alerts may be shown, `false` when the platform is
/// unsupported or the user declined.
Future<bool> requestPermissions();
/// Schedules a one-shot reminder at [scheduledAt] and returns whether the
/// platform accepted it. Times in the past are rejected.
Future<bool> scheduleDoseReminder({
required DateTime scheduledAt,
required String title,
required String body,
int id = kDoseReminderNotificationId,
String? payload,
});
/// Cancels the reminder registered under [id].
Future<void> cancelReminder(int id);
/// Cancels every pending reminder.
Future<void> 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<void>? _initialization;
/// Targets where the plugin can schedule future notifications.
bool get _supportsScheduling =>
!kIsWeb && (Platform.isAndroid || Platform.isIOS || Platform.isMacOS);
@override
Future<void> initialize() => _initialization ??= _initialize();
Future<void> _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<bool> 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<bool> 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<void> cancelReminder(int id) async {
if (!_supportsScheduling) return;
try {
await initialize();
await _plugin.cancel(id);
} catch (error) {
debugPrint('Notification cancel failed: $error');
}
}
@override
Future<void> cancelAllReminders() async {
if (!_supportsScheduling) return;
try {
await initialize();
await _plugin.cancelAll();
} catch (error) {
debugPrint('Notification cancel-all failed: $error');
}
}
}