mirror of
https://github.com/vleeuwenmenno/supplements.git
synced 2025-09-11 18:29:12 +02:00
adds retry functionality
This commit is contained in:
@@ -1,9 +1,9 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart'; // Import this
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart'; // Import this
|
||||||
import 'package:supplements/logging.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||||
|
import 'package:supplements/logging.dart';
|
||||||
|
|
||||||
import 'providers/settings_provider.dart';
|
import 'providers/settings_provider.dart';
|
||||||
import 'providers/simple_sync_provider.dart';
|
import 'providers/simple_sync_provider.dart';
|
||||||
@@ -46,7 +46,7 @@ class MyApp extends StatelessWidget {
|
|||||||
return MultiProvider(
|
return MultiProvider(
|
||||||
providers: [
|
providers: [
|
||||||
ChangeNotifierProvider(
|
ChangeNotifierProvider(
|
||||||
create: (context) => SupplementProvider()..initialize(),
|
create: (context) => SupplementProvider()..initialize(context),
|
||||||
),
|
),
|
||||||
ChangeNotifierProvider.value(
|
ChangeNotifierProvider.value(
|
||||||
value: settingsProvider,
|
value: settingsProvider,
|
||||||
|
@@ -23,6 +23,11 @@ class SettingsProvider extends ChangeNotifier {
|
|||||||
// Notifications
|
// Notifications
|
||||||
int _snoozeMinutes = 10;
|
int _snoozeMinutes = 10;
|
||||||
|
|
||||||
|
// Notification retry settings
|
||||||
|
bool _notificationRetryEnabled = true;
|
||||||
|
int _notificationRetryCount = 3;
|
||||||
|
int _notificationRetryDelayMinutes = 5;
|
||||||
|
|
||||||
// Auto-sync settings
|
// Auto-sync settings
|
||||||
bool _autoSyncEnabled = false;
|
bool _autoSyncEnabled = false;
|
||||||
int _autoSyncDebounceSeconds = 5;
|
int _autoSyncDebounceSeconds = 5;
|
||||||
@@ -42,6 +47,11 @@ class SettingsProvider extends ChangeNotifier {
|
|||||||
// Notifications
|
// Notifications
|
||||||
int get snoozeMinutes => _snoozeMinutes;
|
int get snoozeMinutes => _snoozeMinutes;
|
||||||
|
|
||||||
|
// Notification retry getters
|
||||||
|
bool get notificationRetryEnabled => _notificationRetryEnabled;
|
||||||
|
int get notificationRetryCount => _notificationRetryCount;
|
||||||
|
int get notificationRetryDelayMinutes => _notificationRetryDelayMinutes;
|
||||||
|
|
||||||
// Auto-sync getters
|
// Auto-sync getters
|
||||||
bool get autoSyncEnabled => _autoSyncEnabled;
|
bool get autoSyncEnabled => _autoSyncEnabled;
|
||||||
int get autoSyncDebounceSeconds => _autoSyncDebounceSeconds;
|
int get autoSyncDebounceSeconds => _autoSyncDebounceSeconds;
|
||||||
@@ -85,6 +95,11 @@ class SettingsProvider extends ChangeNotifier {
|
|||||||
// Load snooze setting
|
// Load snooze setting
|
||||||
_snoozeMinutes = prefs.getInt('snooze_minutes') ?? 10;
|
_snoozeMinutes = prefs.getInt('snooze_minutes') ?? 10;
|
||||||
|
|
||||||
|
// Load notification retry settings
|
||||||
|
_notificationRetryEnabled = prefs.getBool('notification_retry_enabled') ?? true;
|
||||||
|
_notificationRetryCount = prefs.getInt('notification_retry_count') ?? 3;
|
||||||
|
_notificationRetryDelayMinutes = prefs.getInt('notification_retry_delay_minutes') ?? 5;
|
||||||
|
|
||||||
// Load auto-sync settings
|
// Load auto-sync settings
|
||||||
_autoSyncEnabled = prefs.getBool('auto_sync_enabled') ?? false;
|
_autoSyncEnabled = prefs.getBool('auto_sync_enabled') ?? false;
|
||||||
_autoSyncDebounceSeconds = prefs.getInt('auto_sync_debounce_seconds') ?? 30;
|
_autoSyncDebounceSeconds = prefs.getInt('auto_sync_debounce_seconds') ?? 30;
|
||||||
@@ -259,6 +274,37 @@ class SettingsProvider extends ChangeNotifier {
|
|||||||
await prefs.setInt('snooze_minutes', minutes);
|
await prefs.setInt('snooze_minutes', minutes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> setNotificationRetryEnabled(bool enabled) async {
|
||||||
|
_notificationRetryEnabled = enabled;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool('notification_retry_enabled', enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setNotificationRetryCount(int count) async {
|
||||||
|
if (count < 0 || count > 10) {
|
||||||
|
throw ArgumentError('Retry count must be between 0 and 10');
|
||||||
|
}
|
||||||
|
_notificationRetryCount = count;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setInt('notification_retry_count', count);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setNotificationRetryDelayMinutes(int minutes) async {
|
||||||
|
const allowed = [1, 2, 3, 5, 10, 15, 20, 30];
|
||||||
|
if (!allowed.contains(minutes)) {
|
||||||
|
throw ArgumentError('Retry delay must be one of ${allowed.join(", ")} minutes');
|
||||||
|
}
|
||||||
|
_notificationRetryDelayMinutes = minutes;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setInt('notification_retry_delay_minutes', minutes);
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-sync setters
|
// Auto-sync setters
|
||||||
Future<void> setAutoSyncEnabled(bool enabled) async {
|
Future<void> setAutoSyncEnabled(bool enabled) async {
|
||||||
_autoSyncEnabled = enabled;
|
_autoSyncEnabled = enabled;
|
||||||
|
@@ -3,10 +3,12 @@ import 'dart:async';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
import 'package:supplements/logging.dart';
|
import 'package:supplements/logging.dart';
|
||||||
|
|
||||||
import '../models/supplement.dart';
|
import '../models/supplement.dart';
|
||||||
import '../models/supplement_intake.dart';
|
import '../models/supplement_intake.dart';
|
||||||
|
import '../providers/settings_provider.dart';
|
||||||
import '../services/database_helper.dart';
|
import '../services/database_helper.dart';
|
||||||
import '../services/database_sync_service.dart';
|
import '../services/database_sync_service.dart';
|
||||||
import '../services/simple_notification_service.dart';
|
import '../services/simple_notification_service.dart';
|
||||||
@@ -27,6 +29,9 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
|
|||||||
// Callback for triggering sync when data changes
|
// Callback for triggering sync when data changes
|
||||||
VoidCallback? _onDataChanged;
|
VoidCallback? _onDataChanged;
|
||||||
|
|
||||||
|
// Context for accessing other providers
|
||||||
|
BuildContext? _context;
|
||||||
|
|
||||||
List<Supplement> get supplements => _supplements;
|
List<Supplement> get supplements => _supplements;
|
||||||
List<Map<String, dynamic>> get todayIntakes => _todayIntakes;
|
List<Map<String, dynamic>> get todayIntakes => _todayIntakes;
|
||||||
List<Map<String, dynamic>> get monthlyIntakes => _monthlyIntakes;
|
List<Map<String, dynamic>> get monthlyIntakes => _monthlyIntakes;
|
||||||
@@ -42,11 +47,12 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
|
|||||||
_onDataChanged?.call();
|
_onDataChanged?.call();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> initialize() async {
|
Future<void> initialize([BuildContext? context]) async {
|
||||||
if (_initialized) {
|
if (_initialized) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_initialized = true;
|
_initialized = true;
|
||||||
|
_context = context;
|
||||||
|
|
||||||
// Add this provider as an observer for app lifecycle changes
|
// Add this provider as an observer for app lifecycle changes
|
||||||
WidgetsBinding.instance.addObserver(this);
|
WidgetsBinding.instance.addObserver(this);
|
||||||
@@ -135,7 +141,21 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await _notificationService.scheduleDailyGroupedRemindersSafe(_supplements);
|
SettingsProvider? settingsProvider;
|
||||||
|
if (_context != null && _context!.mounted) {
|
||||||
|
try {
|
||||||
|
settingsProvider = Provider.of<SettingsProvider>(_context!, listen: false);
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
printLog('📱 Could not access SettingsProvider: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _notificationService.scheduleDailyGroupedRemindersSafe(
|
||||||
|
_supplements,
|
||||||
|
settingsProvider: settingsProvider,
|
||||||
|
);
|
||||||
await _notificationService.getPendingNotifications();
|
await _notificationService.getPendingNotifications();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
|
@@ -106,7 +106,18 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
// Notifications
|
// Notifications
|
||||||
Card(
|
Card(
|
||||||
child: ListTile(
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Notifications',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
leading: const Icon(Icons.snooze),
|
leading: const Icon(Icons.snooze),
|
||||||
title: const Text('Snooze duration'),
|
title: const Text('Snooze duration'),
|
||||||
subtitle: const Text('Delay for Snooze action'),
|
subtitle: const Text('Delay for Snooze action'),
|
||||||
@@ -125,6 +136,68 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const Divider(),
|
||||||
|
SwitchListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
secondary: const Icon(Icons.repeat),
|
||||||
|
title: const Text('Notification Retries'),
|
||||||
|
subtitle: const Text('Automatically retry missed notifications'),
|
||||||
|
value: settingsProvider.notificationRetryEnabled,
|
||||||
|
onChanged: (value) {
|
||||||
|
settingsProvider.setNotificationRetryEnabled(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (settingsProvider.notificationRetryEnabled) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
leading: const Icon(Icons.format_list_numbered),
|
||||||
|
title: const Text('Retry count'),
|
||||||
|
subtitle: const Text('Number of retry attempts'),
|
||||||
|
trailing: DropdownButton<int>(
|
||||||
|
value: settingsProvider.notificationRetryCount,
|
||||||
|
items: const [
|
||||||
|
DropdownMenuItem(value: 1, child: Text('1')),
|
||||||
|
DropdownMenuItem(value: 2, child: Text('2')),
|
||||||
|
DropdownMenuItem(value: 3, child: Text('3')),
|
||||||
|
DropdownMenuItem(value: 4, child: Text('4')),
|
||||||
|
DropdownMenuItem(value: 5, child: Text('5')),
|
||||||
|
],
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value != null) {
|
||||||
|
settingsProvider.setNotificationRetryCount(value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
leading: const Icon(Icons.schedule),
|
||||||
|
title: const Text('Retry delay'),
|
||||||
|
subtitle: const Text('Time between retry attempts'),
|
||||||
|
trailing: DropdownButton<int>(
|
||||||
|
value: settingsProvider.notificationRetryDelayMinutes,
|
||||||
|
items: const [
|
||||||
|
DropdownMenuItem(value: 1, child: Text('1 min')),
|
||||||
|
DropdownMenuItem(value: 2, child: Text('2 min')),
|
||||||
|
DropdownMenuItem(value: 3, child: Text('3 min')),
|
||||||
|
DropdownMenuItem(value: 5, child: Text('5 min')),
|
||||||
|
DropdownMenuItem(value: 10, child: Text('10 min')),
|
||||||
|
DropdownMenuItem(value: 15, child: Text('15 min')),
|
||||||
|
DropdownMenuItem(value: 20, child: Text('20 min')),
|
||||||
|
DropdownMenuItem(value: 30, child: Text('30 min')),
|
||||||
|
],
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value != null) {
|
||||||
|
settingsProvider.setNotificationRetryDelayMinutes(value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Card(
|
Card(
|
||||||
|
@@ -4,14 +4,14 @@ import 'dart:convert';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:supplements/logging.dart';
|
import 'package:supplements/logging.dart';
|
||||||
|
import 'package:supplements/services/simple_notification_service.dart';
|
||||||
|
|
||||||
import '../models/supplement.dart';
|
import '../models/supplement.dart';
|
||||||
import '../providers/supplement_provider.dart';
|
import '../providers/supplement_provider.dart';
|
||||||
import '../widgets/dialogs/bulk_take_dialog.dart';
|
import '../widgets/dialogs/bulk_take_dialog.dart';
|
||||||
import '../widgets/dialogs/take_supplement_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.
|
/// Centralizes routing from notification actions/taps to in-app UI.
|
||||||
/// Handles both foreground/background taps and terminated-launch scenarios.
|
/// Handles both foreground/background taps and terminated-launch scenarios.
|
||||||
@@ -32,6 +32,9 @@ class NotificationRouter {
|
|||||||
printLog('🔔 handleNotificationResponse: Received actionId: $actionId');
|
printLog('🔔 handleNotificationResponse: Received actionId: $actionId');
|
||||||
printLog('🔔 handleNotificationResponse: Decoded payloadMap: $payloadMap');
|
printLog('🔔 handleNotificationResponse: Decoded payloadMap: $payloadMap');
|
||||||
|
|
||||||
|
// Cancel retry notifications for any interaction (take, snooze, or tap)
|
||||||
|
await _cancelRetryNotificationsForResponse(payloadMap);
|
||||||
|
|
||||||
// Handle Snooze actions without surfacing UI
|
// Handle Snooze actions without surfacing UI
|
||||||
if (actionId == 'snooze_single' || actionId == 'snooze_group') {
|
if (actionId == 'snooze_single' || actionId == 'snooze_group') {
|
||||||
try {
|
try {
|
||||||
@@ -162,14 +165,15 @@ class NotificationRouter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Try to wait for providers to be ready to build rich content.
|
// 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));
|
final ready = await _waitUntilReady(timeout: const Duration(seconds: 5));
|
||||||
if (ctx != null && !ctx.mounted) ctx = null;
|
|
||||||
|
|
||||||
SupplementProvider? provider;
|
SupplementProvider? provider;
|
||||||
if (ready && ctx != null) {
|
if (ready) {
|
||||||
|
final ctx = _navigatorKey?.currentContext;
|
||||||
|
if (ctx != null && ctx.mounted) {
|
||||||
provider = Provider.of<SupplementProvider>(ctx, listen: false);
|
provider = Provider.of<SupplementProvider>(ctx, listen: false);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
String title = 'Supplement reminder';
|
String title = 'Supplement reminder';
|
||||||
String body = 'Tap to see details';
|
String body = 'Tap to see details';
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -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.
|
|
||||||
*/
|
|
@@ -1,12 +1,14 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
import 'package:supplements/logging.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/data/latest.dart' as tzdata;
|
||||||
import 'package:timezone/timezone.dart' as tz;
|
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 '../models/supplement.dart';
|
||||||
|
import '../providers/settings_provider.dart';
|
||||||
|
|
||||||
/// A minimal notification scheduler focused purely on:
|
/// A minimal notification scheduler focused purely on:
|
||||||
/// - Initialization
|
/// - Initialization
|
||||||
@@ -131,8 +133,12 @@ class SimpleNotificationService {
|
|||||||
///
|
///
|
||||||
/// IDs:
|
/// IDs:
|
||||||
/// - Group ID per time slot: 40000 + hour*60 + minute.
|
/// - Group ID per time slot: 40000 + hour*60 + minute.
|
||||||
|
/// - Retry IDs: 50000 + (hour*60 + minute)*100 + retryIndex.
|
||||||
/// - Stable and predictable for cancel/update operations.
|
/// - 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) {
|
if (!_initialized) {
|
||||||
await initialize();
|
await initialize();
|
||||||
}
|
}
|
||||||
@@ -281,6 +287,20 @@ class SimpleNotificationService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
printLog('✅ Scheduled group $timeKey with ID $id');
|
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
|
// Log what the system reports as pending
|
||||||
@@ -297,13 +317,16 @@ class SimpleNotificationService {
|
|||||||
|
|
||||||
/// Convenience to schedule grouped reminders for today and tomorrow.
|
/// Convenience to schedule grouped reminders for today and tomorrow.
|
||||||
///
|
///
|
||||||
/// For iOS’s 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.
|
/// 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,
|
/// 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.
|
/// but with daily components this is unnecessary and risks hitting iOS limits.
|
||||||
Future<void> scheduleDailyGroupedRemindersSafe(List<Supplement> supplements) async {
|
Future<void> scheduleDailyGroupedRemindersSafe(
|
||||||
// For now, just schedule today’s recurring groups.
|
List<Supplement> supplements, {
|
||||||
await scheduleDailyGroupedReminders(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].
|
/// Cancel all scheduled reminders for a given [supplementId].
|
||||||
@@ -565,4 +588,176 @@ class SimpleNotificationService {
|
|||||||
}
|
}
|
||||||
return list;
|
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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user