mirror of
https://github.com/vleeuwenmenno/supplements.git
synced 2025-09-11 18:29:12 +02:00
764 lines
24 KiB
Dart
764 lines
24 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
|
import 'package:supplements/logging.dart';
|
|
import 'package:supplements/services/notification_debug_store.dart';
|
|
import 'package:supplements/services/notification_router.dart';
|
|
import 'package:timezone/data/latest.dart' as tzdata;
|
|
import 'package:timezone/timezone.dart' as tz;
|
|
|
|
import '../models/supplement.dart';
|
|
import '../providers/settings_provider.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.
|
|
/// - Retry IDs: 50000 + (hour*60 + minute)*100 + retryIndex.
|
|
/// - Stable and predictable for cancel/update operations.
|
|
Future<void> scheduleDailyGroupedReminders(
|
|
List<Supplement> supplements, {
|
|
SettingsProvider? settingsProvider,
|
|
}) 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');
|
|
|
|
// Schedule retry notifications if enabled
|
|
if (settingsProvider != null && settingsProvider.notificationRetryEnabled) {
|
|
await _scheduleRetryNotifications(
|
|
timeKey: timeKey,
|
|
supplements: items,
|
|
isSingle: isSingle,
|
|
title: title,
|
|
body: body,
|
|
payloadStr: payloadStr,
|
|
retryCount: settingsProvider.notificationRetryCount,
|
|
retryDelayMinutes: settingsProvider.notificationRetryDelayMinutes,
|
|
);
|
|
}
|
|
}
|
|
|
|
// 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, {
|
|
SettingsProvider? settingsProvider,
|
|
}) async {
|
|
// For now, just schedule today's recurring groups.
|
|
await scheduleDailyGroupedReminders(supplements, settingsProvider: settingsProvider);
|
|
}
|
|
|
|
/// 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;
|
|
}
|
|
|
|
/// Schedule retry notifications for a specific time group.
|
|
Future<void> _scheduleRetryNotifications({
|
|
required String timeKey,
|
|
required List<Supplement> supplements,
|
|
required bool isSingle,
|
|
required String title,
|
|
required String body,
|
|
required String payloadStr,
|
|
required int retryCount,
|
|
required int retryDelayMinutes,
|
|
}) async {
|
|
if (retryCount <= 0) return;
|
|
|
|
final parts = timeKey.split(':');
|
|
final hour = int.parse(parts[0]);
|
|
final minute = int.parse(parts[1]);
|
|
final baseTime = _nextInstanceOfTime(hour, minute);
|
|
|
|
for (int retryIndex = 1; retryIndex <= retryCount; retryIndex++) {
|
|
final retryDelay = Duration(minutes: retryDelayMinutes * retryIndex);
|
|
final retryTime = baseTime.add(retryDelay);
|
|
final retryId = 50000 + ((hour * 60) + minute) * 100 + retryIndex;
|
|
|
|
// Parse and modify payload to mark as retry
|
|
Map<String, dynamic> retryPayload;
|
|
try {
|
|
retryPayload = Map<String, dynamic>.from(jsonDecode(payloadStr));
|
|
retryPayload['meta'] = {
|
|
'kind': 'retry',
|
|
'originalTime': timeKey,
|
|
'retryIndex': retryIndex,
|
|
'retryOf': 40000 + (hour * 60) + minute,
|
|
};
|
|
} catch (e) {
|
|
retryPayload = {
|
|
'type': isSingle ? 'single' : 'group',
|
|
if (isSingle) 'id': supplements.first.id else 'time': timeKey,
|
|
'meta': {
|
|
'kind': 'retry',
|
|
'originalTime': timeKey,
|
|
'retryIndex': retryIndex,
|
|
'retryOf': 40000 + (hour * 60) + minute,
|
|
},
|
|
};
|
|
}
|
|
|
|
final retryPayloadStr = jsonEncode(retryPayload);
|
|
final retryTitle = 'Reminder: $title';
|
|
|
|
final androidDetails = AndroidNotificationDetails(
|
|
_channelDailyId,
|
|
_channelDailyName,
|
|
channelDescription: _channelDailyDescription,
|
|
importance: Importance.high,
|
|
priority: Priority.high,
|
|
styleInformation: BigTextStyleInformation(
|
|
body,
|
|
contentTitle: retryTitle,
|
|
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,
|
|
)
|
|
else
|
|
AndroidNotificationAction(
|
|
'snooze_group',
|
|
'Snooze',
|
|
showsUserInterface: false,
|
|
),
|
|
],
|
|
);
|
|
|
|
final iosDetails = DarwinNotificationDetails(
|
|
categoryIdentifier: isSingle ? 'single' : 'group',
|
|
);
|
|
|
|
await _plugin.zonedSchedule(
|
|
retryId,
|
|
retryTitle,
|
|
isSingle ? body : 'Tap to see details',
|
|
retryTime,
|
|
NotificationDetails(
|
|
android: androidDetails,
|
|
iOS: iosDetails,
|
|
),
|
|
androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
|
|
payload: retryPayloadStr,
|
|
);
|
|
|
|
// Log to debug store
|
|
final createdAtMs = DateTime.now().millisecondsSinceEpoch;
|
|
await NotificationDebugStore.instance.add(
|
|
NotificationLogEntry(
|
|
id: retryId,
|
|
kind: 'retry',
|
|
type: isSingle ? 'single' : 'group',
|
|
whenEpochMs: retryTime.millisecondsSinceEpoch,
|
|
createdAtEpochMs: createdAtMs,
|
|
title: retryTitle,
|
|
payload: retryPayloadStr,
|
|
singleId: isSingle ? supplements.first.id : null,
|
|
timeKey: isSingle ? null : timeKey,
|
|
),
|
|
);
|
|
|
|
printLog('🔄 Scheduled retry $retryIndex/$retryCount for $timeKey (ID: $retryId) at $retryTime');
|
|
}
|
|
}
|
|
|
|
/// Cancel retry notifications for a specific time slot.
|
|
/// This should be called when a user responds to a notification.
|
|
Future<void> cancelRetryNotificationsForTimeSlot(String timeKey) async {
|
|
if (!_initialized) {
|
|
await initialize();
|
|
}
|
|
|
|
final parts = timeKey.split(':');
|
|
if (parts.length != 2) return;
|
|
|
|
final hour = int.tryParse(parts[0]);
|
|
final minute = int.tryParse(parts[1]);
|
|
if (hour == null || minute == null) return;
|
|
|
|
// Calculate base retry ID range for this time slot
|
|
final baseRetryId = 50000 + ((hour * 60) + minute) * 100;
|
|
|
|
// Cancel up to 10 possible retries (generous upper bound)
|
|
for (int retryIndex = 1; retryIndex <= 10; retryIndex++) {
|
|
final retryId = baseRetryId + retryIndex;
|
|
await _plugin.cancel(retryId);
|
|
printLog('🚫 Cancelled retry notification ID: $retryId for time slot $timeKey');
|
|
}
|
|
}
|
|
|
|
/// Cancel retry notifications for a specific supplement ID.
|
|
/// This iterates through all possible time slots.
|
|
Future<void> cancelRetryNotificationsForSupplement(int supplementId) async {
|
|
if (!_initialized) {
|
|
await initialize();
|
|
}
|
|
|
|
// Cancel retries for all possible time slots (24 hours * 60 minutes)
|
|
for (int hour = 0; hour < 24; hour++) {
|
|
for (int minute = 0; minute < 60; minute += 5) { // Assume 5-minute intervals
|
|
final baseRetryId = 50000 + ((hour * 60) + minute) * 100;
|
|
for (int retryIndex = 1; retryIndex <= 10; retryIndex++) {
|
|
final retryId = baseRetryId + retryIndex;
|
|
await _plugin.cancel(retryId);
|
|
}
|
|
}
|
|
}
|
|
printLog('🚫 Cancelled all retry notifications for supplement ID: $supplementId');
|
|
}
|
|
}
|