mirror of
https://github.com/vleeuwenmenno/supplements.git
synced 2025-09-11 18:29:12 +02:00
notification overhaul
This commit is contained in:
@@ -3,10 +3,8 @@ import 'package:provider/provider.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../models/ingredient.dart';
|
||||
import '../models/nutrient.dart';
|
||||
import '../models/supplement.dart';
|
||||
import '../providers/supplement_provider.dart';
|
||||
import '../services/nutrient_data_service.dart';
|
||||
|
||||
// Helper class to manage ingredient text controllers
|
||||
class IngredientController {
|
||||
@@ -53,12 +51,8 @@ class _AddSupplementScreenState extends State<AddSupplementScreen> {
|
||||
final _numberOfUnitsController = TextEditingController();
|
||||
final _notesController = TextEditingController();
|
||||
|
||||
// Nutrient data for autocomplete
|
||||
final NutrientDataService _nutrientDataService = NutrientDataService();
|
||||
List<Nutrient> _nutrients = [];
|
||||
|
||||
// Multi-ingredient support with persistent controllers
|
||||
List<IngredientController> _ingredientControllers = [];
|
||||
final _ingredientControllers = [];
|
||||
|
||||
String _selectedUnitType = 'capsules';
|
||||
int _frequencyPerDay = 1;
|
||||
|
@@ -1,11 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../providers/supplement_provider.dart';
|
||||
import '../providers/settings_provider.dart';
|
||||
import 'supplements_list_screen.dart';
|
||||
import 'history_screen.dart';
|
||||
import 'add_supplement_screen.dart';
|
||||
import 'history_screen.dart';
|
||||
import 'settings_screen.dart';
|
||||
import 'supplements_list_screen.dart';
|
||||
|
||||
class HomeScreen extends StatefulWidget {
|
||||
const HomeScreen({super.key});
|
||||
@@ -28,45 +28,10 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
context.read<SupplementProvider>().initialize();
|
||||
_startPersistentReminderCheck();
|
||||
});
|
||||
}
|
||||
|
||||
void _startPersistentReminderCheck() {
|
||||
// Check immediately and then every 3 minutes (faster than any retry interval)
|
||||
_checkPersistentReminders();
|
||||
|
||||
// Set up periodic checking every 3 minutes to ensure we catch all retry intervals
|
||||
Future.doWhile(() async {
|
||||
await Future.delayed(const Duration(minutes: 3));
|
||||
if (mounted) {
|
||||
await _checkPersistentReminders();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _checkPersistentReminders() async {
|
||||
if (!mounted) return;
|
||||
|
||||
try {
|
||||
print('SupplementsLog: 📱 === HOME SCREEN: Checking persistent reminders ===');
|
||||
final supplementProvider = context.read<SupplementProvider>();
|
||||
final settingsProvider = context.read<SettingsProvider>();
|
||||
|
||||
print('SupplementsLog: 📱 Settings: persistent=${settingsProvider.persistentReminders}, interval=${settingsProvider.reminderRetryInterval}, max=${settingsProvider.maxRetryAttempts}');
|
||||
|
||||
await supplementProvider.checkPersistentRemindersWithSettings(
|
||||
persistentReminders: settingsProvider.persistentReminders,
|
||||
reminderRetryInterval: settingsProvider.reminderRetryInterval,
|
||||
maxRetryAttempts: settingsProvider.maxRetryAttempts,
|
||||
);
|
||||
print('SupplementsLog: 📱 === HOME SCREEN: Persistent reminder check complete ===');
|
||||
} catch (e) {
|
||||
print('SupplementsLog: Error checking persistent reminders: $e');
|
||||
}
|
||||
}
|
||||
// Persistent reminder checks removed
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
@@ -1,823 +1,58 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../services/notification_service.dart';
|
||||
import '../services/database_helper.dart';
|
||||
import '../providers/settings_provider.dart';
|
||||
import '../providers/supplement_provider.dart';
|
||||
|
||||
class PendingNotificationsScreen extends StatefulWidget {
|
||||
/// Simple placeholder screen for pending notifications.
|
||||
/// In the simplified notification setup, we no longer track or retry notifications,
|
||||
/// so there is no "pending notifications" list to display.
|
||||
///
|
||||
/// Keep this screen minimal to avoid heavy logic and dependencies.
|
||||
class PendingNotificationsScreen extends StatelessWidget {
|
||||
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 settings for retry interval calculation
|
||||
final settingsProvider = Provider.of<SettingsProvider>(context, listen: false);
|
||||
final reminderRetryInterval = settingsProvider.reminderRetryInterval;
|
||||
final maxRetryAttempts = settingsProvider.maxRetryAttempts;
|
||||
|
||||
// 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();
|
||||
|
||||
// Create a more intelligent matching system
|
||||
final allNotifications = <Map<String, dynamic>>[];
|
||||
final matchedSystemIds = <int>{};
|
||||
|
||||
// First, try to match tracked notifications with system notifications
|
||||
for (final trackedNotification in trackedNotifications) {
|
||||
final scheduledTime = DateTime.parse(trackedNotification['scheduledTime']).toLocal();
|
||||
final lastRetryTime = trackedNotification['lastRetryTime'] != null
|
||||
? DateTime.parse(trackedNotification['lastRetryTime']).toLocal()
|
||||
: null;
|
||||
final retryCount = trackedNotification['retryCount'] ?? 0;
|
||||
final isRetrying = trackedNotification['status'] == 'retrying';
|
||||
final notificationId = trackedNotification['notificationId'] as int;
|
||||
|
||||
// Try to find matching system notification(s)
|
||||
final matchingSystemNotifications = systemPending.where((systemNotification) {
|
||||
return _isMatchingNotification(systemNotification, trackedNotification, retryCount);
|
||||
}).toList();
|
||||
|
||||
// Calculate next retry time if this is a retry notification
|
||||
DateTime? nextRetryTime;
|
||||
bool hasReachedMaxRetries = retryCount >= maxRetryAttempts;
|
||||
|
||||
if (isRetrying && !hasReachedMaxRetries) {
|
||||
if (lastRetryTime != null) {
|
||||
// Next retry is based on last retry time + interval
|
||||
nextRetryTime = lastRetryTime.add(Duration(minutes: reminderRetryInterval));
|
||||
} else {
|
||||
// First retry is based on original scheduled time + interval
|
||||
nextRetryTime = scheduledTime.add(Duration(minutes: reminderRetryInterval));
|
||||
}
|
||||
}
|
||||
|
||||
// Create the notification entry
|
||||
final notificationEntry = {
|
||||
'id': notificationId,
|
||||
'title': 'Time for ${trackedNotification['supplementName'] ?? 'Supplement'}',
|
||||
'body': 'Take your supplement',
|
||||
'scheduledTime': scheduledTime,
|
||||
'nextRetryTime': nextRetryTime,
|
||||
'lastRetryTime': lastRetryTime,
|
||||
'type': matchingSystemNotifications.isNotEmpty ? 'matched' : 'tracked_only',
|
||||
'status': trackedNotification['status'],
|
||||
'isRetry': isRetrying,
|
||||
'retryCount': retryCount,
|
||||
'hasReachedMaxRetries': hasReachedMaxRetries,
|
||||
'maxRetryAttempts': maxRetryAttempts,
|
||||
'supplementName': trackedNotification['supplementName'],
|
||||
'supplementId': trackedNotification['supplementId'],
|
||||
'systemNotificationCount': matchingSystemNotifications.length,
|
||||
};
|
||||
|
||||
allNotifications.add(notificationEntry);
|
||||
|
||||
// Mark these system notifications as matched
|
||||
for (final systemNotification in matchingSystemNotifications) {
|
||||
matchedSystemIds.add(systemNotification.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Add unmatched system notifications
|
||||
for (final systemNotification in systemPending) {
|
||||
if (!matchedSystemIds.contains(systemNotification.id)) {
|
||||
allNotifications.add({
|
||||
'id': systemNotification.id,
|
||||
'title': systemNotification.title ?? 'System Notification',
|
||||
'body': systemNotification.body ?? '',
|
||||
'scheduledTime': DateTime.now(), // PendingNotificationRequest doesn't have scheduledTime
|
||||
'type': 'system_only',
|
||||
'isRetry': false,
|
||||
'retryCount': 0,
|
||||
'hasReachedMaxRetries': false,
|
||||
'maxRetryAttempts': maxRetryAttempts,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 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('SupplementsLog: Error loading notifications: $e');
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showCleanupDialog() async {
|
||||
final result = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Cleanup Old Notifications'),
|
||||
content: const Text(
|
||||
'This will help clean up old or duplicate notifications:\n\n'
|
||||
'• Clear stale system notifications\n'
|
||||
'• Remove very old tracked notifications (>24h overdue)\n'
|
||||
'• Reschedule fresh notifications for active supplements\n\n'
|
||||
'Choose an option:',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop('cancel'),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop('clear_old'),
|
||||
child: const Text('Clear Old Only'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop('clear_all'),
|
||||
child: const Text('Clear All & Reschedule'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (result != null && result != 'cancel') {
|
||||
await _performCleanup(result);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _performCleanup(String action) async {
|
||||
try {
|
||||
// Show loading indicator
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Cleaning up notifications...'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final notificationService = NotificationService();
|
||||
|
||||
if (action == 'clear_all') {
|
||||
// Clear all notifications and reschedule fresh ones
|
||||
await notificationService.cancelAllReminders();
|
||||
await DatabaseHelper.instance.cleanupOldNotificationTracking();
|
||||
|
||||
// Reschedule notifications for all active supplements
|
||||
final supplementProvider = Provider.of<SupplementProvider>(context, listen: false);
|
||||
await supplementProvider.rescheduleAllNotifications();
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('All notifications cleared and rescheduled!'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
} else if (action == 'clear_old') {
|
||||
// Clear only very old notifications (>24 hours overdue)
|
||||
await _clearOldNotifications();
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Old notifications cleared!'),
|
||||
backgroundColor: Colors.orange,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh the list
|
||||
await _loadNotifications();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Error during cleanup: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _clearOldNotifications() async {
|
||||
final now = DateTime.now();
|
||||
final cutoff = now.subtract(const Duration(hours: 24));
|
||||
|
||||
// Clear very old tracked notifications
|
||||
final db = await DatabaseHelper.instance.database;
|
||||
await db.delete(
|
||||
'notification_tracking',
|
||||
where: 'scheduledTime < ? AND status IN (?, ?)',
|
||||
whereArgs: [cutoff.toIso8601String(), 'pending', 'retrying'],
|
||||
);
|
||||
|
||||
// Note: We can't selectively clear system notifications as we don't have their scheduled times
|
||||
// This is a limitation of the flutter_local_notifications plugin
|
||||
}
|
||||
|
||||
bool _isMatchingNotification(PendingNotificationRequest systemNotification, Map<String, dynamic> trackedNotification, int retryCount) {
|
||||
final trackedId = trackedNotification['notificationId'] as int;
|
||||
final supplementId = trackedNotification['supplementId'] as int;
|
||||
|
||||
// Check for exact ID match (original notification)
|
||||
if (systemNotification.id == trackedId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for retry notification IDs (200000 + original_id * 10 + retry_attempt)
|
||||
for (int attempt = 1; attempt <= retryCount; attempt++) {
|
||||
final retryId = 200000 + (trackedId * 10) + attempt;
|
||||
if (systemNotification.id == retryId) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for snooze notification IDs (supplementId * 1000 + minutes)
|
||||
// Common snooze intervals: 5, 10, 15, 30 minutes
|
||||
final snoozeIds = [5, 10, 15, 30].map((minutes) => supplementId * 1000 + minutes);
|
||||
if (snoozeIds.contains(systemNotification.id)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if it's within the supplement's notification ID range (supplementId * 100 + reminderIndex)
|
||||
final baseId = supplementId * 100;
|
||||
if (systemNotification.id >= baseId && systemNotification.id < baseId + 10) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
Color _getTypeColor(String type) {
|
||||
switch (type) {
|
||||
case 'matched':
|
||||
return Colors.green;
|
||||
case 'tracked_only':
|
||||
return Colors.orange;
|
||||
case 'system_only':
|
||||
return Colors.blue;
|
||||
default:
|
||||
return Colors.grey;
|
||||
}
|
||||
}
|
||||
|
||||
Color _getTypeDarkColor(String type) {
|
||||
switch (type) {
|
||||
case 'matched':
|
||||
return Colors.green.shade700;
|
||||
case 'tracked_only':
|
||||
return Colors.orange.shade700;
|
||||
case 'system_only':
|
||||
return Colors.blue.shade700;
|
||||
default:
|
||||
return Colors.grey.shade700;
|
||||
}
|
||||
}
|
||||
|
||||
String _getTypeLabel(String type, Map<String, dynamic> notification) {
|
||||
final systemCount = notification['systemNotificationCount'] as int? ?? 0;
|
||||
|
||||
switch (type) {
|
||||
case 'matched':
|
||||
return systemCount > 1 ? 'Matched' : 'Synced';
|
||||
case 'tracked_only':
|
||||
return 'Tracking Only';
|
||||
case 'system_only':
|
||||
return 'System Only';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Pending Notifications'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.cleaning_services),
|
||||
onPressed: _showCleanupDialog,
|
||||
tooltip: 'Cleanup old notifications',
|
||||
),
|
||||
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,
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.notifications_none,
|
||||
size: 72,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'All caught up!',
|
||||
style: TextStyle(
|
||||
color: Colors.grey,
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No pending notifications UI in simple mode',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: Colors.grey.shade800,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Get settings for retry interval calculation
|
||||
final settingsProvider = Provider.of<SettingsProvider>(context, listen: false);
|
||||
final reminderRetryInterval = settingsProvider.reminderRetryInterval;
|
||||
final maxRetryAttempts = settingsProvider.maxRetryAttempts;
|
||||
|
||||
// Create a more intelligent matching system
|
||||
final allNotifications = <Map<String, dynamic>>[];
|
||||
final matchedSystemIds = <int>{};
|
||||
|
||||
// First, try to match tracked notifications with system notifications
|
||||
for (final trackedNotification in _trackedNotifications) {
|
||||
final scheduledTime = DateTime.parse(trackedNotification['scheduledTime']).toLocal();
|
||||
final lastRetryTime = trackedNotification['lastRetryTime'] != null
|
||||
? DateTime.parse(trackedNotification['lastRetryTime']).toLocal()
|
||||
: null;
|
||||
final retryCount = trackedNotification['retryCount'] ?? 0;
|
||||
final isRetrying = trackedNotification['status'] == 'retrying';
|
||||
final notificationId = trackedNotification['notificationId'] as int;
|
||||
|
||||
// Try to find matching system notification(s)
|
||||
final matchingSystemNotifications = _pendingNotifications.where((systemNotification) {
|
||||
return _isMatchingNotification(systemNotification, trackedNotification, retryCount);
|
||||
}).toList();
|
||||
|
||||
// Calculate next retry time if this is a retry notification
|
||||
DateTime? nextRetryTime;
|
||||
bool hasReachedMaxRetries = retryCount >= maxRetryAttempts;
|
||||
|
||||
if (isRetrying && !hasReachedMaxRetries) {
|
||||
if (lastRetryTime != null) {
|
||||
// Next retry is based on last retry time + interval
|
||||
nextRetryTime = lastRetryTime.add(Duration(minutes: reminderRetryInterval));
|
||||
} else {
|
||||
// First retry is based on original scheduled time + interval
|
||||
nextRetryTime = scheduledTime.add(Duration(minutes: reminderRetryInterval));
|
||||
}
|
||||
}
|
||||
|
||||
// Create the notification entry
|
||||
final notificationEntry = {
|
||||
'id': notificationId,
|
||||
'title': 'Time for ${trackedNotification['supplementName'] ?? 'Supplement'}',
|
||||
'body': 'Take your supplement',
|
||||
'scheduledTime': scheduledTime,
|
||||
'nextRetryTime': nextRetryTime,
|
||||
'lastRetryTime': lastRetryTime,
|
||||
'type': matchingSystemNotifications.isNotEmpty ? 'matched' : 'tracked_only',
|
||||
'status': trackedNotification['status'],
|
||||
'isRetry': isRetrying,
|
||||
'retryCount': retryCount,
|
||||
'hasReachedMaxRetries': hasReachedMaxRetries,
|
||||
'maxRetryAttempts': maxRetryAttempts,
|
||||
'supplementName': trackedNotification['supplementName'],
|
||||
'supplementId': trackedNotification['supplementId'],
|
||||
'systemNotificationCount': matchingSystemNotifications.length,
|
||||
};
|
||||
|
||||
allNotifications.add(notificationEntry);
|
||||
|
||||
// Mark these system notifications as matched
|
||||
for (final systemNotification in matchingSystemNotifications) {
|
||||
matchedSystemIds.add(systemNotification.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Add unmatched system notifications
|
||||
for (final systemNotification in _pendingNotifications) {
|
||||
if (!matchedSystemIds.contains(systemNotification.id)) {
|
||||
allNotifications.add({
|
||||
'id': systemNotification.id,
|
||||
'title': systemNotification.title ?? 'System Notification',
|
||||
'body': systemNotification.body ?? '',
|
||||
'scheduledTime': DateTime.now(), // PendingNotificationRequest doesn't have scheduledTime
|
||||
'type': 'system_only',
|
||||
'isRetry': false,
|
||||
'retryCount': 0,
|
||||
'hasReachedMaxRetries': false,
|
||||
'maxRetryAttempts': maxRetryAttempts,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 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 nextRetryTime = notification['nextRetryTime'] as DateTime?;
|
||||
final lastRetryTime = notification['lastRetryTime'] as DateTime?;
|
||||
final now = DateTime.now();
|
||||
final isOverdue = scheduledTime.isBefore(now);
|
||||
final isRetry = notification['isRetry'] as bool;
|
||||
final retryCount = notification['retryCount'] as int;
|
||||
final hasReachedMaxRetries = notification['hasReachedMaxRetries'] as bool? ?? false;
|
||||
final maxRetryAttempts = notification['maxRetryAttempts'] as int? ?? 3;
|
||||
final type = notification['type'] as String;
|
||||
|
||||
// Determine which time to show and calculate time text
|
||||
DateTime displayTime = scheduledTime;
|
||||
String timePrefix = '';
|
||||
bool showAsNextRetry = false;
|
||||
String timeText = '';
|
||||
|
||||
if (isRetry && nextRetryTime != null && !hasReachedMaxRetries) {
|
||||
// For retry notifications that haven't reached max retries, show next retry time
|
||||
final timeDifference = nextRetryTime.difference(now);
|
||||
if (timeDifference.inSeconds.abs() < 30) {
|
||||
// If within 30 seconds, show "due now"
|
||||
displayTime = nextRetryTime;
|
||||
timePrefix = 'Next retry: ';
|
||||
showAsNextRetry = true;
|
||||
timeText = 'due now';
|
||||
} else if (nextRetryTime.isAfter(now)) {
|
||||
displayTime = nextRetryTime;
|
||||
timePrefix = 'Next retry: ';
|
||||
showAsNextRetry = true;
|
||||
} else {
|
||||
// If next retry time has passed, show it's overdue for retry
|
||||
displayTime = nextRetryTime;
|
||||
timePrefix = 'Retry overdue: ';
|
||||
showAsNextRetry = true;
|
||||
}
|
||||
} else if (isRetry && hasReachedMaxRetries) {
|
||||
// For notifications that have reached max retries, don't show next retry time
|
||||
showAsNextRetry = false;
|
||||
}
|
||||
|
||||
// Calculate time text if not already set
|
||||
if (timeText.isEmpty) {
|
||||
final isDisplayTimeOverdue = displayTime.isBefore(now);
|
||||
final timeUntil = displayTime.difference(now);
|
||||
|
||||
if (isDisplayTimeOverdue) {
|
||||
final overdue = now.difference(displayTime);
|
||||
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 if (overdue.inMinutes > 0) {
|
||||
timeText = 'Overdue by ${overdue.inMinutes}m';
|
||||
} else {
|
||||
timeText = 'Overdue by ${overdue.inSeconds}s';
|
||||
}
|
||||
} 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 if (timeUntil.inMinutes > 0) {
|
||||
timeText = 'In ${timeUntil.inMinutes}m';
|
||||
} else {
|
||||
timeText = 'In ${timeUntil.inSeconds}s';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine if display time is overdue (needed for icon colors later)
|
||||
final isDisplayTimeOverdue = displayTime.isBefore(now);
|
||||
|
||||
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: hasReachedMaxRetries
|
||||
? Colors.red.withOpacity(0.2)
|
||||
: Colors.orange.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: hasReachedMaxRetries
|
||||
? Colors.red.withOpacity(0.5)
|
||||
: Colors.orange.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
hasReachedMaxRetries
|
||||
? 'Max retries ($retryCount/$maxRetryAttempts)'
|
||||
: 'Retry #$retryCount',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: hasReachedMaxRetries
|
||||
? Colors.red.shade700
|
||||
: Colors.orange.shade700,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: _getTypeColor(type).withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: _getTypeColor(type).withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
_getTypeLabel(type, notification),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: _getTypeDarkColor(type),
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
if (type == 'matched' && (notification['systemNotificationCount'] as int? ?? 0) > 1) ...[
|
||||
const SizedBox(width: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.8),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'${notification['systemNotificationCount']}',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: _getTypeDarkColor(type),
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Show original scheduled time
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
isOverdue ? Icons.schedule_outlined : Icons.access_time,
|
||||
size: 16,
|
||||
color: isOverdue ? Colors.red : Colors.grey,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Scheduled: ${_formatTime(scheduledTime)}',
|
||||
style: TextStyle(
|
||||
color: isOverdue ? Colors.red : Colors.grey.shade700,
|
||||
fontWeight: isOverdue ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
if (isOverdue) ...[
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'(${_formatOverdueTime(scheduledTime)})',
|
||||
style: TextStyle(
|
||||
color: Colors.red,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
// Show next retry time if applicable
|
||||
if (showAsNextRetry && nextRetryTime != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
isDisplayTimeOverdue ? Icons.warning_outlined : Icons.refresh,
|
||||
size: 16,
|
||||
color: isDisplayTimeOverdue ? Colors.red : Colors.orange,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${timePrefix}${_formatTime(displayTime)} • $timeText',
|
||||
style: TextStyle(
|
||||
color: isDisplayTimeOverdue ? Colors.red : Colors.orange.shade700,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
// Show max retries reached message
|
||||
if (isRetry && hasReachedMaxRetries) ...[
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.block,
|
||||
size: 16,
|
||||
color: Colors.red,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Maximum retry attempts reached. No more reminders will be sent.',
|
||||
style: TextStyle(
|
||||
color: Colors.red.shade700,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
// Show last retry time if available
|
||||
if (isRetry && lastRetryTime != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.history,
|
||||
size: 16,
|
||||
color: Colors.grey,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Last retry: ${_formatTime(lastRetryTime)}',
|
||||
style: TextStyle(
|
||||
color: Colors.grey.shade600,
|
||||
fontSize: 12,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
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,
|
||||
Text(
|
||||
'Notifications are now scheduled daily at the selected times '
|
||||
'without retries or tracking.',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => Navigator.of(context).maybePop(),
|
||||
icon: const Icon(Icons.close),
|
||||
label: const Text('Close'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
||||
String _formatOverdueTime(DateTime scheduledTime) {
|
||||
final now = DateTime.now();
|
||||
final overdue = now.difference(scheduledTime);
|
||||
|
||||
if (overdue.inDays > 0) {
|
||||
return '${overdue.inDays}d ${overdue.inHours % 24}h overdue';
|
||||
} else if (overdue.inHours > 0) {
|
||||
return '${overdue.inHours}h ${overdue.inMinutes % 60}m overdue';
|
||||
} else {
|
||||
return '${overdue.inMinutes}m overdue';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../providers/settings_provider.dart';
|
||||
import 'pending_notifications_screen.dart';
|
||||
import 'profile_setup_screen.dart';
|
||||
import 'simple_sync_settings_screen.dart';
|
||||
|
||||
@@ -101,101 +100,7 @@ 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: [
|
||||
Icon(Icons.notifications_active, color: Colors.blue),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Reminders',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Configure reminders and how often they are retried when ignored',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SwitchListTile(
|
||||
title: const Text('Enable Persistent Reminders'),
|
||||
subtitle: const Text('Resend notifications if ignored after a specific time'),
|
||||
value: settingsProvider.persistentReminders,
|
||||
onChanged: (value) {
|
||||
settingsProvider.setPersistentReminders(value);
|
||||
},
|
||||
),
|
||||
if (settingsProvider.persistentReminders) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Retry Interval',
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SegmentedButton<int>(
|
||||
segments: const [
|
||||
ButtonSegment(value: 5, label: Text('5 min')),
|
||||
ButtonSegment(value: 10, label: Text('10 min')),
|
||||
ButtonSegment(value: 15, label: Text('15 min')),
|
||||
ButtonSegment(value: 30, label: Text('30 min')),
|
||||
],
|
||||
selected: {settingsProvider.reminderRetryInterval},
|
||||
onSelectionChanged: (values) {
|
||||
settingsProvider.setReminderRetryInterval(values.first);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Maximum Retry Attempts',
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SegmentedButton<int>(
|
||||
segments: const [
|
||||
ButtonSegment(value: 1, label: Text('1')),
|
||||
ButtonSegment(value: 2, label: Text('2')),
|
||||
ButtonSegment(value: 3, label: Text('3')),
|
||||
ButtonSegment(value: 4, label: Text('4')),
|
||||
ButtonSegment(value: 5, label: Text('5')),
|
||||
],
|
||||
selected: {settingsProvider.maxRetryAttempts},
|
||||
onSelectionChanged: (values) {
|
||||
settingsProvider.setMaxRetryAttempts(values.first);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Notification Actions',
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: 320,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const PendingNotificationsScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.list),
|
||||
label: const Text('View Pending Notifications'),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// Reminders settings removed
|
||||
const SizedBox(height: 16),
|
||||
Card(
|
||||
child: Padding(
|
||||
|
@@ -1,16 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:supplements/widgets/info_chip.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../models/ingredient.dart';
|
||||
import '../models/supplement.dart';
|
||||
import '../providers/settings_provider.dart';
|
||||
import '../providers/simple_sync_provider.dart';
|
||||
import '../providers/supplement_provider.dart';
|
||||
import '../services/database_sync_service.dart';
|
||||
import '../services/rda_service.dart';
|
||||
import '../widgets/supplement_card.dart';
|
||||
import '../widgets/dialogs/take_supplement_dialog.dart';
|
||||
import 'add_supplement_screen.dart';
|
||||
import 'archived_supplements_screen.dart';
|
||||
|
||||
@@ -117,162 +114,12 @@ class SupplementsListScreen extends StatelessWidget {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
// Daily RDA overview header
|
||||
FutureBuilder<Map<String, Map<String, dynamic>>>(
|
||||
future: (() async {
|
||||
if (provider.todayIntakes.isEmpty) return <String, Map<String, dynamic>>{};
|
||||
final dailyItems = <Ingredient>[];
|
||||
for (final intake in provider.todayIntakes) {
|
||||
final supId = intake['supplement_id'] as int;
|
||||
final unitsRaw = intake['unitsTaken'];
|
||||
final double units = unitsRaw is int ? unitsRaw.toDouble() : (unitsRaw as double? ?? 1.0);
|
||||
final matching = provider.supplements.where((s) => s.id == supId);
|
||||
if (matching.isEmpty) continue;
|
||||
final sup = matching.first;
|
||||
for (final ing in sup.ingredients) {
|
||||
dailyItems.add(ing.copyWith(amount: ing.amount * units));
|
||||
}
|
||||
}
|
||||
if (dailyItems.isEmpty) return <String, Map<String, dynamic>>{};
|
||||
final service = RdaService();
|
||||
final agg = await service.aggregateDailyIntake(
|
||||
dailyItems,
|
||||
dateOfBirth: settingsProvider.dateOfBirth,
|
||||
gender: settingsProvider.gender,
|
||||
);
|
||||
// Convert to plain map for UI without depending on service types
|
||||
final result = <String, Map<String, dynamic>>{};
|
||||
agg.forEach((key, value) {
|
||||
final v = value; // dynamic
|
||||
result[key] = {
|
||||
'unitLabel': v.unitLabel,
|
||||
'rdaValue': v.rdaValue,
|
||||
'rdaValueMin': v.rdaValueMin,
|
||||
'rdaValueMax': v.rdaValueMax,
|
||||
'ulValue': v.ulValue,
|
||||
'total': v.totalAmountInRdaUnit,
|
||||
'pctRda': v.percentOfRda,
|
||||
'pctUl': v.percentOfUl,
|
||||
'lifeStage': v.matchedLifeStageLabel,
|
||||
'lifeStageDescription': v.matchedLifeStageDescription,
|
||||
'rdaType': v.rdaType,
|
||||
'note': v.note,
|
||||
'nutrientUl': v.nutrientUl != null
|
||||
? {
|
||||
'value': v.nutrientUl!.value,
|
||||
'unit': v.nutrientUl!.unit,
|
||||
'duration': v.nutrientUl!.duration,
|
||||
'note': v.nutrientUl!.note,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
});
|
||||
return result;
|
||||
})(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState != ConnectionState.done) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final data = snapshot.data;
|
||||
if (data == null || data.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
color: Theme.of(context).colorScheme.primaryContainer.withOpacity(0.25),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.health_and_safety, size: 18, color: Theme.of(context).colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
"Today's intake ",
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
Text(
|
||||
"(Vs Recommended Dietary Allowance)",
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
tooltip: 'Sources & disclaimer',
|
||||
icon: const Icon(Icons.info_outline, size: 18),
|
||||
onPressed: () => _showRdaSourcesSheet(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: data.entries.map((e) {
|
||||
final v = e.value;
|
||||
final double pct = (v['pctRda'] as double?) ?? 0.0;
|
||||
final double? pctUl = v['pctUl'] as double?;
|
||||
final pretty = e.key.split('_').map((w) => w.isEmpty ? w : '${w[0].toUpperCase()}${w.substring(1)}').join(' ');
|
||||
Color bg = Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.5);
|
||||
if (pctUl != null && pctUl > 100.0) {
|
||||
bg = (pctUl <= 110.0) ? Colors.amber.withOpacity(0.25) : Colors.red.withOpacity(0.25);
|
||||
} else if (pct >= 100.0) {
|
||||
bg = Colors.green.withOpacity(0.25);
|
||||
}
|
||||
final color = Theme.of(context).colorScheme.onSurfaceVariant;
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
_showRdaDetailsSheet(context, pretty, v);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Theme.of(context).colorScheme.outline.withOpacity(0.3)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
pretty,
|
||||
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: color),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'${pct.toStringAsFixed(pct >= 10 ? 0 : 1)}%',
|
||||
style: TextStyle(fontSize: 12, color: color),
|
||||
),
|
||||
if (pctUl != null) ...[
|
||||
const SizedBox(width: 6),
|
||||
Icon(
|
||||
pctUl > 100.0 ? Icons.warning_amber : Icons.shield_outlined,
|
||||
size: 14,
|
||||
color: pctUl > 100.0
|
||||
? (pctUl <= 110.0 ? Colors.amber : Colors.red)
|
||||
: color,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (groupedSupplements['morning']!.isNotEmpty) ...[
|
||||
_buildSectionHeader('Morning (${settingsProvider.morningRange})', Icons.wb_sunny, Colors.orange, groupedSupplements['morning']!.length),
|
||||
...groupedSupplements['morning']!.map((supplement) =>
|
||||
SupplementCard(
|
||||
supplement: supplement,
|
||||
onTake: () => _showTakeDialog(context, supplement),
|
||||
onTake: () => showTakeSupplementDialog(context, supplement),
|
||||
onEdit: () => _editSupplement(context, supplement),
|
||||
onDelete: () => _deleteSupplement(context, supplement),
|
||||
onArchive: () => _archiveSupplement(context, supplement),
|
||||
@@ -287,7 +134,7 @@ class SupplementsListScreen extends StatelessWidget {
|
||||
...groupedSupplements['afternoon']!.map((supplement) =>
|
||||
SupplementCard(
|
||||
supplement: supplement,
|
||||
onTake: () => _showTakeDialog(context, supplement),
|
||||
onTake: () => showTakeSupplementDialog(context, supplement),
|
||||
onEdit: () => _editSupplement(context, supplement),
|
||||
onDelete: () => _deleteSupplement(context, supplement),
|
||||
onArchive: () => _archiveSupplement(context, supplement),
|
||||
@@ -302,7 +149,7 @@ class SupplementsListScreen extends StatelessWidget {
|
||||
...groupedSupplements['evening']!.map((supplement) =>
|
||||
SupplementCard(
|
||||
supplement: supplement,
|
||||
onTake: () => _showTakeDialog(context, supplement),
|
||||
onTake: () => showTakeSupplementDialog(context, supplement),
|
||||
onEdit: () => _editSupplement(context, supplement),
|
||||
onDelete: () => _deleteSupplement(context, supplement),
|
||||
onArchive: () => _archiveSupplement(context, supplement),
|
||||
@@ -317,7 +164,7 @@ class SupplementsListScreen extends StatelessWidget {
|
||||
...groupedSupplements['night']!.map((supplement) =>
|
||||
SupplementCard(
|
||||
supplement: supplement,
|
||||
onTake: () => _showTakeDialog(context, supplement),
|
||||
onTake: () => showTakeSupplementDialog(context, supplement),
|
||||
onEdit: () => _editSupplement(context, supplement),
|
||||
onDelete: () => _deleteSupplement(context, supplement),
|
||||
onArchive: () => _archiveSupplement(context, supplement),
|
||||
@@ -332,7 +179,7 @@ class SupplementsListScreen extends StatelessWidget {
|
||||
...groupedSupplements['anytime']!.map((supplement) =>
|
||||
SupplementCard(
|
||||
supplement: supplement,
|
||||
onTake: () => _showTakeDialog(context, supplement),
|
||||
onTake: () => showTakeSupplementDialog(context, supplement),
|
||||
onEdit: () => _editSupplement(context, supplement),
|
||||
onDelete: () => _deleteSupplement(context, supplement),
|
||||
onArchive: () => _archiveSupplement(context, supplement),
|
||||
@@ -433,248 +280,6 @@ class SupplementsListScreen extends StatelessWidget {
|
||||
return grouped;
|
||||
}
|
||||
|
||||
void _showRdaSourcesSheet(BuildContext context) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Dietary reference sources',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Source: Health Canada Dietary Reference Intakes. Values are matched by your age and sex. Some ULs (e.g., magnesium) apply to supplemental intake only.',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.open_in_new, size: 18),
|
||||
title: const Text(
|
||||
'Vitamins reference values',
|
||||
style: TextStyle(fontSize: 13),
|
||||
),
|
||||
subtitle: const Text(
|
||||
'canada.ca • reference-values-vitamins',
|
||||
style: TextStyle(fontSize: 11),
|
||||
),
|
||||
onTap: () => _launchUrl('https://www.canada.ca/en/health-canada/services/food-nutrition/healthy-eating/dietary-reference-intakes/tables/reference-values-vitamins.html'),
|
||||
),
|
||||
ListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.open_in_new, size: 18),
|
||||
title: const Text(
|
||||
'Elements (minerals) reference values',
|
||||
style: TextStyle(fontSize: 13),
|
||||
),
|
||||
subtitle: const Text(
|
||||
'canada.ca • reference-values-elements',
|
||||
style: TextStyle(fontSize: 11),
|
||||
),
|
||||
onTap: () => _launchUrl('https://www.canada.ca/en/health-canada/services/food-nutrition/healthy-eating/dietary-reference-intakes/tables/reference-values-elements.html'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Disclaimer: Informational only, some of the details in this app are parsed using AI, and may not be accurate. Always consult a healthcare professional for personalized advice.',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _launchUrl(String url) async {
|
||||
final uri = Uri.parse(url);
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
|
||||
void _showRdaDetailsSheet(BuildContext context, String nutrientPretty, Map<String, dynamic> data) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
isScrollControlled: false,
|
||||
builder: (context) {
|
||||
final unitLabel = (data['unitLabel'] as String?) ?? '';
|
||||
final rdaValue = data['rdaValue'] as double?;
|
||||
final ulValue = data['ulValue'] as double?;
|
||||
final total = data['total'] as double?;
|
||||
final pctRda = data['pctRda'] as double?;
|
||||
final pctUl = data['pctUl'] as double?;
|
||||
final rdaType = data['rdaType'] as String? ?? '';
|
||||
final lifeStage = data['lifeStage'] as String? ?? '';
|
||||
final note = data['note'] as String?;
|
||||
final lifeStageDesc = data['lifeStageDescription'] as String?;
|
||||
final rdaValueMin = data['rdaValueMin'] as double?;
|
||||
final rdaValueMax = data['rdaValueMax'] as double?;
|
||||
final nutrientUl = (data['nutrientUl'] as Map?)?.cast<String, dynamic>();
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Header row: title and sources button
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
nutrientPretty,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Sources & disclaimer',
|
||||
icon: const Icon(Icons.info_outline),
|
||||
onPressed: () => _showRdaSourcesSheet(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (lifeStage.isNotEmpty || (lifeStageDesc != null && lifeStageDesc.isNotEmpty)) ...[
|
||||
const SizedBox(height: 4),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
if (lifeStage.isNotEmpty)
|
||||
InfoChip(
|
||||
icon: Icons.person_outline,
|
||||
label: 'Life stage: $lifeStage',
|
||||
context: context,
|
||||
),
|
||||
if (lifeStageDesc != null && lifeStageDesc.isNotEmpty)
|
||||
InfoChip(
|
||||
icon: Icons.info_outline,
|
||||
label: lifeStageDesc!,
|
||||
context: context,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
// Intake vs RDA chips
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
if (total != null)
|
||||
InfoChip(
|
||||
icon: Icons.local_drink,
|
||||
label: 'Intake: ${total.toStringAsFixed(total % 1 == 0 ? 0 : 1)} $unitLabel',
|
||||
context: context,
|
||||
),
|
||||
if (rdaValueMin != null && rdaValueMax != null)
|
||||
InfoChip(
|
||||
icon: Icons.rule,
|
||||
label: 'RDA: ${rdaValueMin!.toStringAsFixed(rdaValueMin! % 1 == 0 ? 0 : 1)}–${rdaValueMax!.toStringAsFixed(rdaValueMax! % 1 == 0 ? 0 : 1)} $unitLabel',
|
||||
context: context,
|
||||
)
|
||||
else if (rdaValue != null)
|
||||
InfoChip(
|
||||
icon: Icons.rule,
|
||||
label: 'RDA: ${rdaValue!.toStringAsFixed(rdaValue! % 1 == 0 ? 0 : 1)} $unitLabel',
|
||||
context: context,
|
||||
),
|
||||
if (pctRda != null)
|
||||
InfoChip(
|
||||
icon: Icons.percent,
|
||||
label: '%RDA: ${pctRda.toStringAsFixed(pctRda >= 10 ? 0 : 1)}%',
|
||||
context: context,
|
||||
),
|
||||
if (ulValue != null)
|
||||
InfoChip(
|
||||
icon: Icons.shield_outlined,
|
||||
label: 'UL: ${ulValue.toStringAsFixed(ulValue % 1 == 0 ? 0 : 1)} $unitLabel',
|
||||
context: context,
|
||||
),
|
||||
if (pctUl != null)
|
||||
InfoChip(
|
||||
icon: Icons.warning_amber,
|
||||
label: '%UL: ${pctUl.toStringAsFixed(pctUl >= 10 ? 0 : 1)}%',
|
||||
context: context,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (rdaType.isNotEmpty || (note != null && note!.isNotEmpty) || nutrientUl != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (rdaType.isNotEmpty)
|
||||
Text(
|
||||
'Basis: $rdaType',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
if (nutrientUl != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Upper limit guidance',
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
InfoChip(
|
||||
icon: Icons.shield_moon_outlined,
|
||||
label: 'UL: ${nutrientUl['value']} ${nutrientUl['unit']}',
|
||||
context: context,
|
||||
),
|
||||
if ((nutrientUl['duration'] as String?)?.isNotEmpty ?? false)
|
||||
InfoChip(
|
||||
icon: Icons.schedule,
|
||||
label: 'Duration: ${nutrientUl['duration']}',
|
||||
context: context,
|
||||
),
|
||||
],
|
||||
),
|
||||
if ((nutrientUl['note'] as String?)?.isNotEmpty ?? false) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
nutrientUl['note'],
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
if (note != null && note!.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
note!,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showTakeDialog(BuildContext context, Supplement supplement) {
|
||||
final unitsController = TextEditingController(text: supplement.numberOfUnits.toString());
|
||||
final notesController = TextEditingController();
|
||||
|
Reference in New Issue
Block a user