mirror of
https://github.com/vleeuwenmenno/supplements.git
synced 2025-09-11 18:29:12 +02:00
feat: Implement pending notifications screen and enhance notification handling
This commit is contained in:
@@ -99,6 +99,7 @@ class SupplementProvider with ChangeNotifier {
|
||||
required int reminderRetryInterval,
|
||||
required int maxRetryAttempts,
|
||||
}) async {
|
||||
print('📱 🔄 MANUAL CHECK: Persistent reminders called from UI');
|
||||
await _notificationService.checkPersistentReminders(
|
||||
persistentReminders,
|
||||
reminderRetryInterval,
|
||||
@@ -106,6 +107,16 @@ class SupplementProvider with ChangeNotifier {
|
||||
);
|
||||
}
|
||||
|
||||
// Add a manual trigger method for testing
|
||||
Future<void> triggerRetryCheck() async {
|
||||
print('📱 🚨 MANUAL TRIGGER: Forcing retry check...');
|
||||
await checkPersistentRemindersWithSettings(
|
||||
persistentReminders: true,
|
||||
reminderRetryInterval: 5, // Force 5 minute interval for testing
|
||||
maxRetryAttempts: 3,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_persistentReminderTimer?.cancel();
|
||||
@@ -363,6 +374,11 @@ class SupplementProvider with ChangeNotifier {
|
||||
return await _notificationService.getPendingNotifications();
|
||||
}
|
||||
|
||||
// Get pending notifications with retry information from database
|
||||
Future<List<Map<String, dynamic>>> getTrackedNotifications() async {
|
||||
return await DatabaseHelper.instance.getPendingNotifications();
|
||||
}
|
||||
|
||||
// Debug method to test notification persistence
|
||||
Future<void> rescheduleAllNotifications() async {
|
||||
await _rescheduleAllNotifications();
|
||||
|
@@ -33,12 +33,12 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
}
|
||||
|
||||
void _startPersistentReminderCheck() {
|
||||
// Check immediately and then every 10 minutes
|
||||
// Check immediately and then every 3 minutes (faster than any retry interval)
|
||||
_checkPersistentReminders();
|
||||
|
||||
// Set up periodic checking
|
||||
// Set up periodic checking every 3 minutes to ensure we catch all retry intervals
|
||||
Future.doWhile(() async {
|
||||
await Future.delayed(const Duration(minutes: 10));
|
||||
await Future.delayed(const Duration(minutes: 3));
|
||||
if (mounted) {
|
||||
await _checkPersistentReminders();
|
||||
return true;
|
||||
@@ -51,14 +51,18 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
if (!mounted) return;
|
||||
|
||||
try {
|
||||
print('📱 === HOME SCREEN: Checking persistent reminders ===');
|
||||
final supplementProvider = context.read<SupplementProvider>();
|
||||
final settingsProvider = context.read<SettingsProvider>();
|
||||
|
||||
print('📱 Settings: persistent=${settingsProvider.persistentReminders}, interval=${settingsProvider.reminderRetryInterval}, max=${settingsProvider.maxRetryAttempts}');
|
||||
|
||||
await supplementProvider.checkPersistentRemindersWithSettings(
|
||||
persistentReminders: settingsProvider.persistentReminders,
|
||||
reminderRetryInterval: settingsProvider.reminderRetryInterval,
|
||||
maxRetryAttempts: settingsProvider.maxRetryAttempts,
|
||||
);
|
||||
print('📱 === HOME SCREEN: Persistent reminder check complete ===');
|
||||
} catch (e) {
|
||||
print('Error checking persistent reminders: $e');
|
||||
}
|
||||
|
368
lib/screens/pending_notifications_screen.dart
Normal file
368
lib/screens/pending_notifications_screen.dart
Normal file
@@ -0,0 +1,368 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import '../services/notification_service.dart';
|
||||
import '../services/database_helper.dart';
|
||||
|
||||
class PendingNotificationsScreen extends StatefulWidget {
|
||||
const PendingNotificationsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<PendingNotificationsScreen> createState() => _PendingNotificationsScreenState();
|
||||
}
|
||||
|
||||
class _PendingNotificationsScreenState extends State<PendingNotificationsScreen> {
|
||||
List<PendingNotificationRequest> _pendingNotifications = [];
|
||||
List<Map<String, dynamic>> _trackedNotifications = [];
|
||||
bool _isLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadNotifications();
|
||||
}
|
||||
|
||||
Future<void> _loadNotifications() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
// Get system pending notifications
|
||||
final notificationService = NotificationService();
|
||||
final systemPending = await notificationService.getPendingNotifications();
|
||||
|
||||
// Get tracked notifications from database (including retries)
|
||||
final trackedNotifications = await DatabaseHelper.instance.getPendingNotifications();
|
||||
|
||||
// Combine and sort by scheduled time (soonest first)
|
||||
final allNotifications = <Map<String, dynamic>>[];
|
||||
|
||||
// Add system notifications
|
||||
for (final notification in systemPending) {
|
||||
allNotifications.add({
|
||||
'id': notification.id,
|
||||
'title': notification.title ?? 'Notification',
|
||||
'body': notification.body ?? '',
|
||||
'scheduledTime': DateTime.now(), // PendingNotificationRequest doesn't have scheduledTime
|
||||
'type': 'system',
|
||||
'isRetry': false,
|
||||
'retryCount': 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Add tracked notifications from database
|
||||
for (final notification in trackedNotifications) {
|
||||
final scheduledTime = DateTime.parse(notification['scheduledTime']).toLocal();
|
||||
allNotifications.add({
|
||||
'id': notification['notificationId'],
|
||||
'title': 'Time for ${notification['supplementName'] ?? 'Supplement'}',
|
||||
'body': 'Take your supplement',
|
||||
'scheduledTime': scheduledTime,
|
||||
'type': 'tracked',
|
||||
'status': notification['status'],
|
||||
'isRetry': notification['status'] == 'retrying',
|
||||
'retryCount': notification['retryCount'] ?? 0,
|
||||
'supplementName': notification['supplementName'],
|
||||
'supplementId': notification['supplementId'],
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by scheduled time (soonest first)
|
||||
allNotifications.sort((a, b) {
|
||||
final timeA = a['scheduledTime'] as DateTime;
|
||||
final timeB = b['scheduledTime'] as DateTime;
|
||||
return timeA.compareTo(timeB);
|
||||
});
|
||||
|
||||
setState(() {
|
||||
_pendingNotifications = systemPending;
|
||||
_trackedNotifications = trackedNotifications;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
print('Error loading notifications: $e');
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Pending Notifications'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: _loadNotifications,
|
||||
tooltip: 'Refresh',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: RefreshIndicator(
|
||||
onRefresh: _loadNotifications,
|
||||
child: _buildNotificationsList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNotificationsList() {
|
||||
if (_pendingNotifications.isEmpty && _trackedNotifications.isEmpty) {
|
||||
return const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.notifications_off,
|
||||
size: 64,
|
||||
color: Colors.grey,
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'No pending notifications',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'All caught up!',
|
||||
style: TextStyle(
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Combine and sort all notifications
|
||||
final allNotifications = <Map<String, dynamic>>[];
|
||||
|
||||
// Add system notifications
|
||||
for (final notification in _pendingNotifications) {
|
||||
allNotifications.add({
|
||||
'id': notification.id,
|
||||
'title': notification.title ?? 'Notification',
|
||||
'body': notification.body ?? '',
|
||||
'scheduledTime': DateTime.now(), // PendingNotificationRequest doesn't have scheduledTime
|
||||
'type': 'system',
|
||||
'isRetry': false,
|
||||
'retryCount': 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Add tracked notifications from database
|
||||
for (final notification in _trackedNotifications) {
|
||||
final scheduledTime = DateTime.parse(notification['scheduledTime']).toLocal();
|
||||
allNotifications.add({
|
||||
'id': notification['notificationId'],
|
||||
'title': 'Time for ${notification['supplementName'] ?? 'Supplement'}',
|
||||
'body': 'Take your supplement',
|
||||
'scheduledTime': scheduledTime,
|
||||
'type': 'tracked',
|
||||
'status': notification['status'],
|
||||
'isRetry': notification['status'] == 'retrying',
|
||||
'retryCount': notification['retryCount'] ?? 0,
|
||||
'supplementName': notification['supplementName'],
|
||||
'supplementId': notification['supplementId'],
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by scheduled time (soonest first)
|
||||
allNotifications.sort((a, b) {
|
||||
final timeA = a['scheduledTime'] as DateTime;
|
||||
final timeB = b['scheduledTime'] as DateTime;
|
||||
return timeA.compareTo(timeB);
|
||||
});
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: allNotifications.length,
|
||||
itemBuilder: (context, index) {
|
||||
final notification = allNotifications[index];
|
||||
return _buildNotificationCard(notification);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNotificationCard(Map<String, dynamic> notification) {
|
||||
final scheduledTime = notification['scheduledTime'] as DateTime;
|
||||
final now = DateTime.now();
|
||||
final isOverdue = scheduledTime.isBefore(now);
|
||||
final isRetry = notification['isRetry'] as bool;
|
||||
final retryCount = notification['retryCount'] as int;
|
||||
final type = notification['type'] as String;
|
||||
|
||||
final timeUntil = scheduledTime.difference(now);
|
||||
String timeText;
|
||||
|
||||
if (isOverdue) {
|
||||
final overdue = now.difference(scheduledTime);
|
||||
if (overdue.inDays > 0) {
|
||||
timeText = 'Overdue by ${overdue.inDays}d ${overdue.inHours % 24}h';
|
||||
} else if (overdue.inHours > 0) {
|
||||
timeText = 'Overdue by ${overdue.inHours}h ${overdue.inMinutes % 60}m';
|
||||
} else {
|
||||
timeText = 'Overdue by ${overdue.inMinutes}m';
|
||||
}
|
||||
} else {
|
||||
if (timeUntil.inDays > 0) {
|
||||
timeText = 'In ${timeUntil.inDays}d ${timeUntil.inHours % 24}h';
|
||||
} else if (timeUntil.inHours > 0) {
|
||||
timeText = 'In ${timeUntil.inHours}h ${timeUntil.inMinutes % 60}m';
|
||||
} else {
|
||||
timeText = 'In ${timeUntil.inMinutes}m';
|
||||
}
|
||||
}
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
notification['title'] ?? 'Notification',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
if (notification['body'] != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
notification['body'],
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
if (isRetry) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.orange.withOpacity(0.5)),
|
||||
),
|
||||
child: Text(
|
||||
'Retry #$retryCount',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.orange.shade700,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: type == 'system'
|
||||
? Colors.blue.withOpacity(0.2)
|
||||
: Colors.green.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: type == 'system'
|
||||
? Colors.blue.withOpacity(0.5)
|
||||
: Colors.green.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
type == 'system' ? 'System' : 'Tracked',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: type == 'system'
|
||||
? Colors.blue.shade700
|
||||
: Colors.green.shade700,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
isOverdue ? Icons.schedule_outlined : Icons.access_time,
|
||||
size: 16,
|
||||
color: isOverdue ? Colors.red : Colors.grey,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${_formatTime(scheduledTime)} • $timeText',
|
||||
style: TextStyle(
|
||||
color: isOverdue ? Colors.red : Colors.grey.shade700,
|
||||
fontWeight: isOverdue ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (type == 'tracked' && notification['supplementName'] != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.medication,
|
||||
size: 16,
|
||||
color: Colors.grey,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
notification['supplementName'],
|
||||
style: TextStyle(
|
||||
color: Colors.grey.shade700,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatTime(DateTime dateTime) {
|
||||
final now = DateTime.now();
|
||||
final today = DateTime(now.year, now.month, now.day);
|
||||
final tomorrow = today.add(const Duration(days: 1));
|
||||
final notificationDate = DateTime(dateTime.year, dateTime.month, dateTime.day);
|
||||
|
||||
String timeStr = '${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}';
|
||||
|
||||
if (notificationDate == today) {
|
||||
return 'Today $timeStr';
|
||||
} else if (notificationDate == tomorrow) {
|
||||
return 'Tomorrow $timeStr';
|
||||
} else {
|
||||
return '${dateTime.day}/${dateTime.month} $timeStr';
|
||||
}
|
||||
}
|
||||
}
|
@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
|
||||
import '../providers/settings_provider.dart';
|
||||
import '../providers/supplement_provider.dart';
|
||||
import '../services/notification_service.dart';
|
||||
import 'pending_notifications_screen.dart';
|
||||
|
||||
class SettingsScreen extends StatelessWidget {
|
||||
const SettingsScreen({super.key});
|
||||
@@ -212,6 +213,49 @@ class SettingsScreen extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.notifications_outlined),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Notifications',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'View and manage pending notifications',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const PendingNotificationsScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.list),
|
||||
label: const Text('View Pending Notifications'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (Theme.of(context).brightness == Brightness.dark) // Only show in debug mode for now
|
||||
Card(
|
||||
child: Padding(
|
||||
|
@@ -431,11 +431,13 @@ class DatabaseHelper {
|
||||
|
||||
Future<List<Map<String, dynamic>>> getPendingNotifications() async {
|
||||
Database db = await database;
|
||||
return await db.query(
|
||||
notificationTrackingTable,
|
||||
where: 'status IN (?, ?)',
|
||||
whereArgs: ['pending', 'retrying'],
|
||||
);
|
||||
return await db.rawQuery('''
|
||||
SELECT nt.*, s.name as supplementName
|
||||
FROM $notificationTrackingTable nt
|
||||
LEFT JOIN $supplementsTable s ON nt.supplementId = s.id
|
||||
WHERE nt.status IN (?, ?)
|
||||
ORDER BY nt.scheduledTime ASC
|
||||
''', ['pending', 'retrying']);
|
||||
}
|
||||
|
||||
Future<void> markNotificationExpired(int notificationId) async {
|
||||
|
@@ -148,7 +148,7 @@ class NotificationService {
|
||||
}
|
||||
}
|
||||
|
||||
void _handleTakeAction(String? payload, int? notificationId) {
|
||||
Future<void> _handleTakeAction(String? payload, int? notificationId) async {
|
||||
print('📱 === HANDLING TAKE ACTION ===');
|
||||
print('📱 Payload received: $payload');
|
||||
|
||||
@@ -182,7 +182,7 @@ class NotificationService {
|
||||
// Mark notification as taken in database (this will cancel any pending retries)
|
||||
if (notificationId != null) {
|
||||
print('📱 Marking notification $notificationId as taken');
|
||||
DatabaseHelper.instance.markNotificationTaken(notificationId);
|
||||
await DatabaseHelper.instance.markNotificationTaken(notificationId);
|
||||
|
||||
// Cancel any pending retry notifications for this notification
|
||||
_cancelRetryNotifications(notificationId);
|
||||
@@ -316,16 +316,24 @@ class NotificationService {
|
||||
final now = DateTime.now();
|
||||
|
||||
for (final notification in pendingNotifications) {
|
||||
final scheduledTime = DateTime.parse(notification['scheduledTime']);
|
||||
final scheduledTime = DateTime.parse(notification['scheduledTime']).toLocal();
|
||||
final retryCount = notification['retryCount'] as int;
|
||||
final lastRetryTime = notification['lastRetryTime'] != null
|
||||
? DateTime.parse(notification['lastRetryTime'])
|
||||
? DateTime.parse(notification['lastRetryTime']).toLocal()
|
||||
: null;
|
||||
|
||||
// Check if notification is overdue
|
||||
final timeSinceScheduled = now.difference(scheduledTime).inMinutes;
|
||||
final shouldRetry = timeSinceScheduled >= reminderRetryInterval;
|
||||
|
||||
print('📱 Checking notification ${notification['notificationId']}:');
|
||||
print('📱 Scheduled: $scheduledTime (local)');
|
||||
print('📱 Now: $now');
|
||||
print('📱 Time since scheduled: $timeSinceScheduled minutes');
|
||||
print('📱 Retry interval: $reminderRetryInterval minutes');
|
||||
print('📱 Should retry: $shouldRetry');
|
||||
print('📱 Retry count: $retryCount / $maxRetryAttempts');
|
||||
|
||||
// Check if we haven't exceeded max retry attempts
|
||||
if (retryCount >= maxRetryAttempts) {
|
||||
print('📱 Notification ${notification['notificationId']} exceeded max attempts ($maxRetryAttempts)');
|
||||
@@ -342,7 +350,10 @@ class NotificationService {
|
||||
}
|
||||
|
||||
if (shouldRetry) {
|
||||
print('📱 ⚡ SCHEDULING RETRY for notification ${notification['notificationId']}');
|
||||
await _scheduleRetryNotification(notification, retryCount + 1);
|
||||
} else {
|
||||
print('📱 ⏸️ NOT READY FOR RETRY: ${notification['notificationId']}');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -381,13 +392,11 @@ class NotificationService {
|
||||
'take_supplement',
|
||||
'Take',
|
||||
showsUserInterface: true,
|
||||
icon: DrawableResourceAndroidBitmap('@drawable/ic_check'),
|
||||
),
|
||||
AndroidNotificationAction(
|
||||
'snooze_10',
|
||||
'Snooze 10min',
|
||||
showsUserInterface: true,
|
||||
icon: DrawableResourceAndroidBitmap('@drawable/ic_snooze'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
Reference in New Issue
Block a user