mirror of
https://github.com/vleeuwenmenno/supplements.git
synced 2025-12-08 14:05:56 +00:00
Compare commits
3 Commits
99711d56ec
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
769d113713
|
|||
|
5684a197e7
|
|||
|
7828e48d9d
|
@@ -8,7 +8,11 @@
|
|||||||
],
|
],
|
||||||
"env": {
|
"env": {
|
||||||
"DEFAULT_MINIMUM_TOKENS": ""
|
"DEFAULT_MINIMUM_TOKENS": ""
|
||||||
}
|
},
|
||||||
|
"alwaysAllow": [
|
||||||
|
"resolve-library-id",
|
||||||
|
"get-library-docs"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<!-- Permissions for notifications -->
|
<!-- Permissions for notifications -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
<uses-permission android:name="android.permission.VIBRATE" />
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
|
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -105,7 +105,9 @@ class MyApp extends StatelessWidget {
|
|||||||
colorScheme: const ShadZincColorScheme.dark(),
|
colorScheme: const ShadZincColorScheme.dark(),
|
||||||
),
|
),
|
||||||
themeMode: settingsProvider.themeMode,
|
themeMode: settingsProvider.themeMode,
|
||||||
home: const HomeScreen(),
|
home: ShadSonner(
|
||||||
|
child: const HomeScreen(),
|
||||||
|
),
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
import '../models/ingredient.dart';
|
import '../models/ingredient.dart';
|
||||||
@@ -571,10 +572,17 @@ class _AddSupplementScreenState extends State<AddSupplementScreen> {
|
|||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
if (validIngredients.isEmpty) {
|
if (validIngredients.isEmpty) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
final sonner = ShadSonner.of(context);
|
||||||
const SnackBar(
|
final id = DateTime.now().millisecondsSinceEpoch;
|
||||||
content:
|
sonner.show(
|
||||||
Text('Please add at least one ingredient with name and amount'),
|
ShadToast(
|
||||||
|
id: id,
|
||||||
|
title: const Text('Please add at least one ingredient with name and amount'),
|
||||||
|
action: ShadButton(
|
||||||
|
size: ShadButtonSize.sm,
|
||||||
|
child: const Text('Dismiss'),
|
||||||
|
onPressed: () => sonner.hide(id),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -611,21 +619,36 @@ class _AddSupplementScreenState extends State<AddSupplementScreen> {
|
|||||||
if (mounted) {
|
if (mounted) {
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
final sonner = ShadSonner.of(context);
|
||||||
SnackBar(
|
final id = DateTime.now().millisecondsSinceEpoch;
|
||||||
content: Text(widget.supplement != null
|
sonner.show(
|
||||||
|
ShadToast(
|
||||||
|
id: id,
|
||||||
|
title: Text(widget.supplement != null
|
||||||
? 'Supplement updated successfully!'
|
? 'Supplement updated successfully!'
|
||||||
: 'Supplement added successfully!'),
|
: 'Supplement added successfully!'),
|
||||||
backgroundColor: Colors.green,
|
action: ShadButton(
|
||||||
|
size: ShadButtonSize.sm,
|
||||||
|
child: const Text('Dismiss'),
|
||||||
|
onPressed: () => sonner.hide(id),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
final sonner = ShadSonner.of(context);
|
||||||
SnackBar(
|
final id = DateTime.now().millisecondsSinceEpoch;
|
||||||
content: Text('Error: ${e.toString()}'),
|
sonner.show(
|
||||||
backgroundColor: Colors.red,
|
ShadToast(
|
||||||
|
id: id,
|
||||||
|
title: const Text('Error'),
|
||||||
|
description: Text('${e.toString()}'),
|
||||||
|
action: ShadButton(
|
||||||
|
size: ShadButtonSize.sm,
|
||||||
|
child: const Text('Dismiss'),
|
||||||
|
onPressed: () => sonner.hide(id),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||||
import 'package:supplements/widgets/info_chip.dart';
|
import 'package:supplements/widgets/info_chip.dart';
|
||||||
|
|
||||||
import '../models/supplement.dart';
|
import '../models/supplement.dart';
|
||||||
@@ -97,10 +98,9 @@ class _ArchivedSupplementsScreenState extends State<ArchivedSupplementsScreen> {
|
|||||||
onPressed: () {
|
onPressed: () {
|
||||||
context.read<SupplementProvider>().unarchiveSupplement(supplement.id!);
|
context.read<SupplementProvider>().unarchiveSupplement(supplement.id!);
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ShadSonner.of(context).show(
|
||||||
SnackBar(
|
ShadToast(
|
||||||
content: Text('${supplement.name} unarchived'),
|
title: Text('${supplement.name} unarchived'),
|
||||||
backgroundColor: Colors.green,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -128,10 +128,9 @@ class _ArchivedSupplementsScreenState extends State<ArchivedSupplementsScreen> {
|
|||||||
onPressed: () {
|
onPressed: () {
|
||||||
context.read<SupplementProvider>().deleteArchivedSupplement(supplement.id!);
|
context.read<SupplementProvider>().deleteArchivedSupplement(supplement.id!);
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ShadSonner.of(context).show(
|
||||||
SnackBar(
|
ShadToast(
|
||||||
content: Text('${supplement.name} deleted permanently'),
|
title: Text('${supplement.name} deleted permanently'),
|
||||||
backgroundColor: Colors.red,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.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:shadcn_ui/shadcn_ui.dart';
|
||||||
import 'package:supplements/providers/supplement_provider.dart';
|
import 'package:supplements/providers/supplement_provider.dart';
|
||||||
import 'package:supplements/services/notification_debug_store.dart';
|
import 'package:supplements/services/notification_debug_store.dart';
|
||||||
import 'package:supplements/services/simple_notification_service.dart';
|
import 'package:supplements/services/simple_notification_service.dart';
|
||||||
@@ -168,8 +169,10 @@ class _DebugNotificationsScreenState extends State<DebugNotificationsScreen> {
|
|||||||
await SimpleNotificationService.instance.cancelById(id);
|
await SimpleNotificationService.instance.cancelById(id);
|
||||||
await _load();
|
await _load();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ShadSonner.of(context).show(
|
||||||
SnackBar(content: Text('Canceled notification $id')),
|
ShadToast(
|
||||||
|
title: Text('Canceled notification $id'),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,8 +180,10 @@ class _DebugNotificationsScreenState extends State<DebugNotificationsScreen> {
|
|||||||
await SimpleNotificationService.instance.cancelAll();
|
await SimpleNotificationService.instance.cancelAll();
|
||||||
await _load();
|
await _load();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ShadSonner.of(context).show(
|
||||||
const SnackBar(content: Text('Canceled all notifications')),
|
const ShadToast(
|
||||||
|
title: Text('Canceled all notifications'),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,15 +191,19 @@ class _DebugNotificationsScreenState extends State<DebugNotificationsScreen> {
|
|||||||
await NotificationDebugStore.instance.clear();
|
await NotificationDebugStore.instance.clear();
|
||||||
await _load();
|
await _load();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ShadSonner.of(context).show(
|
||||||
const SnackBar(content: Text('Cleared debug log')),
|
const ShadToast(
|
||||||
|
title: Text('Cleared debug log'),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _copyToClipboard(String text) {
|
void _copyToClipboard(String text) {
|
||||||
Clipboard.setData(ClipboardData(text: text));
|
Clipboard.setData(ClipboardData(text: text));
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ShadSonner.of(context).show(
|
||||||
const SnackBar(content: Text('Copied to clipboard')),
|
const ShadToast(
|
||||||
|
title: Text('Copied to clipboard'),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,8 +289,10 @@ class _DebugNotificationsScreenState extends State<DebugNotificationsScreen> {
|
|||||||
isSingle: true, // This is a single notification
|
isSingle: true, // This is a single notification
|
||||||
);
|
);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ShadSonner.of(context).show(
|
||||||
const SnackBar(content: Text('Test snooze notification sent!')),
|
const ShadToast(
|
||||||
|
title: Text('Test snooze notification sent!'),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
child: const Text('Send Test Snooze Notification'),
|
child: const Text('Send Test Snooze Notification'),
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||||
|
|
||||||
import '../providers/settings_provider.dart';
|
|
||||||
import '../providers/supplement_provider.dart';
|
import '../providers/supplement_provider.dart';
|
||||||
|
|
||||||
class HistoryScreen extends StatefulWidget {
|
class HistoryScreen extends StatefulWidget {
|
||||||
@@ -350,10 +349,17 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
context.read<SupplementProvider>().loadMonthlyIntakes(_selectedYear, _selectedMonth);
|
context.read<SupplementProvider>().loadMonthlyIntakes(_selectedYear, _selectedMonth);
|
||||||
});
|
});
|
||||||
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
final sonner = ShadSonner.of(context);
|
||||||
SnackBar(
|
final id = DateTime.now().millisecondsSinceEpoch;
|
||||||
content: Text('$supplementName intake deleted'),
|
sonner.show(
|
||||||
backgroundColor: Colors.red,
|
ShadToast(
|
||||||
|
id: id,
|
||||||
|
title: Text('$supplementName intake deleted'),
|
||||||
|
action: ShadButton(
|
||||||
|
size: ShadButtonSize.sm,
|
||||||
|
child: const Text('Dismiss'),
|
||||||
|
onPressed: () => sonner.hide(id),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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:provider/provider.dart';
|
||||||
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||||
|
|
||||||
import '../providers/settings_provider.dart';
|
import '../providers/settings_provider.dart';
|
||||||
import 'debug_notifications_screen.dart';
|
import 'debug_notifications_screen.dart';
|
||||||
@@ -106,23 +107,97 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
// Notifications
|
// Notifications
|
||||||
Card(
|
Card(
|
||||||
child: ListTile(
|
child: Padding(
|
||||||
leading: const Icon(Icons.snooze),
|
padding: const EdgeInsets.all(16.0),
|
||||||
title: const Text('Snooze duration'),
|
child: Column(
|
||||||
subtitle: const Text('Delay for Snooze action'),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
trailing: DropdownButton<int>(
|
children: [
|
||||||
value: settingsProvider.snoozeMinutes,
|
Text(
|
||||||
items: const [
|
'Notifications',
|
||||||
DropdownMenuItem(value: 5, child: Text('5 min')),
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
DropdownMenuItem(value: 10, child: Text('10 min')),
|
),
|
||||||
DropdownMenuItem(value: 15, child: Text('15 min')),
|
const SizedBox(height: 16),
|
||||||
DropdownMenuItem(value: 20, child: Text('20 min')),
|
ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
leading: const Icon(Icons.snooze),
|
||||||
|
title: const Text('Snooze duration'),
|
||||||
|
subtitle: const Text('Delay for Snooze action'),
|
||||||
|
trailing: DropdownButton<int>(
|
||||||
|
value: settingsProvider.snoozeMinutes,
|
||||||
|
items: const [
|
||||||
|
DropdownMenuItem(value: 2, child: Text('2 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')),
|
||||||
|
],
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value != null) {
|
||||||
|
settingsProvider.setSnoozeMinutes(value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
onChanged: (value) {
|
|
||||||
if (value != null) {
|
|
||||||
settingsProvider.setSnoozeMinutes(value);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -228,10 +303,18 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
final sonner = ShadSonner.of(context);
|
||||||
SnackBar(
|
final id = DateTime.now().millisecondsSinceEpoch;
|
||||||
content: Text('Invalid time ranges: ${e.toString()}'),
|
sonner.show(
|
||||||
backgroundColor: Colors.red,
|
ShadToast(
|
||||||
|
id: id,
|
||||||
|
title: const Text('Invalid time ranges'),
|
||||||
|
description: Text('${e.toString()}'),
|
||||||
|
action: ShadButton(
|
||||||
|
size: ShadButtonSize.sm,
|
||||||
|
child: const Text('Dismiss'),
|
||||||
|
onPressed: () => sonner.hide(id),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||||
|
|
||||||
import '../providers/settings_provider.dart';
|
import '../providers/settings_provider.dart';
|
||||||
import '../providers/simple_sync_provider.dart';
|
import '../providers/simple_sync_provider.dart';
|
||||||
@@ -553,21 +554,36 @@ class _SimpleSyncSettingsScreenState extends State<SimpleSyncSettingsScreen> {
|
|||||||
final success = await syncProvider.testConnection();
|
final success = await syncProvider.testConnection();
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
final sonner = ShadSonner.of(context);
|
||||||
SnackBar(
|
final id = DateTime.now().millisecondsSinceEpoch;
|
||||||
content: Text(success
|
sonner.show(
|
||||||
|
ShadToast(
|
||||||
|
id: id,
|
||||||
|
title: Text(success
|
||||||
? 'Connection successful!'
|
? 'Connection successful!'
|
||||||
: 'Connection failed. Check your settings.'),
|
: 'Connection failed. Check your settings.'),
|
||||||
backgroundColor: success ? Colors.green : Colors.red,
|
action: ShadButton(
|
||||||
|
size: ShadButtonSize.sm,
|
||||||
|
child: const Text('Dismiss'),
|
||||||
|
onPressed: () => sonner.hide(id),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
final sonner = ShadSonner.of(context);
|
||||||
SnackBar(
|
final id = DateTime.now().millisecondsSinceEpoch;
|
||||||
content: Text('Connection test failed: $e'),
|
sonner.show(
|
||||||
backgroundColor: Colors.red,
|
ShadToast(
|
||||||
|
id: id,
|
||||||
|
title: const Text('Connection test failed'),
|
||||||
|
description: Text('$e'),
|
||||||
|
action: ShadButton(
|
||||||
|
size: ShadButtonSize.sm,
|
||||||
|
child: const Text('Dismiss'),
|
||||||
|
onPressed: () => sonner.hide(id),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -596,19 +612,34 @@ class _SimpleSyncSettingsScreenState extends State<SimpleSyncSettingsScreen> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
final sonner = ShadSonner.of(context);
|
||||||
const SnackBar(
|
final id = DateTime.now().millisecondsSinceEpoch;
|
||||||
content: Text('Configuration saved successfully!'),
|
sonner.show(
|
||||||
backgroundColor: Colors.green,
|
ShadToast(
|
||||||
|
id: id,
|
||||||
|
title: const Text('Configuration saved successfully!'),
|
||||||
|
action: ShadButton(
|
||||||
|
size: ShadButtonSize.sm,
|
||||||
|
child: const Text('Dismiss'),
|
||||||
|
onPressed: () => sonner.hide(id),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
final sonner = ShadSonner.of(context);
|
||||||
SnackBar(
|
final id = DateTime.now().millisecondsSinceEpoch;
|
||||||
content: Text('Failed to save configuration: $e'),
|
sonner.show(
|
||||||
backgroundColor: Colors.red,
|
ShadToast(
|
||||||
|
id: id,
|
||||||
|
title: const Text('Failed to save configuration'),
|
||||||
|
description: Text('$e'),
|
||||||
|
action: ShadButton(
|
||||||
|
size: ShadButtonSize.sm,
|
||||||
|
child: const Text('Dismiss'),
|
||||||
|
onPressed: () => sonner.hide(id),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -622,19 +653,34 @@ class _SimpleSyncSettingsScreenState extends State<SimpleSyncSettingsScreen> {
|
|||||||
await syncProvider.syncDatabase(isAutoSync: false);
|
await syncProvider.syncDatabase(isAutoSync: false);
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
final sonner = ShadSonner.of(context);
|
||||||
const SnackBar(
|
final id = DateTime.now().millisecondsSinceEpoch;
|
||||||
content: Text('Manual sync completed!'),
|
sonner.show(
|
||||||
backgroundColor: Colors.green,
|
ShadToast(
|
||||||
|
id: id,
|
||||||
|
title: const Text('Manual sync completed!'),
|
||||||
|
action: ShadButton(
|
||||||
|
size: ShadButtonSize.sm,
|
||||||
|
child: const Text('Dismiss'),
|
||||||
|
onPressed: () => sonner.hide(id),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
final sonner = ShadSonner.of(context);
|
||||||
SnackBar(
|
final id = DateTime.now().millisecondsSinceEpoch;
|
||||||
content: Text('Manual sync failed: $e'),
|
sonner.show(
|
||||||
backgroundColor: Colors.red,
|
ShadToast(
|
||||||
|
id: id,
|
||||||
|
title: const Text('Manual sync failed'),
|
||||||
|
description: Text('$e'),
|
||||||
|
action: ShadButton(
|
||||||
|
size: ShadButtonSize.sm,
|
||||||
|
child: const Text('Dismiss'),
|
||||||
|
onPressed: () => sonner.hide(id),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||||
|
|
||||||
import '../models/supplement.dart';
|
import '../models/supplement.dart';
|
||||||
import '../providers/settings_provider.dart';
|
import '../providers/settings_provider.dart';
|
||||||
@@ -308,10 +309,9 @@ class SupplementsListScreen extends StatelessWidget {
|
|||||||
onPressed: () {
|
onPressed: () {
|
||||||
context.read<SupplementProvider>().deleteSupplement(supplement.id!);
|
context.read<SupplementProvider>().deleteSupplement(supplement.id!);
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ShadSonner.of(context).show(
|
||||||
SnackBar(
|
ShadToast(
|
||||||
content: Text('${supplement.name} deleted'),
|
title: Text('${supplement.name} deleted'),
|
||||||
backgroundColor: Colors.red,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -338,10 +338,9 @@ class SupplementsListScreen extends StatelessWidget {
|
|||||||
onPressed: () {
|
onPressed: () {
|
||||||
context.read<SupplementProvider>().archiveSupplement(supplement.id!);
|
context.read<SupplementProvider>().archiveSupplement(supplement.id!);
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ShadSonner.of(context).show(
|
||||||
SnackBar(
|
ShadToast(
|
||||||
content: Text('${supplement.name} archived'),
|
title: Text('${supplement.name} archived'),
|
||||||
backgroundColor: Colors.orange,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,14 +4,15 @@ 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:shadcn_ui/shadcn_ui.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.
|
||||||
@@ -46,6 +47,11 @@ class NotificationRouter {
|
|||||||
|
|
||||||
// Default: route to in-app UI for Take actions and normal taps
|
// Default: route to in-app UI for Take actions and normal taps
|
||||||
await _routeFromPayload(payloadMap);
|
await _routeFromPayload(payloadMap);
|
||||||
|
|
||||||
|
// Cancel retry notifications only for Take actions (not for taps or snoozes)
|
||||||
|
if (actionId == 'take_single' || actionId == 'take_group') {
|
||||||
|
await _cancelRetryNotificationsForResponse(payloadMap);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> handleAppLaunchDetails(NotificationAppLaunchDetails? details) async {
|
Future<void> handleAppLaunchDetails(NotificationAppLaunchDetails? details) async {
|
||||||
@@ -162,13 +168,14 @@ 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) {
|
||||||
provider = Provider.of<SupplementProvider>(ctx, listen: false);
|
final ctx = _navigatorKey?.currentContext;
|
||||||
|
if (ctx != null && ctx.mounted) {
|
||||||
|
provider = Provider.of<SupplementProvider>(ctx, listen: false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String title = 'Supplement reminder';
|
String title = 'Supplement reminder';
|
||||||
@@ -290,9 +297,51 @@ class NotificationRouter {
|
|||||||
|
|
||||||
void _showSnack(BuildContext context, String message) {
|
void _showSnack(BuildContext context, String message) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ShadSonner.of(context).show(
|
||||||
SnackBar(content: Text(message)),
|
ShadToast(
|
||||||
|
title: Text(message),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||||
|
|
||||||
import '../../models/supplement.dart';
|
import '../../models/supplement.dart';
|
||||||
import '../../providers/supplement_provider.dart';
|
import '../../providers/supplement_provider.dart';
|
||||||
@@ -183,10 +184,17 @@ Future<void> showBulkTakeDialog(
|
|||||||
|
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
if (recorded > 0) {
|
if (recorded > 0) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
final sonner = ShadSonner.of(context);
|
||||||
SnackBar(
|
final id = DateTime.now().millisecondsSinceEpoch;
|
||||||
content: Text('Recorded $recorded supplement${recorded == 1 ? '' : 's'}'),
|
sonner.show(
|
||||||
backgroundColor: Colors.green,
|
ShadToast(
|
||||||
|
id: id,
|
||||||
|
title: Text('Recorded $recorded supplement${recorded == 1 ? '' : 's'}'),
|
||||||
|
action: ShadButton(
|
||||||
|
size: ShadButtonSize.sm,
|
||||||
|
child: const Text('Dismiss'),
|
||||||
|
onPressed: () => sonner.hide(id),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
name: supplements
|
name: supplements
|
||||||
description: "A supplement tracking app for managing your daily supplements"
|
description: "A supplement tracking app for managing your daily supplements"
|
||||||
publish_to: "none"
|
publish_to: "none"
|
||||||
version: 1.0.9+31082025
|
version: 1.0.10+05092025
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.9.0
|
sdk: ^3.9.0
|
||||||
|
|||||||
Reference in New Issue
Block a user