mirror of
https://github.com/vleeuwenmenno/supplements.git
synced 2025-12-08 06:02:34 +00:00
569 lines
18 KiB
Dart
569 lines
18 KiB
Dart
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 'package:supplements/services/notification_debug_store.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<void> 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<bool> 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<void> scheduleDailyGroupedReminders(List<Supplement> 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<Supplement>
|
||
final Map<String, List<Supplement>> 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<String, dynamic> 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');
|
||
}
|
||
|
||
// 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<void> scheduleDailyGroupedRemindersSafe(List<Supplement> 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<void> 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<void> cancelAll() async {
|
||
if (!_initialized) {
|
||
await initialize();
|
||
}
|
||
await _plugin.cancelAll();
|
||
}
|
||
|
||
/// Cancel a specific notification by ID.
|
||
Future<void> cancelById(int id) async {
|
||
if (!_initialized) {
|
||
await initialize();
|
||
}
|
||
await _plugin.cancel(id);
|
||
}
|
||
|
||
/// Show an immediate notification. Useful for quick diagnostics.
|
||
Future<void> 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<void> 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<String, dynamic>? pmap;
|
||
try {
|
||
pmap = jsonDecode(payload) as Map<String, dynamic>;
|
||
} 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<NotificationAppLaunchDetails?> 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<List<PendingNotificationRequest>> 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;
|
||
}
|
||
}
|