adds retry functionality

This commit is contained in:
2025-09-05 15:13:55 +02:00
parent 99711d56ec
commit 7828e48d9d
7 changed files with 414 additions and 47 deletions

View File

@@ -4,14 +4,14 @@ import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:supplements/logging.dart';
import 'package:supplements/services/simple_notification_service.dart';
import '../models/supplement.dart';
import '../providers/supplement_provider.dart';
import '../widgets/dialogs/bulk_take_dialog.dart';
import '../widgets/dialogs/take_supplement_dialog.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:supplements/services/simple_notification_service.dart';
/// Centralizes routing from notification actions/taps to in-app UI.
/// Handles both foreground/background taps and terminated-launch scenarios.
@@ -32,6 +32,9 @@ class NotificationRouter {
printLog('🔔 handleNotificationResponse: Received actionId: $actionId');
printLog('🔔 handleNotificationResponse: Decoded payloadMap: $payloadMap');
// Cancel retry notifications for any interaction (take, snooze, or tap)
await _cancelRetryNotificationsForResponse(payloadMap);
// Handle Snooze actions without surfacing UI
if (actionId == 'snooze_single' || actionId == 'snooze_group') {
try {
@@ -162,13 +165,14 @@ class NotificationRouter {
}
// Try to wait for providers to be ready to build rich content.
BuildContext? ctx = _navigatorKey?.currentContext;
final ready = await _waitUntilReady(timeout: const Duration(seconds: 5));
if (ctx != null && !ctx.mounted) ctx = null;
SupplementProvider? provider;
if (ready && ctx != null) {
provider = Provider.of<SupplementProvider>(ctx, listen: false);
if (ready) {
final ctx = _navigatorKey?.currentContext;
if (ctx != null && ctx.mounted) {
provider = Provider.of<SupplementProvider>(ctx, listen: false);
}
}
String title = 'Supplement reminder';
@@ -295,4 +299,44 @@ class NotificationRouter {
);
});
}
/// Cancel retry notifications when user responds to any notification.
/// This prevents redundant notifications after user interaction.
Future<void> _cancelRetryNotificationsForResponse(Map<String, dynamic>? payloadMap) async {
if (payloadMap == null) return;
try {
final type = payloadMap['type'];
if (type == 'single') {
// For single notifications, we need to find the time slot to cancel retries
// We can extract this from the meta if it's a retry, or find it from the supplement
final meta = payloadMap['meta'] as Map<String, dynamic>?;
if (meta != null && meta['originalTime'] != null) {
// This is a retry notification, cancel remaining retries for the original time
final originalTime = meta['originalTime'] as String;
await SimpleNotificationService.instance.cancelRetryNotificationsForTimeSlot(originalTime);
printLog('🚫 Cancelled retries for original time slot: $originalTime');
} else {
// This is an original notification, find the time slot from the supplement
final supplementId = payloadMap['id'];
if (supplementId is int) {
// We need to find which time slots this supplement has and cancel retries for all of them
// For now, we'll use the broader cancellation method
await SimpleNotificationService.instance.cancelRetryNotificationsForSupplement(supplementId);
printLog('🚫 Cancelled retries for supplement ID: $supplementId');
}
}
} else if (type == 'group') {
// For group notifications, we have the time key directly
final timeKey = payloadMap['time'] as String?;
if (timeKey != null) {
await SimpleNotificationService.instance.cancelRetryNotificationsForTimeSlot(timeKey);
printLog('🚫 Cancelled retries for time slot: $timeKey');
}
}
} catch (e) {
printLog('⚠️ Failed to cancel retry notifications: $e');
}
}
}

View File

@@ -1,11 +0,0 @@
/*
Deprecated/removed: notification_service.dart
This legacy notification service has been intentionally removed.
The app now uses a minimal scheduler in:
services/simple_notification_service.dart
All retry/snooze/database-tracking logic has been dropped to keep things simple.
This file is left empty to ensure any lingering references fail at compile time,
prompting migration to the new SimpleNotificationService.
*/

View File

@@ -1,12 +1,14 @@
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 'dart:convert';
import 'package:supplements/services/notification_router.dart';
import 'package:supplements/services/notification_debug_store.dart';
import '../models/supplement.dart';
import '../providers/settings_provider.dart';
/// A minimal notification scheduler focused purely on:
/// - Initialization
@@ -131,8 +133,12 @@ class SimpleNotificationService {
///
/// 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) async {
Future<void> scheduleDailyGroupedReminders(
List<Supplement> supplements, {
SettingsProvider? settingsProvider,
}) async {
if (!_initialized) {
await initialize();
}
@@ -281,6 +287,20 @@ class SimpleNotificationService {
);
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
@@ -297,13 +317,16 @@ class SimpleNotificationService {
/// Convenience to schedule grouped reminders for today and tomorrow.
///
/// For iOSs 64 limit, we stick to one day for recurring (matchDateTimeComponents)
/// 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 tomorrows one-offs,
/// 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 todays recurring groups.
await scheduleDailyGroupedReminders(supplements);
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].
@@ -565,4 +588,176 @@ class SimpleNotificationService {
}
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');
}
}