3 Commits

17 changed files with 609 additions and 128 deletions

View File

@@ -8,7 +8,11 @@
], ],
"env": { "env": {
"DEFAULT_MINIMUM_TOKENS": "" "DEFAULT_MINIMUM_TOKENS": ""
} },
"alwaysAllow": [
"resolve-library-id",
"get-library-docs"
]
} }
} }
} }

View File

@@ -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" />

View File

@@ -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,
); );
}, },

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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),
),
), ),
); );
} }

View File

@@ -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,
), ),
); );
}, },

View File

@@ -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'),

View File

@@ -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),
),
), ),
); );
}, },

View File

@@ -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),
),
), ),
); );
} }

View File

@@ -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),
),
), ),
); );
} }

View File

@@ -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,
), ),
); );
}, },

View File

@@ -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');
}
}
} }

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: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 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. /// 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. /// 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 todays 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');
}
} }

View File

@@ -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),
),
), ),
); );
} }

View File

@@ -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