195 lines
6.4 KiB
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');
|
|
}
|
|
}
|
|
} |