feat: Implement pending notifications screen and enhance notification handling

This commit is contained in:
2025-08-26 18:15:35 +02:00
parent 2aec59ec35
commit 53dfd92cec
6 changed files with 457 additions and 14 deletions

View File

@@ -99,6 +99,7 @@ class SupplementProvider with ChangeNotifier {
required int reminderRetryInterval, required int reminderRetryInterval,
required int maxRetryAttempts, required int maxRetryAttempts,
}) async { }) async {
print('📱 🔄 MANUAL CHECK: Persistent reminders called from UI');
await _notificationService.checkPersistentReminders( await _notificationService.checkPersistentReminders(
persistentReminders, persistentReminders,
reminderRetryInterval, 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 @override
void dispose() { void dispose() {
_persistentReminderTimer?.cancel(); _persistentReminderTimer?.cancel();
@@ -363,6 +374,11 @@ class SupplementProvider with ChangeNotifier {
return await _notificationService.getPendingNotifications(); 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 // Debug method to test notification persistence
Future<void> rescheduleAllNotifications() async { Future<void> rescheduleAllNotifications() async {
await _rescheduleAllNotifications(); await _rescheduleAllNotifications();

View File

@@ -33,12 +33,12 @@ class _HomeScreenState extends State<HomeScreen> {
} }
void _startPersistentReminderCheck() { void _startPersistentReminderCheck() {
// Check immediately and then every 10 minutes // Check immediately and then every 3 minutes (faster than any retry interval)
_checkPersistentReminders(); _checkPersistentReminders();
// Set up periodic checking // Set up periodic checking every 3 minutes to ensure we catch all retry intervals
Future.doWhile(() async { Future.doWhile(() async {
await Future.delayed(const Duration(minutes: 10)); await Future.delayed(const Duration(minutes: 3));
if (mounted) { if (mounted) {
await _checkPersistentReminders(); await _checkPersistentReminders();
return true; return true;
@@ -51,14 +51,18 @@ class _HomeScreenState extends State<HomeScreen> {
if (!mounted) return; if (!mounted) return;
try { try {
print('📱 === HOME SCREEN: Checking persistent reminders ===');
final supplementProvider = context.read<SupplementProvider>(); final supplementProvider = context.read<SupplementProvider>();
final settingsProvider = context.read<SettingsProvider>(); final settingsProvider = context.read<SettingsProvider>();
print('📱 Settings: persistent=${settingsProvider.persistentReminders}, interval=${settingsProvider.reminderRetryInterval}, max=${settingsProvider.maxRetryAttempts}');
await supplementProvider.checkPersistentRemindersWithSettings( await supplementProvider.checkPersistentRemindersWithSettings(
persistentReminders: settingsProvider.persistentReminders, persistentReminders: settingsProvider.persistentReminders,
reminderRetryInterval: settingsProvider.reminderRetryInterval, reminderRetryInterval: settingsProvider.reminderRetryInterval,
maxRetryAttempts: settingsProvider.maxRetryAttempts, maxRetryAttempts: settingsProvider.maxRetryAttempts,
); );
print('📱 === HOME SCREEN: Persistent reminder check complete ===');
} catch (e) { } catch (e) {
print('Error checking persistent reminders: $e'); print('Error checking persistent reminders: $e');
} }

View 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';
}
}
}

View File

@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
import '../providers/settings_provider.dart'; import '../providers/settings_provider.dart';
import '../providers/supplement_provider.dart'; import '../providers/supplement_provider.dart';
import '../services/notification_service.dart'; import '../services/notification_service.dart';
import 'pending_notifications_screen.dart';
class SettingsScreen extends StatelessWidget { class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key}); const SettingsScreen({super.key});
@@ -212,6 +213,49 @@ class SettingsScreen extends StatelessWidget {
), ),
), ),
const SizedBox(height: 16), 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 if (Theme.of(context).brightness == Brightness.dark) // Only show in debug mode for now
Card( Card(
child: Padding( child: Padding(

View File

@@ -431,11 +431,13 @@ class DatabaseHelper {
Future<List<Map<String, dynamic>>> getPendingNotifications() async { Future<List<Map<String, dynamic>>> getPendingNotifications() async {
Database db = await database; Database db = await database;
return await db.query( return await db.rawQuery('''
notificationTrackingTable, SELECT nt.*, s.name as supplementName
where: 'status IN (?, ?)', FROM $notificationTrackingTable nt
whereArgs: ['pending', 'retrying'], 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 { Future<void> markNotificationExpired(int notificationId) async {

View File

@@ -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('📱 === HANDLING TAKE ACTION ===');
print('📱 Payload received: $payload'); print('📱 Payload received: $payload');
@@ -182,7 +182,7 @@ class NotificationService {
// Mark notification as taken in database (this will cancel any pending retries) // Mark notification as taken in database (this will cancel any pending retries)
if (notificationId != null) { if (notificationId != null) {
print('📱 Marking notification $notificationId as taken'); print('📱 Marking notification $notificationId as taken');
DatabaseHelper.instance.markNotificationTaken(notificationId); await DatabaseHelper.instance.markNotificationTaken(notificationId);
// Cancel any pending retry notifications for this notification // Cancel any pending retry notifications for this notification
_cancelRetryNotifications(notificationId); _cancelRetryNotifications(notificationId);
@@ -316,16 +316,24 @@ class NotificationService {
final now = DateTime.now(); final now = DateTime.now();
for (final notification in pendingNotifications) { 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 retryCount = notification['retryCount'] as int;
final lastRetryTime = notification['lastRetryTime'] != null final lastRetryTime = notification['lastRetryTime'] != null
? DateTime.parse(notification['lastRetryTime']) ? DateTime.parse(notification['lastRetryTime']).toLocal()
: null; : null;
// Check if notification is overdue // Check if notification is overdue
final timeSinceScheduled = now.difference(scheduledTime).inMinutes; final timeSinceScheduled = now.difference(scheduledTime).inMinutes;
final shouldRetry = timeSinceScheduled >= reminderRetryInterval; 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 // Check if we haven't exceeded max retry attempts
if (retryCount >= maxRetryAttempts) { if (retryCount >= maxRetryAttempts) {
print('📱 Notification ${notification['notificationId']} exceeded max attempts ($maxRetryAttempts)'); print('📱 Notification ${notification['notificationId']} exceeded max attempts ($maxRetryAttempts)');
@@ -342,7 +350,10 @@ class NotificationService {
} }
if (shouldRetry) { if (shouldRetry) {
print('📱 ⚡ SCHEDULING RETRY for notification ${notification['notificationId']}');
await _scheduleRetryNotification(notification, retryCount + 1); await _scheduleRetryNotification(notification, retryCount + 1);
} else {
print('📱 ⏸️ NOT READY FOR RETRY: ${notification['notificationId']}');
} }
} }
} catch (e) { } catch (e) {
@@ -381,13 +392,11 @@ class NotificationService {
'take_supplement', 'take_supplement',
'Take', 'Take',
showsUserInterface: true, showsUserInterface: true,
icon: DrawableResourceAndroidBitmap('@drawable/ic_check'),
), ),
AndroidNotificationAction( AndroidNotificationAction(
'snooze_10', 'snooze_10',
'Snooze 10min', 'Snooze 10min',
showsUserInterface: true, showsUserInterface: true,
icon: DrawableResourceAndroidBitmap('@drawable/ic_snooze'),
), ),
], ],
), ),