diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index ee6ddc4..dac0589 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -8,7 +8,8 @@ + android:icon="@mipmap/ic_launcher" + android:enableOnBackInvokedCallback="true"> + + diff --git a/lib/main.dart b/lib/main.dart index 0b19b7b..e3c073a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'providers/supplement_provider.dart'; +import 'providers/settings_provider.dart'; import 'screens/home_screen.dart'; void main() { @@ -12,27 +13,38 @@ class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { - return ChangeNotifierProvider( - create: (context) => SupplementProvider()..initialize(), - child: MaterialApp( - title: 'Supplements Tracker', - theme: ThemeData( - colorScheme: ColorScheme.fromSeed( - seedColor: Colors.blue, - brightness: Brightness.light, - ), - useMaterial3: true, + return MultiProvider( + providers: [ + ChangeNotifierProvider( + create: (context) => SupplementProvider()..initialize(), ), - darkTheme: ThemeData( - colorScheme: ColorScheme.fromSeed( - seedColor: Colors.blue, - brightness: Brightness.dark, - ), - useMaterial3: true, + ChangeNotifierProvider( + create: (context) => SettingsProvider()..initialize(), ), - themeMode: ThemeMode.system, // Follows system theme - home: const HomeScreen(), - debugShowCheckedModeBanner: false, + ], + child: Consumer( + builder: (context, settingsProvider, child) { + return MaterialApp( + title: 'Supplements Tracker', + theme: ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: Colors.blue, + brightness: Brightness.light, + ), + useMaterial3: true, + ), + darkTheme: ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: Colors.blue, + brightness: Brightness.dark, + ), + useMaterial3: true, + ), + themeMode: settingsProvider.themeMode, + home: const HomeScreen(), + debugShowCheckedModeBanner: false, + ); + }, ), ); } diff --git a/lib/models/ingredient.dart b/lib/models/ingredient.dart new file mode 100644 index 0000000..acd8466 --- /dev/null +++ b/lib/models/ingredient.dart @@ -0,0 +1,64 @@ +class Ingredient { + final int? id; + final String name; // e.g., "Vitamin K2", "Vitamin D3" + final double amount; // e.g., 75, 20 + final String unit; // e.g., "mcg", "mg", "IU" + + const Ingredient({ + this.id, + required this.name, + required this.amount, + required this.unit, + }); + + Map toMap() { + return { + 'id': id, + 'name': name, + 'amount': amount, + 'unit': unit, + }; + } + + factory Ingredient.fromMap(Map map) { + return Ingredient( + id: map['id'], + name: map['name'], + amount: map['amount']?.toDouble() ?? 0.0, + unit: map['unit'], + ); + } + + Ingredient copyWith({ + int? id, + String? name, + double? amount, + String? unit, + }) { + return Ingredient( + id: id ?? this.id, + name: name ?? this.name, + amount: amount ?? this.amount, + unit: unit ?? this.unit, + ); + } + + @override + String toString() { + return '$amount$unit $name'; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is Ingredient && + other.name == name && + other.amount == amount && + other.unit == unit; + } + + @override + int get hashCode { + return name.hashCode ^ amount.hashCode ^ unit.hashCode; + } +} diff --git a/lib/models/supplement.dart b/lib/models/supplement.dart index e3275de..724746d 100644 --- a/lib/models/supplement.dart +++ b/lib/models/supplement.dart @@ -1,9 +1,12 @@ +import 'ingredient.dart'; +import 'dart:convert'; + class Supplement { final int? id; final String name; - final double dosageAmount; // Amount per unit (e.g., 187mg) + final String? brand; + final List ingredients; final int numberOfUnits; // Number of units to take (e.g., 2 capsules) - final String unit; // mg, g, ml, etc. final String unitType; // capsules, tablets, ml, etc. final int frequencyPerDay; final List reminderTimes; // e.g., ['08:00', '20:00'] @@ -14,9 +17,9 @@ class Supplement { Supplement({ this.id, required this.name, - required this.dosageAmount, + this.brand, + this.ingredients = const [], required this.numberOfUnits, - required this.unit, required this.unitType, required this.frequencyPerDay, required this.reminderTimes, @@ -25,16 +28,38 @@ class Supplement { this.isActive = true, }); - // Helper getter for total dosage per intake - double get totalDosagePerIntake => dosageAmount * numberOfUnits; + // Helper getters + double get totalDosagePerIntake { + // This concept doesn't apply well to multi-ingredient supplements + // Return 0 as it should be handled per ingredient + return 0.0; + } + + // Get formatted ingredients string for display + String get ingredientsDisplay { + if (ingredients.isEmpty) { + return 'No ingredients specified'; + } + return ingredients.map((ingredient) => + '${ingredient.amount * numberOfUnits}${ingredient.unit} ${ingredient.name}' + ).join(', '); + } + + // Get ingredients per single unit + String get ingredientsPerUnit { + if (ingredients.isEmpty) { + return 'No ingredients specified'; + } + return ingredients.map((ingredient) => ingredient.toString()).join(', '); + } Map toMap() { return { 'id': id, 'name': name, - 'dosageAmount': dosageAmount, + 'brand': brand, + 'ingredients': jsonEncode(ingredients.map((ingredient) => ingredient.toMap()).toList()), 'numberOfUnits': numberOfUnits, - 'unit': unit, 'unitType': unitType, 'frequencyPerDay': frequencyPerDay, 'reminderTimes': reminderTimes.join(','), @@ -45,13 +70,29 @@ class Supplement { } factory Supplement.fromMap(Map map) { + List ingredients = []; + + // Try to parse ingredients if they exist + if (map['ingredients'] != null && map['ingredients'].isNotEmpty) { + try { + final ingredientsJson = map['ingredients'] as String; + final ingredientsList = jsonDecode(ingredientsJson) as List; + ingredients = ingredientsList + .map((ingredient) => Ingredient.fromMap(ingredient as Map)) + .toList(); + } catch (e) { + // If parsing fails, fall back to empty list + ingredients = []; + } + } + return Supplement( id: map['id'], name: map['name'], - dosageAmount: map['dosageAmount']?.toDouble() ?? map['dosage']?.toDouble() ?? 0.0, // Backwards compatibility - numberOfUnits: map['numberOfUnits'] ?? 1, // Default to 1 for backwards compatibility - unit: map['unit'], - unitType: map['unitType'] ?? 'units', // Default unit type for backwards compatibility + brand: map['brand'], + ingredients: ingredients, + numberOfUnits: map['numberOfUnits'] ?? 1, + unitType: map['unitType'] ?? 'units', frequencyPerDay: map['frequencyPerDay'], reminderTimes: map['reminderTimes'].split(','), notes: map['notes'], @@ -63,9 +104,9 @@ class Supplement { Supplement copyWith({ int? id, String? name, - double? dosageAmount, + String? brand, + List? ingredients, int? numberOfUnits, - String? unit, String? unitType, int? frequencyPerDay, List? reminderTimes, @@ -76,9 +117,9 @@ class Supplement { return Supplement( id: id ?? this.id, name: name ?? this.name, - dosageAmount: dosageAmount ?? this.dosageAmount, + brand: brand ?? this.brand, + ingredients: ingredients ?? this.ingredients, numberOfUnits: numberOfUnits ?? this.numberOfUnits, - unit: unit ?? this.unit, unitType: unitType ?? this.unitType, frequencyPerDay: frequencyPerDay ?? this.frequencyPerDay, reminderTimes: reminderTimes ?? this.reminderTimes, diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart new file mode 100644 index 0000000..f0016ca --- /dev/null +++ b/lib/providers/settings_provider.dart @@ -0,0 +1,259 @@ +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +enum ThemeOption { + system, + light, + dark, +} + +class SettingsProvider extends ChangeNotifier { + ThemeOption _themeOption = ThemeOption.system; + + // Time range settings (stored as hours, 0-23) + int _morningStart = 5; + int _morningEnd = 10; + int _afternoonStart = 11; + int _afternoonEnd = 16; + int _eveningStart = 17; + int _eveningEnd = 22; + int _nightStart = 23; + int _nightEnd = 4; + + // Persistent reminder settings + bool _persistentReminders = true; + int _reminderRetryInterval = 5; // minutes + int _maxRetryAttempts = 3; + + ThemeOption get themeOption => _themeOption; + + // Time range getters + int get morningStart => _morningStart; + int get morningEnd => _morningEnd; + int get afternoonStart => _afternoonStart; + int get afternoonEnd => _afternoonEnd; + int get eveningStart => _eveningStart; + int get eveningEnd => _eveningEnd; + int get nightStart => _nightStart; + int get nightEnd => _nightEnd; + + // Persistent reminder getters + bool get persistentReminders => _persistentReminders; + int get reminderRetryInterval => _reminderRetryInterval; + int get maxRetryAttempts => _maxRetryAttempts; + + // Helper method to get formatted time ranges for display + String get morningRange => '${_formatHour(_morningStart)} - ${_formatHour((_morningEnd + 1) % 24)}'; + String get afternoonRange => '${_formatHour(_afternoonStart)} - ${_formatHour((_afternoonEnd + 1) % 24)}'; + String get eveningRange => '${_formatHour(_eveningStart)} - ${_formatHour((_eveningEnd + 1) % 24)}'; + String get nightRange => '${_formatHour(_nightStart)} - ${_formatHour((_nightEnd + 1) % 24)}'; + + String _formatHour(int hour) { + return '${hour.toString().padLeft(2, '0')}:00'; + } + + ThemeMode get themeMode { + switch (_themeOption) { + case ThemeOption.light: + return ThemeMode.light; + case ThemeOption.dark: + return ThemeMode.dark; + case ThemeOption.system: + return ThemeMode.system; + } + } + + Future initialize() async { + final prefs = await SharedPreferences.getInstance(); + final themeIndex = prefs.getInt('theme_option') ?? 0; + _themeOption = ThemeOption.values[themeIndex]; + + // Load time range settings + _morningStart = prefs.getInt('morning_start') ?? 5; + _morningEnd = prefs.getInt('morning_end') ?? 10; + _afternoonStart = prefs.getInt('afternoon_start') ?? 11; + _afternoonEnd = prefs.getInt('afternoon_end') ?? 16; + _eveningStart = prefs.getInt('evening_start') ?? 17; + _eveningEnd = prefs.getInt('evening_end') ?? 22; + _nightStart = prefs.getInt('night_start') ?? 23; + _nightEnd = prefs.getInt('night_end') ?? 4; + + // Load persistent reminder settings + _persistentReminders = prefs.getBool('persistent_reminders') ?? true; + _reminderRetryInterval = prefs.getInt('reminder_retry_interval') ?? 5; + _maxRetryAttempts = prefs.getInt('max_retry_attempts') ?? 3; + + notifyListeners(); + } + + Future setThemeOption(ThemeOption option) async { + _themeOption = option; + notifyListeners(); + + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt('theme_option', option.index); + } + + Future setTimeRanges({ + required int morningStart, + required int morningEnd, + required int afternoonStart, + required int afternoonEnd, + required int eveningStart, + required int eveningEnd, + required int nightStart, + required int nightEnd, + }) async { + // Validate ranges don't overlap (simplified validation) + if (!_areTimeRangesValid( + morningStart, morningEnd, + afternoonStart, afternoonEnd, + eveningStart, eveningEnd, + nightStart, nightEnd, + )) { + throw ArgumentError('Time ranges overlap or are invalid'); + } + + _morningStart = morningStart; + _morningEnd = morningEnd; + _afternoonStart = afternoonStart; + _afternoonEnd = afternoonEnd; + _eveningStart = eveningStart; + _eveningEnd = eveningEnd; + _nightStart = nightStart; + _nightEnd = nightEnd; + + notifyListeners(); + + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt('morning_start', morningStart); + await prefs.setInt('morning_end', morningEnd); + await prefs.setInt('afternoon_start', afternoonStart); + await prefs.setInt('afternoon_end', afternoonEnd); + await prefs.setInt('evening_start', eveningStart); + await prefs.setInt('evening_end', eveningEnd); + await prefs.setInt('night_start', nightStart); + await prefs.setInt('night_end', nightEnd); + } + + bool _areTimeRangesValid( + int morningStart, int morningEnd, + int afternoonStart, int afternoonEnd, + int eveningStart, int eveningEnd, + int nightStart, int nightEnd, + ) { + // Basic validation - ensure start < end for non-wrapping periods + if (morningStart > morningEnd) return false; + if (afternoonStart > afternoonEnd) return false; + if (eveningStart > eveningEnd) return false; + + // Night can wrap around midnight, so we allow nightStart > nightEnd + + // Check for overlaps in sequential periods + if (morningEnd >= afternoonStart) return false; + if (afternoonEnd >= eveningStart) return false; + if (eveningEnd >= nightStart) return false; + + return true; + } + + // Method to determine time category based on current settings + String determineTimeCategory(List reminderTimes) { + if (reminderTimes.isEmpty) { + return 'anytime'; + } + + // Convert reminder times to hours for categorization + final hours = reminderTimes.map((time) { + final parts = time.split(':'); + return int.tryParse(parts[0]) ?? 12; + }).toList(); + + // Count how many times fall into each category + int morningCount = 0; + int afternoonCount = 0; + int eveningCount = 0; + int nightCount = 0; + + for (final hour in hours) { + if (hour >= _morningStart && hour <= _morningEnd) { + morningCount++; + } else if (hour >= _afternoonStart && hour <= _afternoonEnd) { + afternoonCount++; + } else if (hour >= _eveningStart && hour <= _eveningEnd) { + eveningCount++; + } else if (_isInNightRange(hour)) { + nightCount++; + } + } + + // If supplement is taken throughout the day (has times in multiple periods) + final periodsCount = (morningCount > 0 ? 1 : 0) + + (afternoonCount > 0 ? 1 : 0) + + (eveningCount > 0 ? 1 : 0) + + (nightCount > 0 ? 1 : 0); + + if (periodsCount >= 2) { + // Categorize based on the earliest reminder time for consistency + final earliestHour = hours.reduce((a, b) => a < b ? a : b); + if (earliestHour >= _morningStart && earliestHour <= _morningEnd) { + return 'morning'; + } else if (earliestHour >= _afternoonStart && earliestHour <= _afternoonEnd) { + return 'afternoon'; + } else if (earliestHour >= _eveningStart && earliestHour <= _eveningEnd) { + return 'evening'; + } else if (_isInNightRange(earliestHour)) { + return 'night'; + } + } + + // If all times are in one period, categorize accordingly + if (morningCount > 0) { + return 'morning'; + } else if (afternoonCount > 0) { + return 'afternoon'; + } else if (eveningCount > 0) { + return 'evening'; + } else if (nightCount > 0) { + return 'night'; + } else { + return 'anytime'; + } + } + + bool _isInNightRange(int hour) { + // Night range can wrap around midnight + if (_nightStart <= _nightEnd) { + // Normal range (doesn't wrap around midnight) + return hour >= _nightStart && hour <= _nightEnd; + } else { + // Wrapping range (e.g., 23:00 to 4:00) + return hour >= _nightStart || hour <= _nightEnd; + } + } + + // Persistent reminder setters + Future setPersistentReminders(bool enabled) async { + _persistentReminders = enabled; + notifyListeners(); + + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('persistent_reminders', enabled); + } + + Future setReminderRetryInterval(int minutes) async { + _reminderRetryInterval = minutes; + notifyListeners(); + + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt('reminder_retry_interval', minutes); + } + + Future setMaxRetryAttempts(int attempts) async { + _maxRetryAttempts = attempts; + notifyListeners(); + + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt('max_retry_attempts', attempts); + } +} diff --git a/lib/providers/supplement_provider.dart b/lib/providers/supplement_provider.dart index c03eb69..8f056b6 100644 --- a/lib/providers/supplement_provider.dart +++ b/lib/providers/supplement_provider.dart @@ -1,4 +1,6 @@ +import 'dart:async'; import 'package:flutter/foundation.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import '../models/supplement.dart'; import '../models/supplement_intake.dart'; import '../services/database_helper.dart'; @@ -12,6 +14,7 @@ class SupplementProvider with ChangeNotifier { List> _todayIntakes = []; List> _monthlyIntakes = []; bool _isLoading = false; + Timer? _persistentReminderTimer; List get supplements => _supplements; List> get todayIntakes => _todayIntakes; @@ -20,9 +23,115 @@ class SupplementProvider with ChangeNotifier { Future initialize() async { await _notificationService.initialize(); - await _notificationService.requestPermissions(); + + // Set up the callback for handling supplement intake from notifications + print('📱 Setting up notification callback...'); + _notificationService.setTakeSupplementCallback((supplementId, supplementName, units, unitType) { + print('📱 === NOTIFICATION CALLBACK TRIGGERED ==='); + print('📱 Supplement ID: $supplementId'); + print('📱 Supplement Name: $supplementName'); + print('📱 Units: $units'); + print('📱 Unit Type: $unitType'); + + // Record the intake when user taps "Take" on notification + recordIntake(supplementId, 0.0, unitsTaken: units); + print('📱 Intake recorded successfully'); + print('📱 === CALLBACK COMPLETE ==='); + + if (kDebugMode) { + print('📱 Recorded intake from notification: $supplementName ($units $unitType)'); + } + }); + print('📱 Notification callback setup complete'); + + // Request permissions with error handling + try { + await _notificationService.requestPermissions(); + } catch (e) { + if (kDebugMode) { + print('Error requesting notification permissions: $e'); + } + // Continue without notifications rather than crashing + } + await loadSupplements(); await loadTodayIntakes(); + + // Reschedule notifications for all active supplements to ensure persistence + await _rescheduleAllNotifications(); + + // Start periodic checking for persistent reminders (every 5 minutes) + _startPersistentReminderCheck(); + } + + void _startPersistentReminderCheck() { + // Cancel any existing timer + _persistentReminderTimer?.cancel(); + + // Check every 5 minutes for persistent reminders + _persistentReminderTimer = Timer.periodic(const Duration(minutes: 5), (timer) async { + try { + // This will be called from settings provider context, so we need to import it + await _checkPersistentReminders(); + } catch (e) { + if (kDebugMode) { + print('Error checking persistent reminders: $e'); + } + } + }); + + // Also check immediately + _checkPersistentReminders(); + } + + Future _checkPersistentReminders() async { + // This method will be enhanced to accept settings from the UI layer + // For now, we'll check with default settings + // In practice, the UI should call checkPersistentRemindersWithSettings + if (kDebugMode) { + print('📱 Checking persistent reminders with default settings'); + } + } + + // Method to be called from UI with actual settings + Future checkPersistentRemindersWithSettings({ + required bool persistentReminders, + required int reminderRetryInterval, + required int maxRetryAttempts, + }) async { + await _notificationService.checkPersistentReminders( + persistentReminders, + reminderRetryInterval, + maxRetryAttempts, + ); + } + + @override + void dispose() { + _persistentReminderTimer?.cancel(); + super.dispose(); + } + + Future _rescheduleAllNotifications() async { + if (kDebugMode) { + print('📱 Rescheduling notifications for all active supplements...'); + } + + for (final supplement in _supplements) { + if (supplement.reminderTimes.isNotEmpty) { + try { + await _notificationService.scheduleSupplementReminders(supplement); + } catch (e) { + if (kDebugMode) { + print('📱 Error rescheduling notifications for ${supplement.name}: $e'); + } + } + } + } + + if (kDebugMode) { + print('📱 Finished rescheduling notifications'); + } } Future loadSupplements() async { @@ -103,11 +212,11 @@ class SupplementProvider with ChangeNotifier { } } - Future recordIntake(int supplementId, double dosage, {double? unitsTaken, String? notes}) async { + Future recordIntake(int supplementId, double dosage, {double? unitsTaken, String? notes, DateTime? takenAt}) async { try { final intake = SupplementIntake( supplementId: supplementId, - takenAt: DateTime.now(), + takenAt: takenAt ?? DateTime.now(), dosageTaken: dosage, unitsTaken: unitsTaken ?? 1.0, notes: notes, @@ -121,7 +230,7 @@ class SupplementProvider with ChangeNotifier { final unitsText = unitsTaken != null && unitsTaken != 1 ? '${unitsTaken.toStringAsFixed(unitsTaken % 1 == 0 ? 0 : 1)} ${supplement.unitType}' : ''; await _notificationService.showInstantNotification( 'Supplement Taken', - 'Recorded ${supplement.name}${unitsText.isNotEmpty ? ' - $unitsText' : ''} ($dosage ${supplement.unit})', + 'Recorded ${supplement.name}${unitsText.isNotEmpty ? ' - $unitsText' : ''} (${supplement.ingredientsDisplay})', ); } catch (e) { if (kDebugMode) { @@ -167,10 +276,100 @@ class SupplementProvider with ChangeNotifier { try { await _databaseHelper.deleteIntake(intakeId); await loadTodayIntakes(); + // Also refresh monthly intakes if they're loaded + if (_monthlyIntakes.isNotEmpty) { + await loadMonthlyIntakes(DateTime.now().year, DateTime.now().month); + } + notifyListeners(); } catch (e) { if (kDebugMode) { print('Error deleting intake: $e'); } } } + + bool hasBeenTakenToday(int supplementId) { + return _todayIntakes.any((intake) => intake['supplement_id'] == supplementId); + } + + int getTodayIntakeCount(int supplementId) { + return _todayIntakes.where((intake) => intake['supplement_id'] == supplementId).length; + } + + // Archive functionality + List _archivedSupplements = []; + List get archivedSupplements => _archivedSupplements; + + Future loadArchivedSupplements() async { + try { + _archivedSupplements = await _databaseHelper.getArchivedSupplements(); + notifyListeners(); + } catch (e) { + if (kDebugMode) { + print('Error loading archived supplements: $e'); + } + } + } + + Future archiveSupplement(int supplementId) async { + try { + await _databaseHelper.archiveSupplement(supplementId); + await loadSupplements(); // Refresh active supplements + await loadArchivedSupplements(); // Refresh archived supplements + } catch (e) { + if (kDebugMode) { + print('Error archiving supplement: $e'); + } + } + } + + Future unarchiveSupplement(int supplementId) async { + try { + await _databaseHelper.unarchiveSupplement(supplementId); + await loadSupplements(); // Refresh active supplements + await loadArchivedSupplements(); // Refresh archived supplements + } catch (e) { + if (kDebugMode) { + print('Error unarchiving supplement: $e'); + } + } + } + + Future deleteArchivedSupplement(int supplementId) async { + try { + await _databaseHelper.deleteSupplement(supplementId); + await loadArchivedSupplements(); // Refresh archived supplements + } catch (e) { + if (kDebugMode) { + print('Error deleting archived supplement: $e'); + } + } + } + + // Debug methods for notification testing + Future testNotifications() async { + await _notificationService.testNotification(); + } + + Future testScheduledNotification() async { + await _notificationService.testScheduledNotification(); + } + + Future testNotificationActions() async { + await _notificationService.testNotificationWithActions(); + } + + Future> getPendingNotifications() async { + return await _notificationService.getPendingNotifications(); + } + + // Debug method to test notification persistence + Future rescheduleAllNotifications() async { + await _rescheduleAllNotifications(); + } + + // Debug method to cancel all notifications + Future cancelAllNotifications() async { + await _notificationService.cancelAllReminders(); + } } diff --git a/lib/screens/add_supplement_screen.dart b/lib/screens/add_supplement_screen.dart index fe38d97..12dfc69 100644 --- a/lib/screens/add_supplement_screen.dart +++ b/lib/screens/add_supplement_screen.dart @@ -1,9 +1,36 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:flutter_datetime_picker_plus/flutter_datetime_picker_plus.dart'; import '../models/supplement.dart'; +import '../models/ingredient.dart'; import '../providers/supplement_provider.dart'; +// Helper class to manage ingredient text controllers +class IngredientController { + final TextEditingController nameController; + final TextEditingController amountController; + String selectedUnit; + + IngredientController({ + String name = '', + double amount = 0.0, + this.selectedUnit = 'mg', + }) : nameController = TextEditingController(text: name), + amountController = TextEditingController(text: amount > 0 ? amount.toString() : ''); + + Ingredient toIngredient() { + return Ingredient( + name: nameController.text.trim(), + amount: double.tryParse(amountController.text) ?? 0.0, + unit: selectedUnit, + ); + } + + void dispose() { + nameController.dispose(); + amountController.dispose(); + } +} + class AddSupplementScreen extends StatefulWidget { final Supplement? supplement; @@ -16,11 +43,13 @@ class AddSupplementScreen extends StatefulWidget { class _AddSupplementScreenState extends State { final _formKey = GlobalKey(); final _nameController = TextEditingController(); - final _dosageAmountController = TextEditingController(); + final _brandController = TextEditingController(); final _numberOfUnitsController = TextEditingController(); final _notesController = TextEditingController(); - String _selectedUnit = 'mg'; + // Multi-ingredient support with persistent controllers + List _ingredientControllers = []; + String _selectedUnitType = 'capsules'; int _frequencyPerDay = 1; List _reminderTimes = ['08:00']; @@ -28,26 +57,159 @@ class _AddSupplementScreenState extends State { final List _units = ['mg', 'g', 'μg', 'IU', 'ml']; final List _unitTypes = ['capsules', 'tablets', 'softgels', 'drops', 'ml', 'scoops', 'gummies']; - @override + @override void initState() { super.initState(); if (widget.supplement != null) { _initializeWithExistingSupplement(); } else { _numberOfUnitsController.text = '1'; // Default to 1 unit + // Start with one empty ingredient + _ingredientControllers.add(IngredientController()); } } + void _addIngredient() { + setState(() { + _ingredientControllers.add(IngredientController()); + }); + } + + void _removeIngredient(int index) { + if (_ingredientControllers.length > 1) { + setState(() { + _ingredientControllers[index].dispose(); + _ingredientControllers.removeAt(index); + }); + } + } + + void _updateIngredient(int index, String field, dynamic value) { + if (index < _ingredientControllers.length) { + setState(() { + if (field == 'unit') { + _ingredientControllers[index].selectedUnit = value as String; + } + // Note: name and amount are handled by the TextEditingControllers directly + }); + } + } + + Widget _buildIngredientRow(int index, IngredientController controller) { + return Card( + margin: const EdgeInsets.only(bottom: 12), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'Ingredient ${index + 1}', + style: Theme.of(context).textTheme.titleSmall, + ), + const Spacer(), + if (_ingredientControllers.length > 1) + IconButton( + onPressed: () => _removeIngredient(index), + icon: const Icon(Icons.remove_circle_outline), + color: Colors.red, + tooltip: 'Remove ingredient', + ), + ], + ), + const SizedBox(height: 12), + TextFormField( + controller: controller.nameController, + decoration: const InputDecoration( + labelText: 'Ingredient Name *', + border: OutlineInputBorder(), + hintText: 'e.g., Vitamin D3, Magnesium', + ), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Please enter ingredient name'; + } + return null; + }, + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + flex: 2, + child: TextFormField( + controller: controller.amountController, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Amount *', + border: OutlineInputBorder(), + hintText: '100', + ), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Enter amount'; + } + if (double.tryParse(value) == null || double.parse(value) <= 0) { + return 'Enter valid amount'; + } + return null; + }, + ), + ), + const SizedBox(width: 12), + Expanded( + flex: 1, + child: DropdownButtonFormField( + value: controller.selectedUnit, + decoration: const InputDecoration( + labelText: 'Unit', + border: OutlineInputBorder(), + ), + items: _units.map((unit) { + return DropdownMenuItem( + value: unit, + child: Text(unit), + ); + }).toList(), + onChanged: (value) { + _updateIngredient(index, 'unit', value); + }, + ), + ), + ], + ), + ], + ), + ), + ); + } + void _initializeWithExistingSupplement() { final supplement = widget.supplement!; _nameController.text = supplement.name; - _dosageAmountController.text = supplement.dosageAmount.toString(); + _brandController.text = supplement.brand ?? ''; _numberOfUnitsController.text = supplement.numberOfUnits.toString(); _notesController.text = supplement.notes ?? ''; - _selectedUnit = supplement.unit; _selectedUnitType = supplement.unitType; _frequencyPerDay = supplement.frequencyPerDay; _reminderTimes = List.from(supplement.reminderTimes); + + // Initialize ingredient controllers from existing ingredients + _ingredientControllers.clear(); + if (supplement.ingredients.isEmpty) { + // If no ingredients, start with one empty controller + _ingredientControllers.add(IngredientController()); + } else { + for (final ingredient in supplement.ingredients) { + _ingredientControllers.add(IngredientController( + name: ingredient.name, + amount: ingredient.amount, + selectedUnit: ingredient.unit, + )); + } + } } @override @@ -83,53 +245,36 @@ class _AddSupplementScreenState extends State { ), const SizedBox(height: 16), - // Dosage amount per unit - Row( - children: [ - Expanded( - flex: 2, - child: TextFormField( - controller: _dosageAmountController, - keyboardType: TextInputType.number, - decoration: const InputDecoration( - labelText: 'Amount per unit *', - border: OutlineInputBorder(), - hintText: '187', - ), - validator: (value) { - if (value == null || value.trim().isEmpty) { - return 'Please enter amount per unit'; - } - if (double.tryParse(value) == null) { - return 'Please enter a valid number'; - } - return null; - }, - ), - ), - const SizedBox(width: 12), - Expanded( - flex: 1, - child: DropdownButtonFormField( - value: _selectedUnit, - decoration: const InputDecoration( - labelText: 'Unit', - border: OutlineInputBorder(), - ), - items: _units.map((unit) { - return DropdownMenuItem( - value: unit, - child: Text(unit), - ); - }).toList(), - onChanged: (value) { - setState(() { - _selectedUnit = value!; - }); - }, - ), - ), - ], + // Brand field + TextFormField( + controller: _brandController, + decoration: const InputDecoration( + labelText: 'Brand (Optional)', + border: OutlineInputBorder(), + hintText: 'e.g., Nature Made, NOW Foods', + ), + ), + const SizedBox(height: 16), + + // Ingredients section + Text( + 'Ingredients', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + ..._ingredientControllers.asMap().entries.map((entry) { + final index = entry.key; + final controller = entry.value; + return _buildIngredientRow(index, controller); + }), + const SizedBox(height: 8), + OutlinedButton.icon( + onPressed: _addIngredient, + icon: const Icon(Icons.add), + label: const Text('Add Ingredient'), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), ), const SizedBox(height: 16), @@ -181,15 +326,41 @@ class _AddSupplementScreenState extends State { ), ], ), - const SizedBox(height: 8), - Text( - 'Total per intake: ${_dosageAmountController.text.isNotEmpty && _numberOfUnitsController.text.isNotEmpty ? (double.tryParse(_dosageAmountController.text) ?? 0) * (int.tryParse(_numberOfUnitsController.text) ?? 0) : 0} $_selectedUnit', - style: TextStyle( - fontSize: 12, - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontStyle: FontStyle.italic, + const SizedBox(height: 16), + + // Show ingredients summary + if (_ingredientControllers.isNotEmpty && _ingredientControllers.any((c) => c.nameController.text.isNotEmpty && (double.tryParse(c.amountController.text) ?? 0) > 0)) + Card( + color: Theme.of(context).colorScheme.primaryContainer.withOpacity(0.3), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Per ${_numberOfUnitsController.text.isNotEmpty ? _numberOfUnitsController.text : "1"} $_selectedUnitType:', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + const SizedBox(height: 4), + ..._ingredientControllers.where((c) => c.nameController.text.isNotEmpty && (double.tryParse(c.amountController.text) ?? 0) > 0).map((controller) { + final amount = double.tryParse(controller.amountController.text) ?? 0; + final totalAmount = amount * (int.tryParse(_numberOfUnitsController.text) ?? 1); + return Text( + '${totalAmount.toStringAsFixed(totalAmount % 1 == 0 ? 0 : 1)}${controller.selectedUnit} ${controller.nameController.text}', + style: TextStyle( + fontSize: 11, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ); + }), + ], + ), + ), ), - ), const SizedBox(height: 16), // Frequency per day @@ -232,30 +403,66 @@ class _AddSupplementScreenState extends State { Expanded( child: InkWell( onTap: () => _selectTime(index), + borderRadius: BorderRadius.circular(8), child: Container( - padding: const EdgeInsets.all(12), + padding: const EdgeInsets.all(16), decoration: BoxDecoration( border: Border.all(color: Theme.of(context).colorScheme.outline), - borderRadius: BorderRadius.circular(4), + borderRadius: BorderRadius.circular(8), + color: Theme.of(context).colorScheme.surface, + ), + child: Row( + children: [ + Icon( + Icons.access_time, + size: 20, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 12), + Text( + time, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + const Spacer(), + Icon( + Icons.edit, + size: 16, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ], ), - child: Text(time), ), ), ), if (_reminderTimes.length > 1) - IconButton( - onPressed: () => _removeReminderTime(index), - icon: const Icon(Icons.remove_circle_outline), + Padding( + padding: const EdgeInsets.only(left: 8), + child: IconButton( + onPressed: () => _removeReminderTime(index), + icon: const Icon(Icons.remove_circle_outline), + color: Colors.red, + tooltip: 'Remove reminder time', + ), ), ], ), ); }), if (_reminderTimes.length < _frequencyPerDay) - TextButton.icon( - onPressed: _addReminderTime, - icon: const Icon(Icons.add), - label: const Text('Add Reminder Time'), + Padding( + padding: const EdgeInsets.only(top: 8), + child: OutlinedButton.icon( + onPressed: _addReminderTime, + icon: const Icon(Icons.add), + label: const Text('Add Reminder Time'), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), + ), ), const SizedBox(height: 16), @@ -299,17 +506,35 @@ class _AddSupplementScreenState extends State { } } - void _selectTime(int index) { - DatePicker.showTimePicker( - context, - showTitleActions: true, - onConfirm: (time) { - setState(() { - _reminderTimes[index] = '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}'; - }); + void _selectTime(int index) async { + // Parse current time or use default + TimeOfDay currentTime = TimeOfDay(hour: 8, minute: 0); + if (index < _reminderTimes.length) { + final timeParts = _reminderTimes[index].split(':'); + if (timeParts.length >= 2) { + currentTime = TimeOfDay( + hour: int.tryParse(timeParts[0]) ?? 8, + minute: int.tryParse(timeParts[1]) ?? 0, + ); + } + } + + final TimeOfDay? picked = await showTimePicker( + context: context, + initialTime: currentTime, + builder: (context, child) { + return MediaQuery( + data: MediaQuery.of(context).copyWith(alwaysUse24HourFormat: true), + child: child!, + ); }, - currentTime: DateTime.now(), ); + + if (picked != null) { + setState(() { + _reminderTimes[index] = '${picked.hour.toString().padLeft(2, '0')}:${picked.minute.toString().padLeft(2, '0')}'; + }); + } } void _addReminderTime() { @@ -330,12 +555,31 @@ class _AddSupplementScreenState extends State { void _saveSupplement() async { if (_formKey.currentState!.validate()) { + // Validate that we have at least one ingredient with name and amount + final validIngredients = _ingredientControllers.where((controller) => + controller.nameController.text.trim().isNotEmpty && + (double.tryParse(controller.amountController.text) ?? 0) > 0 + ).map((controller) => Ingredient( + name: controller.nameController.text.trim(), + amount: double.tryParse(controller.amountController.text) ?? 0, + unit: controller.selectedUnit, + )).toList(); + + if (validIngredients.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Please add at least one ingredient with name and amount'), + ), + ); + return; + } + final supplement = Supplement( id: widget.supplement?.id, name: _nameController.text.trim(), - dosageAmount: double.parse(_dosageAmountController.text), + brand: _brandController.text.trim().isNotEmpty ? _brandController.text.trim() : null, + ingredients: validIngredients, numberOfUnits: int.parse(_numberOfUnitsController.text), - unit: _selectedUnit, unitType: _selectedUnitType, frequencyPerDay: _frequencyPerDay, reminderTimes: _reminderTimes, @@ -380,9 +624,15 @@ class _AddSupplementScreenState extends State { @override void dispose() { _nameController.dispose(); - _dosageAmountController.dispose(); + _brandController.dispose(); _numberOfUnitsController.dispose(); _notesController.dispose(); + + // Dispose all ingredient controllers + for (final controller in _ingredientControllers) { + controller.dispose(); + } + super.dispose(); } } diff --git a/lib/screens/archived_supplements_screen.dart b/lib/screens/archived_supplements_screen.dart new file mode 100644 index 0000000..a74ebee --- /dev/null +++ b/lib/screens/archived_supplements_screen.dart @@ -0,0 +1,385 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/supplement_provider.dart'; +import '../models/supplement.dart'; + +class ArchivedSupplementsScreen extends StatefulWidget { + const ArchivedSupplementsScreen({super.key}); + + @override + State createState() => _ArchivedSupplementsScreenState(); +} + +class _ArchivedSupplementsScreenState extends State { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().loadArchivedSupplements(); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Archived Supplements'), + backgroundColor: Theme.of(context).colorScheme.inversePrimary, + ), + body: Consumer( + builder: (context, provider, child) { + if (provider.archivedSupplements.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.archive_outlined, + size: 64, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 16), + Text( + 'No archived supplements', + style: TextStyle( + fontSize: 18, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Text( + 'Archived supplements will appear here', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + } + + return RefreshIndicator( + onRefresh: () async { + await provider.loadArchivedSupplements(); + }, + child: ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: provider.archivedSupplements.length, + itemBuilder: (context, index) { + final supplement = provider.archivedSupplements[index]; + return _ArchivedSupplementCard( + supplement: supplement, + onUnarchive: () => _unarchiveSupplement(context, supplement), + onDelete: () => _deleteSupplement(context, supplement), + ); + }, + ), + ); + }, + ), + ); + } + + void _unarchiveSupplement(BuildContext context, Supplement supplement) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Unarchive Supplement'), + content: Text('Are you sure you want to unarchive ${supplement.name}?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () { + context.read().unarchiveSupplement(supplement.id!); + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('${supplement.name} unarchived'), + backgroundColor: Colors.green, + ), + ); + }, + child: const Text('Unarchive'), + ), + ], + ), + ); + } + + void _deleteSupplement(BuildContext context, Supplement supplement) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete Supplement'), + content: Text( + 'Are you sure you want to permanently delete ${supplement.name}? This action cannot be undone.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () { + context.read().deleteArchivedSupplement(supplement.id!); + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('${supplement.name} deleted permanently'), + backgroundColor: Colors.red, + ), + ); + }, + style: ElevatedButton.styleFrom(backgroundColor: Colors.red), + child: const Text('Delete', style: TextStyle(color: Colors.white)), + ), + ], + ), + ); + } +} + +class _ArchivedSupplementCard extends StatelessWidget { + final Supplement supplement; + final VoidCallback onUnarchive; + final VoidCallback onDelete; + + const _ArchivedSupplementCard({ + required this.supplement, + required this.onUnarchive, + required this.onDelete, + }); + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.only(bottom: 16), + elevation: 2, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.3), + border: Border.all( + color: Theme.of(context).colorScheme.outline.withOpacity(0.2), + width: 1, + ), + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.outline.withOpacity(0.1), + shape: BoxShape.circle, + ), + child: Icon( + Icons.archive, + color: Theme.of(context).colorScheme.outline, + size: 20, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + supplement.name, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + if (supplement.brand != null && supplement.brand!.isNotEmpty) + Text( + supplement.brand!, + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.outline, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + PopupMenuButton( + padding: EdgeInsets.zero, + icon: Icon( + Icons.more_vert, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + onSelected: (value) { + switch (value) { + case 'unarchive': + onUnarchive(); + break; + case 'delete': + onDelete(); + break; + } + }, + itemBuilder: (context) => [ + const PopupMenuItem( + value: 'unarchive', + child: Row( + children: [ + Icon(Icons.unarchive, color: Colors.green), + SizedBox(width: 8), + Text('Unarchive'), + ], + ), + ), + const PopupMenuItem( + value: 'delete', + child: Row( + children: [ + Icon(Icons.delete_forever, color: Colors.red), + SizedBox(width: 8), + Text('Delete Permanently', style: TextStyle(color: Colors.red)), + ], + ), + ), + ], + ), + ], + ), + const SizedBox(height: 16), + + // Supplement details in a muted style + if (supplement.ingredients.isNotEmpty) ...[ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.3), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Ingredients per ${supplement.unitType}:', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 6), + Wrap( + spacing: 6, + runSpacing: 4, + children: supplement.ingredients.map((ingredient) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.outline.withOpacity(0.1), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + '${ingredient.name} ${ingredient.amount}${ingredient.unit}', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.outline, + ), + ), + ); + }).toList(), + ), + ], + ), + ), + const SizedBox(height: 12), + ], + + // Dosage info + Row( + children: [ + _InfoChip( + icon: Icons.schedule, + label: '${supplement.frequencyPerDay}x daily', + context: context, + ), + const SizedBox(width: 8), + _InfoChip( + icon: Icons.medication, + label: '${supplement.numberOfUnits} ${supplement.unitType}', + context: context, + ), + ], + ), + + if (supplement.reminderTimes.isNotEmpty) ...[ + const SizedBox(height: 8), + _InfoChip( + icon: Icons.notifications_off, + label: 'Was: ${supplement.reminderTimes.join(', ')}', + context: context, + fullWidth: true, + ), + ], + ], + ), + ), + ), + ); + } +} + +class _InfoChip extends StatelessWidget { + final IconData icon; + final String label; + final BuildContext context; + final bool fullWidth; + + const _InfoChip({ + required this.icon, + required this.label, + required this.context, + this.fullWidth = false, + }); + + @override + Widget build(BuildContext context) { + return Container( + width: fullWidth ? double.infinity : null, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.4), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: fullWidth ? MainAxisSize.max : MainAxisSize.min, + children: [ + Icon( + icon, + size: 14, + color: Theme.of(context).colorScheme.outline, + ), + const SizedBox(width: 4), + Flexible( + child: Text( + label, + style: TextStyle( + fontSize: 11, + color: Theme.of(context).colorScheme.outline, + fontWeight: FontWeight.w500, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/history_screen.dart b/lib/screens/history_screen.dart index 0aefa0a..b2e638e 100644 --- a/lib/screens/history_screen.dart +++ b/lib/screens/history_screen.dart @@ -15,6 +15,7 @@ class _HistoryScreenState extends State with SingleTickerProvider DateTime _selectedDate = DateTime.now(); int _selectedMonth = DateTime.now().month; int _selectedYear = DateTime.now().year; + int _refreshKey = 0; // Add this to force FutureBuilder refresh @override void initState() { @@ -90,6 +91,7 @@ class _HistoryScreenState extends State with SingleTickerProvider ), Expanded( child: FutureBuilder>>( + key: ValueKey('daily_view_$_refreshKey'), // Use refresh key to force rebuild future: context.read().getIntakesForDate(_selectedDate), builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { @@ -137,12 +139,10 @@ class _HistoryScreenState extends State with SingleTickerProvider final supplementIntakes = groupedIntakes[supplementName]!; // Calculate totals - double totalDosage = 0; double totalUnits = 0; final firstIntake = supplementIntakes.first; for (final intake in supplementIntakes) { - totalDosage += intake['dosageTaken'] as double; totalUnits += (intake['unitsTaken'] as num?)?.toDouble() ?? 1.0; } @@ -161,14 +161,14 @@ class _HistoryScreenState extends State with SingleTickerProvider crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - '${totalDosage.toStringAsFixed(totalDosage % 1 == 0 ? 0 : 1)} ${firstIntake['supplementUnit']} total', + '${totalUnits.toStringAsFixed(totalUnits % 1 == 0 ? 0 : 1)} ${firstIntake['supplementUnitType'] ?? 'units'} total', style: TextStyle( fontWeight: FontWeight.w500, color: Theme.of(context).colorScheme.primary, ), ), Text( - '${totalUnits.toStringAsFixed(totalUnits % 1 == 0 ? 0 : 1)} ${firstIntake['supplementUnitType'] ?? 'units'} • ${supplementIntakes.length} intake${supplementIntakes.length > 1 ? 's' : ''}', + '${supplementIntakes.length} intake${supplementIntakes.length > 1 ? 's' : ''}', style: TextStyle( fontSize: 12, color: Theme.of(context).colorScheme.onSurfaceVariant, @@ -181,9 +181,9 @@ class _HistoryScreenState extends State with SingleTickerProvider final units = (intake['unitsTaken'] as num?)?.toDouble() ?? 1.0; return ListTile( - contentPadding: const EdgeInsets.only(left: 72, right: 16), + contentPadding: const EdgeInsets.only(left: 72, right: 8), title: Text( - '${(intake['dosageTaken'] as double).toStringAsFixed((intake['dosageTaken'] as double) % 1 == 0 ? 0 : 1)} ${intake['supplementUnit']}', + '${units.toStringAsFixed(units % 1 == 0 ? 0 : 1)} ${intake['supplementUnitType'] ?? 'units'}', style: const TextStyle(fontSize: 14), ), subtitle: Column( @@ -210,6 +210,19 @@ class _HistoryScreenState extends State with SingleTickerProvider ), ], ), + trailing: IconButton( + icon: Icon( + Icons.delete_outline, + color: Colors.red.shade400, + size: 20, + ), + onPressed: () => _deleteIntake(context, intake['id'], intake['supplementName']), + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 32, + minHeight: 32, + ), + ), ); }).toList(), ), @@ -335,6 +348,7 @@ class _HistoryScreenState extends State with SingleTickerProvider const SizedBox(height: 8), ...dayIntakes.map((intake) { final takenAt = DateTime.parse(intake['takenAt']); + final units = (intake['unitsTaken'] as num?)?.toDouble() ?? 1.0; return Padding( padding: const EdgeInsets.only(bottom: 4), child: Row( @@ -347,10 +361,23 @@ class _HistoryScreenState extends State with SingleTickerProvider const SizedBox(width: 8), Expanded( child: Text( - '${intake['supplementName']} - ${intake['dosageTaken']} ${intake['supplementUnit']} at ${DateFormat('HH:mm').format(takenAt)}', + '${intake['supplementName']} - ${units.toStringAsFixed(units % 1 == 0 ? 0 : 1)} ${intake['supplementUnitType'] ?? 'units'} at ${DateFormat('HH:mm').format(takenAt)}', style: const TextStyle(fontSize: 14), ), ), + IconButton( + icon: Icon( + Icons.delete_outline, + color: Colors.red.shade400, + size: 18, + ), + onPressed: () => _deleteIntake(context, intake['id'], intake['supplementName']), + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 24, + minHeight: 24, + ), + ), ], ), ); @@ -403,16 +430,27 @@ class _HistoryScreenState extends State with SingleTickerProvider child: const Text('Cancel'), ), ElevatedButton( - onPressed: () { - context.read().deleteIntake(intakeId); + onPressed: () async { + await context.read().deleteIntake(intakeId); Navigator.of(context).pop(); + + // Force refresh of the UI + setState(() { + _refreshKey++; // This will force FutureBuilder to rebuild + }); + + // Force refresh of the current view data + if (_tabController.index == 1) { + // Monthly view - refresh monthly intakes + context.read().loadMonthlyIntakes(_selectedYear, _selectedMonth); + } + ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Intake deleted'), + SnackBar( + content: Text('$supplementName intake deleted'), backgroundColor: Colors.red, ), ); - setState(() {}); // Refresh the view }, style: ElevatedButton.styleFrom(backgroundColor: Colors.red), child: const Text('Delete'), diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 86a271d..a65e98c 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -1,9 +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 'settings_screen.dart'; class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); @@ -18,6 +20,7 @@ class _HomeScreenState extends State { final List _screens = [ const SupplementsListScreen(), const HistoryScreen(), + const SettingsScreen(), ]; @override @@ -25,9 +28,42 @@ class _HomeScreenState extends State { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { context.read().initialize(); + _startPersistentReminderCheck(); }); } + void _startPersistentReminderCheck() { + // Check immediately and then every 10 minutes + _checkPersistentReminders(); + + // Set up periodic checking + Future.doWhile(() async { + await Future.delayed(const Duration(minutes: 10)); + if (mounted) { + await _checkPersistentReminders(); + return true; + } + return false; + }); + } + + Future _checkPersistentReminders() async { + if (!mounted) return; + + try { + final supplementProvider = context.read(); + final settingsProvider = context.read(); + + await supplementProvider.checkPersistentRemindersWithSettings( + persistentReminders: settingsProvider.persistentReminders, + reminderRetryInterval: settingsProvider.reminderRetryInterval, + maxRetryAttempts: settingsProvider.maxRetryAttempts, + ); + } catch (e) { + print('Error checking persistent reminders: $e'); + } + } + @override Widget build(BuildContext context) { return Scaffold( @@ -39,6 +75,7 @@ class _HomeScreenState extends State { _currentIndex = index; }); }, + type: BottomNavigationBarType.fixed, items: const [ BottomNavigationBarItem( icon: Icon(Icons.medication), @@ -48,6 +85,10 @@ class _HomeScreenState extends State { icon: Icon(Icons.history), label: 'History', ), + BottomNavigationBarItem( + icon: Icon(Icons.settings), + label: 'Settings', + ), ], ), floatingActionButton: _currentIndex == 0 diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart new file mode 100644 index 0000000..ce343be --- /dev/null +++ b/lib/screens/settings_screen.dart @@ -0,0 +1,601 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/settings_provider.dart'; +import '../providers/supplement_provider.dart'; +import '../services/notification_service.dart'; + +class SettingsScreen extends StatelessWidget { + const SettingsScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Settings'), + ), + body: Consumer( + builder: (context, settingsProvider, child) { + return ListView( + padding: const EdgeInsets.all(16.0), + children: [ + Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Theme', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 16), + RadioListTile( + title: const Text('Follow System'), + subtitle: const Text('Use system theme setting'), + value: ThemeOption.system, + groupValue: settingsProvider.themeOption, + onChanged: (value) { + if (value != null) { + settingsProvider.setThemeOption(value); + } + }, + ), + RadioListTile( + title: const Text('Light Theme'), + subtitle: const Text('Always use light theme'), + value: ThemeOption.light, + groupValue: settingsProvider.themeOption, + onChanged: (value) { + if (value != null) { + settingsProvider.setThemeOption(value); + } + }, + ), + RadioListTile( + title: const Text('Dark Theme'), + subtitle: const Text('Always use dark theme'), + value: ThemeOption.dark, + groupValue: settingsProvider.themeOption, + onChanged: (value) { + if (value != null) { + settingsProvider.setThemeOption(value); + } + }, + ), + ], + ), + ), + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Time Periods', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + 'Customize when morning, afternoon, evening, and night periods occur', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 16), + _TimeRangeSelector( + title: 'Morning', + icon: Icons.wb_sunny, + color: Colors.orange, + startHour: settingsProvider.morningStart, + endHour: settingsProvider.morningEnd, + onChanged: (start, end) => _updateTimeRanges( + context, settingsProvider, + morningStart: start, morningEnd: end, + ), + ), + const SizedBox(height: 12), + _TimeRangeSelector( + title: 'Afternoon', + icon: Icons.light_mode, + color: Colors.blue, + startHour: settingsProvider.afternoonStart, + endHour: settingsProvider.afternoonEnd, + onChanged: (start, end) => _updateTimeRanges( + context, settingsProvider, + afternoonStart: start, afternoonEnd: end, + ), + ), + const SizedBox(height: 12), + _TimeRangeSelector( + title: 'Evening', + icon: Icons.nightlight_round, + color: Colors.indigo, + startHour: settingsProvider.eveningStart, + endHour: settingsProvider.eveningEnd, + onChanged: (start, end) => _updateTimeRanges( + context, settingsProvider, + eveningStart: start, eveningEnd: end, + ), + ), + const SizedBox(height: 12), + _TimeRangeSelector( + title: 'Night', + icon: Icons.bedtime, + color: Colors.purple, + startHour: settingsProvider.nightStart, + endHour: settingsProvider.nightEnd, + onChanged: (start, end) => _updateTimeRanges( + context, settingsProvider, + nightStart: start, nightEnd: end, + ), + ), + ], + ), + ), + ), + 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( + 'Persistent Reminders', + style: Theme.of(context).textTheme.titleMedium, + ), + ], + ), + const SizedBox(height: 8), + Text( + 'Configure automatic reminder retries for ignored notifications', + 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'), + 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( + 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( + segments: const [ + ButtonSegment(value: 1, label: Text('1')), + ButtonSegment(value: 2, label: Text('2')), + ButtonSegment(value: 3, label: Text('3')), + ButtonSegment(value: 5, label: Text('5')), + ], + selected: {settingsProvider.maxRetryAttempts}, + onSelectionChanged: (values) { + settingsProvider.setMaxRetryAttempts(values.first); + }, + ), + ], + ], + ), + ), + ), + const SizedBox(height: 16), + if (Theme.of(context).brightness == Brightness.dark) // Only show in debug mode for now + Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.bug_report, color: Colors.orange), + const SizedBox(width: 8), + Text( + 'Debug - Notifications', + style: Theme.of(context).textTheme.titleMedium, + ), + ], + ), + const SizedBox(height: 16), + Consumer( + builder: (context, supplementProvider, child) { + return Column( + children: [ + ElevatedButton.icon( + onPressed: () async { + await supplementProvider.testNotifications(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Test notification sent!')), + ); + } + }, + icon: const Icon(Icons.notifications_active), + label: const Text('Test Instant'), + ), + const SizedBox(height: 8), + ElevatedButton.icon( + onPressed: () async { + await supplementProvider.testScheduledNotification(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Scheduled test notification for 1 minute from now!')), + ); + } + }, + icon: const Icon(Icons.schedule), + label: const Text('Test Scheduled (1min)'), + ), + const SizedBox(height: 8), + ElevatedButton.icon( + onPressed: () async { + await supplementProvider.testNotificationActions(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Test notification with actions sent! Try the Take/Snooze buttons.')), + ); + } + }, + icon: const Icon(Icons.touch_app), + label: const Text('Test Actions'), + ), + const SizedBox(height: 8), + ElevatedButton.icon( + onPressed: () async { + await NotificationService().testBasicNotification(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Basic test notification sent! Tap it to test callback.')), + ); + } + }, + icon: const Icon(Icons.tap_and_play), + label: const Text('Test Basic Tap'), + ), + const SizedBox(height: 8), + ElevatedButton.icon( + onPressed: () async { + await supplementProvider.rescheduleAllNotifications(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('All notifications rescheduled!')), + ); + } + }, + icon: const Icon(Icons.refresh), + label: const Text('Reschedule All'), + ), + const SizedBox(height: 8), + ElevatedButton.icon( + onPressed: () async { + await supplementProvider.cancelAllNotifications(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('All notifications cancelled!')), + ); + } + }, + icon: const Icon(Icons.cancel), + label: const Text('Cancel All'), + ), + const SizedBox(height: 8), + ElevatedButton.icon( + onPressed: () async { + final pending = await supplementProvider.getPendingNotifications(); + if (context.mounted) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Pending Notifications'), + content: pending.isEmpty + ? const Text('No pending notifications') + : SizedBox( + width: double.maxFinite, + child: Consumer( + builder: (context, provider, child) { + return ListView.builder( + shrinkWrap: true, + itemCount: pending.length, + itemBuilder: (context, index) { + final notification = pending[index]; + + // Calculate scheduled time inline + String scheduledTime = ''; + try { + final notificationId = notification.id; + if (notificationId == 99999) { + scheduledTime = 'Test notification'; + } else if (notificationId > 1000) { + final snoozeMinutes = notificationId % 1000; + scheduledTime = 'Snoozed ($snoozeMinutes min)'; + } else { + final supplementId = notificationId ~/ 100; + final reminderIndex = notificationId % 100; + + final supplement = provider.supplements.firstWhere( + (s) => s.id == supplementId, + orElse: () => provider.supplements.first, + ); + + if (reminderIndex < supplement.reminderTimes.length) { + final reminderTime = supplement.reminderTimes[reminderIndex]; + final now = DateTime.now(); + final timeParts = reminderTime.split(':'); + final hour = int.parse(timeParts[0]); + final minute = int.parse(timeParts[1]); + + final today = DateTime(now.year, now.month, now.day, hour, minute); + final isToday = today.isAfter(now); + + scheduledTime = '${isToday ? 'Today' : 'Tomorrow'} at $reminderTime'; + } else { + scheduledTime = 'Unknown time'; + } + } + } catch (e) { + scheduledTime = 'ID: ${notification.id}'; + } + + return Card( + margin: const EdgeInsets.symmetric(vertical: 4), + child: ListTile( + leading: CircleAvatar( + backgroundColor: Theme.of(context).colorScheme.primary, + child: Text( + '${index + 1}', + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimary, + fontWeight: FontWeight.bold, + ), + ), + ), + title: Text( + notification.title ?? 'No title', + style: const TextStyle(fontWeight: FontWeight.w600), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('ID: ${notification.id}'), + Text(notification.body ?? 'No body'), + if (scheduledTime.isNotEmpty) ...[ + const SizedBox(height: 4), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + '⏰ $scheduledTime', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + ), + ), + ], + ], + ), + isThreeLine: true, + ), + ); + }, + ); + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], + ), + ); + } + }, + icon: const Icon(Icons.list), + label: const Text('Show Pending'), + ), + ], + ); + }, + ), + ], + ), + ), + ), + ], + ); + }, + ), + ); + } + + void _updateTimeRanges( + BuildContext context, + SettingsProvider settingsProvider, { + int? morningStart, + int? morningEnd, + int? afternoonStart, + int? afternoonEnd, + int? eveningStart, + int? eveningEnd, + int? nightStart, + int? nightEnd, + }) async { + try { + await settingsProvider.setTimeRanges( + morningStart: morningStart ?? settingsProvider.morningStart, + morningEnd: morningEnd ?? settingsProvider.morningEnd, + afternoonStart: afternoonStart ?? settingsProvider.afternoonStart, + afternoonEnd: afternoonEnd ?? settingsProvider.afternoonEnd, + eveningStart: eveningStart ?? settingsProvider.eveningStart, + eveningEnd: eveningEnd ?? settingsProvider.eveningEnd, + nightStart: nightStart ?? settingsProvider.nightStart, + nightEnd: nightEnd ?? settingsProvider.nightEnd, + ); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Invalid time ranges: ${e.toString()}'), + backgroundColor: Colors.red, + ), + ); + } + } + } +} + +class _TimeRangeSelector extends StatelessWidget { + final String title; + final IconData icon; + final Color color; + final int startHour; + final int endHour; + final void Function(int start, int end) onChanged; + + const _TimeRangeSelector({ + required this.title, + required this.icon, + required this.color, + required this.startHour, + required this.endHour, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: color.withOpacity(0.3)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, color: color, size: 20), + const SizedBox(width: 8), + Text( + title, + style: TextStyle( + fontWeight: FontWeight.bold, + color: color, + fontSize: 16, + ), + ), + const Spacer(), + Text( + '${_formatHour(startHour)} - ${_formatHour(endHour + 1)}', + style: TextStyle( + color: color, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Start: ${_formatHour(startHour)}', + style: const TextStyle(fontSize: 12), + ), + Slider( + value: startHour.toDouble(), + min: 0, + max: 23, + divisions: 23, + activeColor: color, + onChanged: (value) { + final newStart = value.round(); + if (newStart != endHour) { + onChanged(newStart, endHour); + } + }, + ), + ], + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'End: ${_formatHour(endHour)}', + style: const TextStyle(fontSize: 12), + ), + Slider( + value: endHour.toDouble(), + min: 0, + max: 23, + divisions: 23, + activeColor: color, + onChanged: (value) { + final newEnd = value.round(); + if (newEnd != startHour) { + onChanged(startHour, newEnd); + } + }, + ), + ], + ), + ), + ], + ), + ], + ), + ); + } + + String _formatHour(int hour) { + final adjustedHour = hour % 24; + return '${adjustedHour.toString().padLeft(2, '0')}:00'; + } +} diff --git a/lib/screens/supplements_list_screen.dart b/lib/screens/supplements_list_screen.dart index d7623b7..ad9daa3 100644 --- a/lib/screens/supplements_list_screen.dart +++ b/lib/screens/supplements_list_screen.dart @@ -1,10 +1,11 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:intl/intl.dart'; import '../providers/supplement_provider.dart'; +import '../providers/settings_provider.dart'; import '../models/supplement.dart'; import '../widgets/supplement_card.dart'; import 'add_supplement_screen.dart'; +import 'archived_supplements_screen.dart'; class SupplementsListScreen extends StatelessWidget { const SupplementsListScreen({super.key}); @@ -15,9 +16,22 @@ class SupplementsListScreen extends StatelessWidget { appBar: AppBar( title: const Text('My Supplements'), backgroundColor: Theme.of(context).colorScheme.inversePrimary, + actions: [ + IconButton( + icon: const Icon(Icons.archive), + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const ArchivedSupplementsScreen(), + ), + ); + }, + tooltip: 'Archived Supplements', + ), + ], ), - body: Consumer( - builder: (context, provider, child) { + body: Consumer2( + builder: (context, provider, settingsProvider, child) { if (provider.isLoading) { return const Center(child: CircularProgressIndicator()); } @@ -56,86 +70,190 @@ class SupplementsListScreen extends StatelessWidget { onRefresh: () async { await provider.loadSupplements(); }, - child: Column( - children: [ - // Today's Intakes Section - if (provider.todayIntakes.isNotEmpty) ...[ - Container( - width: double.infinity, - margin: const EdgeInsets.all(16), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Theme.of(context).colorScheme.outline), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(Icons.check_circle, color: Theme.of(context).colorScheme.primary), - const SizedBox(width: 8), - Text( - 'Today\'s Intakes', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onPrimaryContainer, - ), - ), - ], - ), - const SizedBox(height: 8), - ...provider.todayIntakes.map((intake) { - final takenAt = DateTime.parse(intake['takenAt']); - return Padding( - padding: const EdgeInsets.only(bottom: 4), - child: Text( - '${intake['supplementName']} - ${intake['dosageTaken']} ${intake['supplementUnit']} at ${DateFormat('HH:mm').format(takenAt)}', - style: TextStyle(color: Theme.of(context).colorScheme.onPrimaryContainer), - ), - ); - }), - ], - ), - ), - ], - // Supplements List - Expanded( - child: ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: provider.supplements.length, - itemBuilder: (context, index) { - final supplement = provider.supplements[index]; - return SupplementCard( - supplement: supplement, - onTake: () => _showTakeDialog(context, supplement), - onEdit: () => _editSupplement(context, supplement), - onDelete: () => _deleteSupplement(context, supplement), - ); - }, - ), - ), - ], - ), + child: _buildGroupedSupplementsList(context, provider.supplements, settingsProvider), ); }, ), ); } + Widget _buildGroupedSupplementsList(BuildContext context, List supplements, SettingsProvider settingsProvider) { + final groupedSupplements = _groupSupplementsByTimeOfDay(supplements, settingsProvider); + + return ListView( + padding: const EdgeInsets.all(16), + children: [ + 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), + onEdit: () => _editSupplement(context, supplement), + onDelete: () => _deleteSupplement(context, supplement), + onArchive: () => _archiveSupplement(context, supplement), + ), + ), + const SizedBox(height: 16), + ], + + if (groupedSupplements['afternoon']!.isNotEmpty) ...[ + _buildSectionHeader('Afternoon (${settingsProvider.afternoonRange})', Icons.light_mode, Colors.blue, groupedSupplements['afternoon']!.length), + ...groupedSupplements['afternoon']!.map((supplement) => + SupplementCard( + supplement: supplement, + onTake: () => _showTakeDialog(context, supplement), + onEdit: () => _editSupplement(context, supplement), + onDelete: () => _deleteSupplement(context, supplement), + onArchive: () => _archiveSupplement(context, supplement), + ), + ), + const SizedBox(height: 16), + ], + + if (groupedSupplements['evening']!.isNotEmpty) ...[ + _buildSectionHeader('Evening (${settingsProvider.eveningRange})', Icons.nightlight_round, Colors.indigo, groupedSupplements['evening']!.length), + ...groupedSupplements['evening']!.map((supplement) => + SupplementCard( + supplement: supplement, + onTake: () => _showTakeDialog(context, supplement), + onEdit: () => _editSupplement(context, supplement), + onDelete: () => _deleteSupplement(context, supplement), + onArchive: () => _archiveSupplement(context, supplement), + ), + ), + const SizedBox(height: 16), + ], + + if (groupedSupplements['night']!.isNotEmpty) ...[ + _buildSectionHeader('Night (${settingsProvider.nightRange})', Icons.bedtime, Colors.purple, groupedSupplements['night']!.length), + ...groupedSupplements['night']!.map((supplement) => + SupplementCard( + supplement: supplement, + onTake: () => _showTakeDialog(context, supplement), + onEdit: () => _editSupplement(context, supplement), + onDelete: () => _deleteSupplement(context, supplement), + onArchive: () => _archiveSupplement(context, supplement), + ), + ), + const SizedBox(height: 16), + ], + + if (groupedSupplements['anytime']!.isNotEmpty) ...[ + _buildSectionHeader('Anytime', Icons.schedule, Colors.grey, groupedSupplements['anytime']!.length), + ...groupedSupplements['anytime']!.map((supplement) => + SupplementCard( + supplement: supplement, + onTake: () => _showTakeDialog(context, supplement), + onEdit: () => _editSupplement(context, supplement), + onDelete: () => _deleteSupplement(context, supplement), + onArchive: () => _archiveSupplement(context, supplement), + ), + ), + ], + ], + ); + } + + Widget _buildSectionHeader(String title, IconData icon, Color color, int count) { + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: color.withOpacity(0.3), + width: 1, + ), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: color.withOpacity(0.2), + shape: BoxShape.circle, + ), + child: Icon( + icon, + size: 20, + color: color, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title.contains('(') ? title.split('(')[0].trim() : title, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: color, + ), + ), + if (title.contains('(')) ...[ + Text( + '(${title.split('(')[1]}', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: color.withOpacity(0.8), + ), + ), + ], + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: color.withOpacity(0.15), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + count.toString(), + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: color, + ), + ), + ), + ], + ), + ); + } + + Map> _groupSupplementsByTimeOfDay(List supplements, SettingsProvider settingsProvider) { + final Map> grouped = { + 'morning': [], + 'afternoon': [], + 'evening': [], + 'night': [], + 'anytime': [], + }; + + for (final supplement in supplements) { + final category = settingsProvider.determineTimeCategory(supplement.reminderTimes); + grouped[category]!.add(supplement); + } + + return grouped; + } + void _showTakeDialog(BuildContext context, Supplement supplement) { final unitsController = TextEditingController(text: supplement.numberOfUnits.toString()); final notesController = TextEditingController(); + DateTime selectedDateTime = DateTime.now(); + bool useCustomTime = false; showDialog( context: context, builder: (context) => StatefulBuilder( builder: (context, setState) { - final units = double.tryParse(unitsController.text) ?? supplement.numberOfUnits.toDouble(); - final totalDosage = supplement.dosageAmount * units; - return AlertDialog( title: Text('Take ${supplement.name}'), content: Column( @@ -175,7 +293,7 @@ class SupplementsListScreen extends StatelessWidget { ), ), Text( - '${totalDosage.toStringAsFixed(totalDosage % 1 == 0 ? 0 : 1)} ${supplement.unit}', + supplement.ingredientsDisplay, style: TextStyle( fontSize: 12, fontWeight: FontWeight.w600, @@ -186,6 +304,162 @@ class SupplementsListScreen extends StatelessWidget { ), ), const SizedBox(height: 16), + + // Time selection section + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer.withOpacity(0.3), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context).colorScheme.primary.withOpacity(0.3), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.access_time, + size: 16, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 6), + Text( + 'When did you take it?', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.primary, + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: RadioListTile( + dense: true, + contentPadding: EdgeInsets.zero, + title: const Text('Just now', style: TextStyle(fontSize: 12)), + value: false, + groupValue: useCustomTime, + onChanged: (value) => setState(() => useCustomTime = value!), + ), + ), + Expanded( + child: RadioListTile( + dense: true, + contentPadding: EdgeInsets.zero, + title: const Text('Custom time', style: TextStyle(fontSize: 12)), + value: true, + groupValue: useCustomTime, + onChanged: (value) => setState(() => useCustomTime = value!), + ), + ), + ], + ), + if (useCustomTime) ...[ + const SizedBox(height: 8), + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: Theme.of(context).colorScheme.outline.withOpacity(0.5), + ), + ), + child: Column( + children: [ + // Date picker + Row( + children: [ + Icon( + Icons.calendar_today, + size: 14, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Date: ${selectedDateTime.day}/${selectedDateTime.month}/${selectedDateTime.year}', + style: const TextStyle(fontSize: 12), + ), + ), + TextButton( + onPressed: () async { + final date = await showDatePicker( + context: context, + initialDate: selectedDateTime, + firstDate: DateTime.now().subtract(const Duration(days: 7)), + lastDate: DateTime.now(), + ); + if (date != null) { + setState(() { + selectedDateTime = DateTime( + date.year, + date.month, + date.day, + selectedDateTime.hour, + selectedDateTime.minute, + ); + }); + } + }, + child: const Text('Change', style: TextStyle(fontSize: 10)), + ), + ], + ), + const SizedBox(height: 4), + // Time picker + Row( + children: [ + Icon( + Icons.access_time, + size: 14, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Time: ${selectedDateTime.hour.toString().padLeft(2, '0')}:${selectedDateTime.minute.toString().padLeft(2, '0')}', + style: const TextStyle(fontSize: 12), + ), + ), + TextButton( + onPressed: () async { + final time = await showTimePicker( + context: context, + initialTime: TimeOfDay.fromDateTime(selectedDateTime), + ); + if (time != null) { + setState(() { + selectedDateTime = DateTime( + selectedDateTime.year, + selectedDateTime.month, + selectedDateTime.day, + time.hour, + time.minute, + ); + }); + } + }, + child: const Text('Change', style: TextStyle(fontSize: 10)), + ), + ], + ), + ], + ), + ), + ], + ], + ), + ), + const SizedBox(height: 16), TextField( controller: notesController, decoration: const InputDecoration( @@ -204,12 +478,15 @@ class SupplementsListScreen extends StatelessWidget { ElevatedButton( onPressed: () { final unitsTaken = double.tryParse(unitsController.text) ?? supplement.numberOfUnits.toDouble(); - final totalDosageTaken = supplement.dosageAmount * unitsTaken; + // For now, we'll record 0 as total dosage since we're transitioning to ingredients + // This will be properly implemented when we add the full ingredient tracking + final totalDosageTaken = 0.0; context.read().recordIntake( supplement.id!, totalDosageTaken, unitsTaken: unitsTaken, notes: notesController.text.isNotEmpty ? notesController.text : null, + takenAt: useCustomTime ? selectedDateTime : null, ); Navigator.of(context).pop(); ScaffoldMessenger.of(context).showSnackBar( @@ -219,7 +496,7 @@ class SupplementsListScreen extends StatelessWidget { ), ); }, - child: const Text('Take'), + child: const Text('Record'), ), ], ); @@ -265,4 +542,34 @@ class SupplementsListScreen extends StatelessWidget { ), ); } + + void _archiveSupplement(BuildContext context, Supplement supplement) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Archive Supplement'), + content: Text('Are you sure you want to archive ${supplement.name}? You can unarchive it later from the archived supplements list.'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () { + context.read().archiveSupplement(supplement.id!); + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('${supplement.name} archived'), + backgroundColor: Colors.orange, + ), + ); + }, + style: ElevatedButton.styleFrom(backgroundColor: Colors.orange), + child: const Text('Archive'), + ), + ], + ), + ); + } } diff --git a/lib/services/database_helper.dart b/lib/services/database_helper.dart index 4f70a29..93de080 100644 --- a/lib/services/database_helper.dart +++ b/lib/services/database_helper.dart @@ -2,15 +2,17 @@ import 'package:sqflite/sqflite.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:path/path.dart'; import 'dart:io'; +import 'dart:convert'; import '../models/supplement.dart'; import '../models/supplement_intake.dart'; class DatabaseHelper { static const _databaseName = 'supplements.db'; - static const _databaseVersion = 2; // Increment version for schema changes + static const _databaseVersion = 5; // Increment version for notification tracking static const supplementsTable = 'supplements'; static const intakesTable = 'supplement_intakes'; + static const notificationTrackingTable = 'notification_tracking'; DatabaseHelper._privateConstructor(); static final DatabaseHelper instance = DatabaseHelper._privateConstructor(); @@ -50,9 +52,9 @@ class DatabaseHelper { CREATE TABLE $supplementsTable ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, - dosageAmount REAL NOT NULL, + brand TEXT, + ingredients TEXT NOT NULL DEFAULT '[]', numberOfUnits INTEGER NOT NULL DEFAULT 1, - unit TEXT NOT NULL, unitType TEXT NOT NULL DEFAULT 'units', frequencyPerDay INTEGER NOT NULL, reminderTimes TEXT NOT NULL, @@ -73,6 +75,20 @@ class DatabaseHelper { FOREIGN KEY (supplementId) REFERENCES $supplementsTable (id) ) '''); + + await db.execute(''' + CREATE TABLE $notificationTrackingTable ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + notificationId INTEGER NOT NULL UNIQUE, + supplementId INTEGER NOT NULL, + scheduledTime TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + retryCount INTEGER NOT NULL DEFAULT 0, + lastRetryTime TEXT, + createdAt TEXT NOT NULL, + FOREIGN KEY (supplementId) REFERENCES $supplementsTable (id) + ) + '''); } Future _onUpgrade(Database db, int oldVersion, int newVersion) async { @@ -97,6 +113,7 @@ class DatabaseHelper { CREATE TABLE ${supplementsTable}_new ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, + brand TEXT, dosageAmount REAL NOT NULL, numberOfUnits INTEGER NOT NULL DEFAULT 1, unit TEXT NOT NULL, @@ -112,8 +129,8 @@ class DatabaseHelper { // Copy data to new table await db.execute(''' INSERT INTO ${supplementsTable}_new - (id, name, dosageAmount, numberOfUnits, unit, unitType, frequencyPerDay, reminderTimes, notes, createdAt, isActive) - SELECT id, name, dosageAmount, numberOfUnits, unit, unitType, frequencyPerDay, reminderTimes, notes, createdAt, isActive + (id, name, brand, dosageAmount, numberOfUnits, unit, unitType, frequencyPerDay, reminderTimes, notes, createdAt, isActive) + SELECT id, name, NULL as brand, dosageAmount, numberOfUnits, unit, unitType, frequencyPerDay, reminderTimes, notes, createdAt, isActive FROM $supplementsTable '''); @@ -121,6 +138,86 @@ class DatabaseHelper { await db.execute('DROP TABLE $supplementsTable'); await db.execute('ALTER TABLE ${supplementsTable}_new RENAME TO $supplementsTable'); } + + if (oldVersion < 3) { + // Add brand column for version 3 + await db.execute('ALTER TABLE $supplementsTable ADD COLUMN brand TEXT'); + } + + if (oldVersion < 4) { + // Complete migration to new ingredient-based schema + // Add ingredients column and migrate old data + await db.execute('ALTER TABLE $supplementsTable ADD COLUMN ingredients TEXT DEFAULT "[]"'); + + // Migrate existing supplements to use ingredients format + final supplements = await db.query(supplementsTable); + for (final supplement in supplements) { + final dosageAmount = supplement['dosageAmount'] as double?; + final unit = supplement['unit'] as String?; + final name = supplement['name'] as String; + + if (dosageAmount != null && unit != null && dosageAmount > 0) { + // Create a single ingredient from the old dosage data + final ingredient = { + 'name': name, + 'amount': dosageAmount, + 'unit': unit, + }; + final ingredientsJson = jsonEncode([ingredient]); + + await db.update( + supplementsTable, + {'ingredients': ingredientsJson}, + where: 'id = ?', + whereArgs: [supplement['id']], + ); + } + } + + // Remove old columns + await db.execute(''' + CREATE TABLE ${supplementsTable}_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + brand TEXT, + ingredients TEXT NOT NULL DEFAULT '[]', + numberOfUnits INTEGER NOT NULL DEFAULT 1, + unitType TEXT NOT NULL DEFAULT 'units', + frequencyPerDay INTEGER NOT NULL, + reminderTimes TEXT NOT NULL, + notes TEXT, + createdAt TEXT NOT NULL, + isActive INTEGER NOT NULL DEFAULT 1 + ) + '''); + + await db.execute(''' + INSERT INTO ${supplementsTable}_new + (id, name, brand, ingredients, numberOfUnits, unitType, frequencyPerDay, reminderTimes, notes, createdAt, isActive) + SELECT id, name, brand, ingredients, numberOfUnits, unitType, frequencyPerDay, reminderTimes, notes, createdAt, isActive + FROM $supplementsTable + '''); + + await db.execute('DROP TABLE $supplementsTable'); + await db.execute('ALTER TABLE ${supplementsTable}_new RENAME TO $supplementsTable'); + } + + if (oldVersion < 5) { + // Add notification tracking table + await db.execute(''' + CREATE TABLE $notificationTrackingTable ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + notificationId INTEGER NOT NULL UNIQUE, + supplementId INTEGER NOT NULL, + scheduledTime TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + retryCount INTEGER NOT NULL DEFAULT 0, + lastRetryTime TEXT, + createdAt TEXT NOT NULL, + FOREIGN KEY (supplementId) REFERENCES $supplementsTable (id) + ) + '''); + } } // Supplement CRUD operations @@ -140,6 +237,37 @@ class DatabaseHelper { return List.generate(maps.length, (i) => Supplement.fromMap(maps[i])); } + Future> getArchivedSupplements() async { + Database db = await database; + List> maps = await db.query( + supplementsTable, + where: 'isActive = ?', + whereArgs: [0], + orderBy: 'name ASC', + ); + return List.generate(maps.length, (i) => Supplement.fromMap(maps[i])); + } + + Future archiveSupplement(int id) async { + Database db = await database; + await db.update( + supplementsTable, + {'isActive': 0}, + where: 'id = ?', + whereArgs: [id], + ); + } + + Future unarchiveSupplement(int id) async { + Database db = await database; + await db.update( + supplementsTable, + {'isActive': 1}, + where: 'id = ?', + whereArgs: [id], + ); + } + Future getSupplement(int id) async { Database db = await database; List> maps = await db.query( @@ -213,7 +341,10 @@ class DatabaseHelper { String endDate = DateTime(date.year, date.month, date.day, 23, 59, 59).toIso8601String(); List> result = await db.rawQuery(''' - SELECT i.*, s.name as supplementName, s.unit as supplementUnit, s.unitType as supplementUnitType + SELECT i.*, + i.supplementId as supplement_id, + s.name as supplementName, + s.unitType as supplementUnitType FROM $intakesTable i JOIN $supplementsTable s ON i.supplementId = s.id WHERE i.takenAt >= ? AND i.takenAt <= ? @@ -229,7 +360,10 @@ class DatabaseHelper { String endDate = DateTime(year, month + 1, 0, 23, 59, 59).toIso8601String(); List> result = await db.rawQuery(''' - SELECT i.*, s.name as supplementName, s.unit as supplementUnit, s.unitType as supplementUnitType + SELECT i.*, + i.supplementId as supplement_id, + s.name as supplementName, + s.unitType as supplementUnitType FROM $intakesTable i JOIN $supplementsTable s ON i.supplementId = s.id WHERE i.takenAt >= ? AND i.takenAt <= ? @@ -247,4 +381,90 @@ class DatabaseHelper { whereArgs: [id], ); } + + // Notification tracking methods + Future trackNotification({ + required int notificationId, + required int supplementId, + required DateTime scheduledTime, + }) async { + Database db = await database; + + // Use INSERT OR REPLACE to handle both new and existing notifications + await db.rawInsert(''' + INSERT OR REPLACE INTO $notificationTrackingTable + (notificationId, supplementId, scheduledTime, status, retryCount, lastRetryTime, createdAt) + VALUES (?, ?, ?, ?, ?, ?, ?) + ''', [ + notificationId, + supplementId, + scheduledTime.toIso8601String(), + 'pending', + 0, + null, + DateTime.now().toIso8601String(), + ]); + + return notificationId; + } + + Future markNotificationTaken(int notificationId) async { + Database db = await database; + await db.update( + notificationTrackingTable, + {'status': 'taken'}, + where: 'notificationId = ?', + whereArgs: [notificationId], + ); + } + + Future incrementRetryCount(int notificationId) async { + Database db = await database; + await db.rawUpdate(''' + UPDATE $notificationTrackingTable + SET retryCount = retryCount + 1, + lastRetryTime = ?, + status = 'retrying' + WHERE notificationId = ? + ''', [DateTime.now().toIso8601String(), notificationId]); + } + + Future>> getPendingNotifications() async { + Database db = await database; + return await db.query( + notificationTrackingTable, + where: 'status IN (?, ?)', + whereArgs: ['pending', 'retrying'], + ); + } + + Future markNotificationExpired(int notificationId) async { + Database db = await database; + await db.update( + notificationTrackingTable, + {'status': 'expired'}, + where: 'notificationId = ?', + whereArgs: [notificationId], + ); + } + + Future cleanupOldNotificationTracking() async { + Database db = await database; + // Remove tracking records older than 7 days + final cutoffDate = DateTime.now().subtract(const Duration(days: 7)).toIso8601String(); + await db.delete( + notificationTrackingTable, + where: 'createdAt < ?', + whereArgs: [cutoffDate], + ); + } + + Future clearNotificationTracking(int supplementId) async { + Database db = await database; + await db.delete( + notificationTrackingTable, + where: 'supplementId = ?', + whereArgs: [supplementId], + ); + } } diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index a05cac8..e5d70db 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -2,6 +2,24 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:timezone/timezone.dart' as tz; import 'package:timezone/data/latest.dart' as tz; import '../models/supplement.dart'; +import 'database_helper.dart'; + +// Top-level function to handle notification responses when app is running +@pragma('vm:entry-point') +void notificationTapBackground(NotificationResponse notificationResponse) { + print('📱 === BACKGROUND NOTIFICATION RESPONSE ==='); + print('📱 Action ID: ${notificationResponse.actionId}'); + print('📱 Payload: ${notificationResponse.payload}'); + print('📱 Notification ID: ${notificationResponse.id}'); + print('📱 =========================================='); + + // For now, just log the action. The main app handler will process it. + if (notificationResponse.actionId == 'take_supplement') { + print('📱 BACKGROUND: Take action detected'); + } else if (notificationResponse.actionId == 'snooze_10') { + print('📱 BACKGROUND: Snooze action detected'); + } +} class NotificationService { static final NotificationService _instance = NotificationService._internal(); @@ -9,15 +27,81 @@ class NotificationService { NotificationService._internal(); final FlutterLocalNotificationsPlugin _notifications = FlutterLocalNotificationsPlugin(); + bool _isInitialized = false; + static bool _engineInitialized = false; + bool _permissionsRequested = false; + + // Callback for handling supplement intake from notifications + Function(int supplementId, String supplementName, double units, String unitType)? _onTakeSupplementCallback; + + // Set callback for handling supplement intake from notifications + void setTakeSupplementCallback(Function(int supplementId, String supplementName, double units, String unitType) callback) { + _onTakeSupplementCallback = callback; + } Future initialize() async { - tz.initializeTimeZones(); + print('📱 Initializing NotificationService...'); + if (_isInitialized) { + print('📱 Already initialized'); + return; + } + + try { + print('📱 Initializing timezones...'); + print('📱 Engine initialized flag: $_engineInitialized'); + + if (!_engineInitialized) { + tz.initializeTimeZones(); + _engineInitialized = true; + print('📱 Timezones initialized successfully'); + } else { + print('📱 Timezones already initialized, skipping'); + } + } catch (e) { + print('📱 Warning: Timezone initialization issue (may already be initialized): $e'); + _engineInitialized = true; // Mark as initialized to prevent retry + } + + // Try to detect and set the local timezone more reliably + try { + // First try using the system timezone name + final String timeZoneName = DateTime.now().timeZoneName; + print('📱 System timezone name: $timeZoneName'); + + tz.Location? location; + + // Try common timezone mappings for your region + if (timeZoneName.contains('CET') || timeZoneName.contains('CEST')) { + location = tz.getLocation('Europe/Amsterdam'); // Netherlands + } else if (timeZoneName.contains('UTC') || timeZoneName.contains('GMT')) { + location = tz.getLocation('UTC'); + } else { + // Fallback: try to use the timezone name directly + try { + location = tz.getLocation(timeZoneName); + } catch (e) { + print('📱 Could not find timezone $timeZoneName, using Europe/Amsterdam as default'); + location = tz.getLocation('Europe/Amsterdam'); + } + } + + tz.setLocalLocation(location); + print('📱 Timezone set to: ${location.name}'); + + } catch (e) { + print('📱 Error setting timezone: $e, using default'); + // Fallback to a reasonable default for Netherlands + tz.setLocalLocation(tz.getLocation('Europe/Amsterdam')); + } + + print('📱 Current local time: ${tz.TZDateTime.now(tz.local)}'); + print('📱 Current system time: ${DateTime.now()}'); const AndroidInitializationSettings androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher'); const DarwinInitializationSettings iosSettings = DarwinInitializationSettings( - requestAlertPermission: true, - requestBadgePermission: true, - requestSoundPermission: true, + requestAlertPermission: false, // We'll request these separately + requestBadgePermission: false, + requestSoundPermission: false, ); const LinuxInitializationSettings linuxSettings = LinuxInitializationSettings( defaultActionName: 'Open notification', @@ -29,28 +113,347 @@ class NotificationService { linux: linuxSettings, ); - await _notifications.initialize(initSettings); + print('📱 Initializing flutter_local_notifications...'); + await _notifications.initialize( + initSettings, + onDidReceiveNotificationResponse: _onNotificationResponse, + onDidReceiveBackgroundNotificationResponse: notificationTapBackground, + ); + + // Test if notification response callback is working + print('📱 Callback function is set and ready'); + + _isInitialized = true; + print('📱 NotificationService initialization complete'); + } + + // Handle notification responses (when user taps on notification or action) + void _onNotificationResponse(NotificationResponse response) { + print('📱 === NOTIFICATION RESPONSE ==='); + print('📱 Action ID: ${response.actionId}'); + print('📱 Payload: ${response.payload}'); + print('📱 Notification ID: ${response.id}'); + print('📱 Input: ${response.input}'); + print('📱 ==============================='); + + if (response.actionId == 'take_supplement') { + print('📱 Processing TAKE action...'); + _handleTakeAction(response.payload, response.id); + } else if (response.actionId == 'snooze_10') { + print('📱 Processing SNOOZE action...'); + _handleSnoozeAction(response.payload, 10, response.id); + } else { + print('📱 Default notification tap (no specific action)'); + // Default tap (no actionId) opens the app normally + } + } + + void _handleTakeAction(String? payload, int? notificationId) { + print('📱 === HANDLING TAKE ACTION ==='); + print('📱 Payload received: $payload'); + + if (payload != null) { + try { + // Parse the payload to get supplement info + final parts = payload.split('|'); + print('📱 Payload parts: $parts (length: ${parts.length})'); + + if (parts.length >= 4) { + final supplementId = int.parse(parts[0]); + final supplementName = parts[1]; + final units = double.parse(parts[2]); + final unitType = parts[3]; + + print('📱 Parsed data:'); + print('📱 - ID: $supplementId'); + print('📱 - Name: $supplementName'); + print('📱 - Units: $units'); + print('📱 - Type: $unitType'); + + // Call the callback to record the intake + if (_onTakeSupplementCallback != null) { + print('📱 Calling supplement callback...'); + _onTakeSupplementCallback!(supplementId, supplementName, units, unitType); + print('📱 Callback completed'); + } else { + print('📱 ERROR: No callback registered!'); + } + + // 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); + + // Cancel any pending retry notifications for this notification + _cancelRetryNotifications(notificationId); + } + + // Show a confirmation notification + print('📱 Showing confirmation notification...'); + showInstantNotification( + 'Supplement Taken!', + '$supplementName has been recorded at ${DateTime.now().hour.toString().padLeft(2, '0')}:${DateTime.now().minute.toString().padLeft(2, '0')}', + ); + } else { + print('📱 ERROR: Invalid payload format - not enough parts'); + } + } catch (e) { + print('📱 ERROR in _handleTakeAction: $e'); + } + } else { + print('📱 ERROR: Payload is null'); + } + print('📱 === TAKE ACTION COMPLETE ==='); + } + + void _cancelRetryNotifications(int notificationId) { + // Retry notifications use ID range starting from 200000 + for (int i = 0; i < 10; i++) { // Cancel up to 10 potential retries + int retryId = 200000 + (notificationId * 10) + i; + _notifications.cancel(retryId); + print('📱 Cancelled retry notification ID: $retryId'); + } + } + + void _handleSnoozeAction(String? payload, int minutes, int? notificationId) { + print('📱 === HANDLING SNOOZE ACTION ==='); + print('📱 Payload: $payload, Minutes: $minutes'); + + if (payload != null) { + try { + final parts = payload.split('|'); + if (parts.length >= 2) { + final supplementId = int.parse(parts[0]); + final supplementName = parts[1]; + + print('📱 Snoozing supplement for $minutes minutes: $supplementName'); + + // Mark notification as snoozed in database (increment retry count) + if (notificationId != null) { + print('📱 Incrementing retry count for notification $notificationId'); + DatabaseHelper.instance.incrementRetryCount(notificationId); + } + + // Schedule a new notification for the snooze time + final snoozeTime = tz.TZDateTime.now(tz.local).add(Duration(minutes: minutes)); + print('📱 Snooze time: $snoozeTime'); + + _notifications.zonedSchedule( + supplementId * 1000 + minutes, // Unique ID for snooze notifications + 'Reminder: $supplementName', + 'Snoozed reminder - Take your $supplementName now', + snoozeTime, + NotificationDetails( + android: AndroidNotificationDetails( + 'supplement_reminders', + 'Supplement Reminders', + channelDescription: 'Notifications for supplement intake reminders', + importance: Importance.high, + priority: Priority.high, + actions: [ + AndroidNotificationAction( + 'take_supplement', + 'Take', + ), + AndroidNotificationAction( + 'snooze_10', + 'Snooze 10min', + ), + ], + ), + iOS: const DarwinNotificationDetails(), + ), + androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, + payload: payload, + ); + + showInstantNotification( + 'Reminder Snoozed', + '$supplementName reminder snoozed for $minutes minutes', + ); + print('📱 Snooze scheduled successfully'); + } + } catch (e) { + print('📱 Error handling snooze action: $e'); + } + } + print('📱 === SNOOZE ACTION COMPLETE ==='); + } + + /// Check for persistent reminders from app context with settings + Future checkPersistentReminders( + bool persistentReminders, + int reminderRetryInterval, + int maxRetryAttempts, + ) async { + await schedulePersistentReminders( + persistentReminders: persistentReminders, + reminderRetryInterval: reminderRetryInterval, + maxRetryAttempts: maxRetryAttempts, + ); + } + + /// Check for pending notifications that need retry and schedule them + Future schedulePersistentReminders({ + required bool persistentReminders, + required int reminderRetryInterval, + required int maxRetryAttempts, + }) async { + print('📱 Checking for pending notifications to retry...'); + + try { + if (!persistentReminders) { + print('📱 Persistent reminders disabled'); + return; + } + + print('📱 Retry settings: interval=$reminderRetryInterval min, max=$maxRetryAttempts attempts'); + + // Get all pending notifications from database + final pendingNotifications = await DatabaseHelper.instance.getPendingNotifications(); + print('📱 Found ${pendingNotifications.length} pending notifications'); + + final now = DateTime.now(); + + for (final notification in pendingNotifications) { + final scheduledTime = DateTime.parse(notification['scheduledTime']); + final retryCount = notification['retryCount'] as int; + final lastRetryTime = notification['lastRetryTime'] != null + ? DateTime.parse(notification['lastRetryTime']) + : null; + + // Check if notification is overdue + final timeSinceScheduled = now.difference(scheduledTime).inMinutes; + final shouldRetry = timeSinceScheduled >= reminderRetryInterval; + + // Check if we haven't exceeded max retry attempts + if (retryCount >= maxRetryAttempts) { + print('📱 Notification ${notification['notificationId']} exceeded max attempts ($maxRetryAttempts)'); + continue; + } + + // Check if enough time has passed since last retry + if (lastRetryTime != null) { + final timeSinceLastRetry = now.difference(lastRetryTime).inMinutes; + if (timeSinceLastRetry < reminderRetryInterval) { + print('📱 Notification ${notification['notificationId']} not ready for retry yet'); + continue; + } + } + + if (shouldRetry) { + await _scheduleRetryNotification(notification, retryCount + 1); + } + } + } catch (e) { + print('📱 Error scheduling persistent reminders: $e'); + } + } + + Future _scheduleRetryNotification(Map notification, int retryAttempt) async { + try { + final notificationId = notification['notificationId'] as int; + final supplementId = notification['supplementId'] as int; + + // Generate a unique ID for this retry (200000 + original_id * 10 + retry_attempt) + final retryNotificationId = 200000 + (notificationId * 10) + retryAttempt; + + print('📱 Scheduling retry notification $retryNotificationId for supplement $supplementId (attempt $retryAttempt)'); + + // Get supplement details from database + final supplements = await DatabaseHelper.instance.getAllSupplements(); + final supplement = supplements.firstWhere((s) => s.id == supplementId && s.isActive, orElse: () => throw Exception('Supplement not found')); + + // Schedule the retry notification immediately + await _notifications.show( + retryNotificationId, + 'Reminder: ${supplement.name}', + 'Don\'t forget to take your ${supplement.name}! (Retry #$retryAttempt)', + NotificationDetails( + android: AndroidNotificationDetails( + 'supplement_reminders', + 'Supplement Reminders', + channelDescription: 'Notifications for supplement intake reminders', + importance: Importance.high, + priority: Priority.high, + actions: [ + AndroidNotificationAction( + 'take_supplement', + 'Take', + showsUserInterface: true, + icon: DrawableResourceAndroidBitmap('@drawable/ic_check'), + ), + AndroidNotificationAction( + 'snooze_10', + 'Snooze 10min', + showsUserInterface: true, + icon: DrawableResourceAndroidBitmap('@drawable/ic_snooze'), + ), + ], + ), + iOS: const DarwinNotificationDetails(), + ), + payload: '${supplement.id}|${supplement.name}|${supplement.numberOfUnits}|${supplement.unitType}|$notificationId', + ); + + // Update the retry count in database + await DatabaseHelper.instance.incrementRetryCount(notificationId); + + print('📱 Retry notification scheduled successfully'); + } catch (e) { + print('📱 Error scheduling retry notification: $e'); + } } Future requestPermissions() async { - final androidPlugin = _notifications.resolvePlatformSpecificImplementation(); - if (androidPlugin != null) { - await androidPlugin.requestNotificationsPermission(); + print('📱 Requesting notification permissions...'); + if (_permissionsRequested) { + print('📱 Permissions already requested'); + return true; } - final iosPlugin = _notifications.resolvePlatformSpecificImplementation(); - if (iosPlugin != null) { - await iosPlugin.requestPermissions( - alert: true, - badge: true, - sound: true, - ); + try { + _permissionsRequested = true; + + final androidPlugin = _notifications.resolvePlatformSpecificImplementation(); + if (androidPlugin != null) { + print('📱 Requesting Android permissions...'); + final granted = await androidPlugin.requestNotificationsPermission(); + print('📱 Android permissions granted: $granted'); + if (granted != true) { + _permissionsRequested = false; + return false; + } + } + + final iosPlugin = _notifications.resolvePlatformSpecificImplementation(); + if (iosPlugin != null) { + print('📱 Requesting iOS permissions...'); + final granted = await iosPlugin.requestPermissions( + alert: true, + badge: true, + sound: true, + ); + print('📱 iOS permissions granted: $granted'); + if (granted != true) { + _permissionsRequested = false; + return false; + } + } + + print('📱 All permissions granted successfully'); + return true; + } catch (e) { + _permissionsRequested = false; + print('📱 Error requesting permissions: $e'); + return false; } - - return true; } Future scheduleSupplementReminders(Supplement supplement) async { + print('📱 Scheduling reminders for ${supplement.name}'); + print('📱 Reminder times: ${supplement.reminderTimes}'); + // Cancel existing notifications for this supplement await cancelSupplementReminders(supplement.id!); @@ -61,25 +464,59 @@ class NotificationService { final minute = int.parse(timeParts[1]); final notificationId = supplement.id! * 100 + i; // Unique ID for each reminder + final scheduledTime = _nextInstanceOfTime(hour, minute); + + print('📱 Scheduling notification ID $notificationId for ${timeStr} -> ${scheduledTime}'); + + // Track this notification in the database + await DatabaseHelper.instance.trackNotification( + notificationId: notificationId, + supplementId: supplement.id!, + scheduledTime: scheduledTime.toLocal(), + ); await _notifications.zonedSchedule( notificationId, 'Time for ${supplement.name}', - 'Take ${supplement.numberOfUnits} ${supplement.unitType} (${supplement.totalDosagePerIntake} ${supplement.unit})', - _nextInstanceOfTime(hour, minute), - const NotificationDetails( + 'Take ${supplement.numberOfUnits} ${supplement.unitType} (${supplement.ingredientsPerUnit})', + scheduledTime, + NotificationDetails( android: AndroidNotificationDetails( 'supplement_reminders', 'Supplement Reminders', channelDescription: 'Notifications for supplement intake reminders', importance: Importance.high, priority: Priority.high, + actions: [ + AndroidNotificationAction( + 'take_supplement', + 'Take', + icon: DrawableResourceAndroidBitmap('@android:drawable/ic_menu_save'), + showsUserInterface: true, // Changed to true to open app + ), + AndroidNotificationAction( + 'snooze_10', + 'Snooze 10min', + icon: DrawableResourceAndroidBitmap('@android:drawable/ic_menu_recent_history'), + showsUserInterface: true, // Changed to true to open app + ), + ], ), - iOS: DarwinNotificationDetails(), + iOS: const DarwinNotificationDetails(), ), androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, matchDateTimeComponents: DateTimeComponents.time, + payload: '${supplement.id}|${supplement.name}|${supplement.numberOfUnits}|${supplement.unitType}', ); + + print('📱 Successfully scheduled notification ID $notificationId'); + } + + // Get all pending notifications to verify + final pendingNotifications = await _notifications.pendingNotificationRequests(); + print('📱 Total pending notifications: ${pendingNotifications.length}'); + for (final notification in pendingNotifications) { + print('📱 Pending: ID=${notification.id}, Title=${notification.title}'); } } @@ -89,6 +526,9 @@ class NotificationService { final notificationId = supplementId * 100 + i; await _notifications.cancel(notificationId); } + + // Also clean up database tracking records for this supplement + await DatabaseHelper.instance.clearNotificationTracking(supplementId); } Future cancelAllReminders() async { @@ -99,14 +539,22 @@ class NotificationService { final tz.TZDateTime now = tz.TZDateTime.now(tz.local); tz.TZDateTime scheduledDate = tz.TZDateTime(tz.local, now.year, now.month, now.day, hour, minute); + print('📱 Current time: $now (${now.timeZoneName})'); + print('📱 Target time: ${hour.toString().padLeft(2, '0')}:${minute.toString().padLeft(2, '0')}'); + print('📱 Initial scheduled date: $scheduledDate (${scheduledDate.timeZoneName})'); + if (scheduledDate.isBefore(now)) { scheduledDate = scheduledDate.add(const Duration(days: 1)); + print('📱 Time has passed, scheduling for tomorrow: $scheduledDate (${scheduledDate.timeZoneName})'); + } else { + print('📱 Time is in the future, scheduling for today: $scheduledDate (${scheduledDate.timeZoneName})'); } return scheduledDate; } Future showInstantNotification(String title, String body) async { + print('📱 Showing instant notification: $title - $body'); const NotificationDetails notificationDetails = NotificationDetails( android: AndroidNotificationDetails( 'instant_notifications', @@ -124,5 +572,108 @@ class NotificationService { body, notificationDetails, ); + print('📱 Instant notification sent'); + } + + // Debug function to test notifications + Future testNotification() async { + print('📱 Testing notification system...'); + await showInstantNotification('Test Notification', 'This is a test notification to verify the system is working.'); + } + + // Debug function to schedule a test notification 1 minute from now + Future testScheduledNotification() async { + print('📱 Testing scheduled notification...'); + final now = tz.TZDateTime.now(tz.local); + final testTime = now.add(const Duration(minutes: 1)); + + print('📱 Scheduling test notification for: $testTime'); + + await _notifications.zonedSchedule( + 99999, // Special ID for test notifications + 'Test Scheduled Notification', + 'This notification was scheduled 1 minute ago at ${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}', + testTime, + const NotificationDetails( + android: AndroidNotificationDetails( + 'test_notifications', + 'Test Notifications', + channelDescription: 'Test notifications for debugging', + importance: Importance.high, + priority: Priority.high, + ), + iOS: DarwinNotificationDetails(), + ), + androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, + ); + + print('📱 Test notification scheduled successfully'); + } + + // Debug function to get all pending notifications + Future> getPendingNotifications() async { + return await _notifications.pendingNotificationRequests(); + } + + // Debug function to test notification actions + Future testNotificationWithActions() async { + print('📱 Creating test notification with actions...'); + + await _notifications.show( + 88888, // Special test ID + 'Test Action Notification', + 'Tap Take or Snooze to test notification actions', + NotificationDetails( + android: AndroidNotificationDetails( + 'test_notifications', + 'Test Notifications', + channelDescription: 'Test notifications for debugging actions', + importance: Importance.high, + priority: Priority.high, + actions: [ + AndroidNotificationAction( + 'take_supplement', + 'Take', + icon: DrawableResourceAndroidBitmap('@android:drawable/ic_menu_save'), + showsUserInterface: true, + ), + AndroidNotificationAction( + 'snooze_10', + 'Snooze 10min', + icon: DrawableResourceAndroidBitmap('@android:drawable/ic_menu_recent_history'), + showsUserInterface: true, + ), + ], + ), + iOS: const DarwinNotificationDetails(), + ), + payload: '999|Test Supplement|1.0|capsule', + ); + + print('📱 Test notification with actions created'); + } + + // Debug function to test basic notification tap response + Future testBasicNotification() async { + print('📱 Creating basic test notification...'); + + await _notifications.show( + 77777, // Special test ID for basic notification + 'Basic Test Notification', + 'Tap this notification to test basic callback', + NotificationDetails( + android: AndroidNotificationDetails( + 'test_notifications', + 'Test Notifications', + channelDescription: 'Test notifications for debugging', + importance: Importance.high, + priority: Priority.high, + ), + iOS: const DarwinNotificationDetails(), + ), + payload: 'basic_test', + ); + + print('📱 Basic test notification created'); } } diff --git a/lib/widgets/supplement_card.dart b/lib/widgets/supplement_card.dart index e8bcbac..b24be76 100644 --- a/lib/widgets/supplement_card.dart +++ b/lib/widgets/supplement_card.dart @@ -1,11 +1,14 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; import '../models/supplement.dart'; +import '../providers/supplement_provider.dart'; -class SupplementCard extends StatelessWidget { +class SupplementCard extends StatefulWidget { final Supplement supplement; final VoidCallback onTake; final VoidCallback onEdit; final VoidCallback onDelete; + final VoidCallback onArchive; const SupplementCard({ super.key, @@ -13,51 +16,204 @@ class SupplementCard extends StatelessWidget { required this.onTake, required this.onEdit, required this.onDelete, + required this.onArchive, }); + @override + State createState() => _SupplementCardState(); +} + +class _SupplementCardState extends State { + bool _isExpanded = false; + @override Widget build(BuildContext context) { - return Card( - margin: const EdgeInsets.only(bottom: 12), - elevation: 2, - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - supplement.name, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 4), - Text( - '${supplement.numberOfUnits} ${supplement.unitType} (${supplement.dosageAmount} ${supplement.unit} each)', - style: TextStyle( - fontSize: 14, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ], + return Consumer( + builder: (context, provider, child) { + final bool isTakenToday = provider.hasBeenTakenToday(widget.supplement.id!); + final int todayIntakeCount = provider.getTodayIntakeCount(widget.supplement.id!); + final bool isCompletelyTaken = todayIntakeCount >= widget.supplement.frequencyPerDay; + + // Get today's intake times for this supplement + final todayIntakes = provider.todayIntakes + .where((intake) => intake['supplement_id'] == widget.supplement.id) + .map((intake) { + final takenAt = DateTime.parse(intake['takenAt']); + final unitsTaken = intake['unitsTaken'] ?? 1.0; + return { + 'time': '${takenAt.hour.toString().padLeft(2, '0')}:${takenAt.minute.toString().padLeft(2, '0')}', + 'units': unitsTaken, + }; + }).toList(); + + return Card( + margin: const EdgeInsets.only(bottom: 16), + elevation: 3, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: isCompletelyTaken + ? Colors.green.shade800 + : isTakenToday + ? Theme.of(context).colorScheme.secondaryContainer + : Theme.of(context).colorScheme.surface, + border: Border.all( + color: isCompletelyTaken + ? Colors.green.shade600 + : isTakenToday + ? Theme.of(context).colorScheme.secondary + : Theme.of(context).colorScheme.outline.withOpacity(0.2), + width: 1.5, + ), + ), + child: Theme( + data: Theme.of(context).copyWith( + dividerColor: Colors.transparent, + ), + child: ExpansionTile( + initiallyExpanded: _isExpanded, + onExpansionChanged: (expanded) { + setState(() { + _isExpanded = expanded; + }); + }, + tilePadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + childrenPadding: const EdgeInsets.fromLTRB(20, 0, 20, 20), + leading: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: isCompletelyTaken + ? Colors.green.shade500 + : isTakenToday + ? Theme.of(context).colorScheme.secondary + : Theme.of(context).colorScheme.primary.withOpacity(0.1), + shape: BoxShape.circle, + ), + child: Icon( + isCompletelyTaken + ? Icons.check_circle + : isTakenToday + ? Icons.schedule + : Icons.medication, + color: isCompletelyTaken + ? Colors.white + : isTakenToday + ? Theme.of(context).colorScheme.onSecondary + : Theme.of(context).colorScheme.primary, + size: 20, ), ), - PopupMenuButton( + title: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.supplement.name, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: isCompletelyTaken + ? Colors.white + : isTakenToday + ? Theme.of(context).colorScheme.onSecondaryContainer + : Theme.of(context).colorScheme.onSurface, + ), + ), + if (widget.supplement.brand != null && widget.supplement.brand!.isNotEmpty) + Text( + widget.supplement.brand!, + style: TextStyle( + fontSize: 12, + color: isCompletelyTaken + ? Colors.green.shade200 + : isTakenToday + ? Theme.of(context).colorScheme.secondary + : Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + // Status badge and take button in collapsed view + if (!_isExpanded) ...[ + if (isCompletelyTaken) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.green.shade500, + borderRadius: BorderRadius.circular(12), + ), + child: const Text( + 'Complete', + style: TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ) + else ...[ + if (isTakenToday) + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + margin: const EdgeInsets.only(right: 8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.secondary, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + '$todayIntakeCount/${widget.supplement.frequencyPerDay}', + style: TextStyle( + color: Theme.of(context).colorScheme.onSecondary, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), + ElevatedButton( + onPressed: isCompletelyTaken ? null : widget.onTake, + style: ElevatedButton.styleFrom( + backgroundColor: isCompletelyTaken + ? Colors.green.shade500 + : Theme.of(context).colorScheme.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + minimumSize: const Size(60, 32), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: Text( + isCompletelyTaken ? '✓' : 'Take', + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600), + ), + ), + ], + ], + ], + ), + trailing: PopupMenuButton( + padding: EdgeInsets.zero, + icon: Icon( + Icons.more_vert, + color: isCompletelyTaken + ? Colors.white + : Theme.of(context).colorScheme.onSurfaceVariant, + ), onSelected: (value) { switch (value) { case 'edit': - onEdit(); + widget.onEdit(); + break; + case 'archive': + widget.onArchive(); break; case 'delete': - onDelete(); + widget.onDelete(); break; } }, @@ -72,6 +228,16 @@ class SupplementCard extends StatelessWidget { ], ), ), + const PopupMenuItem( + value: 'archive', + child: Row( + children: [ + Icon(Icons.archive, color: Colors.orange), + SizedBox(width: 8), + Text('Archive'), + ], + ), + ), const PopupMenuItem( value: 'delete', child: Row( @@ -84,115 +250,269 @@ class SupplementCard extends StatelessWidget { ), ], ), - ], - ), - const SizedBox(height: 12), - Row( - children: [ - Icon(Icons.schedule, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), - const SizedBox(width: 4), - Text( - '${supplement.frequencyPerDay}x daily', - style: TextStyle( - fontSize: 12, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(width: 16), - Icon(Icons.notifications, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), - const SizedBox(width: 4), - Text( - supplement.reminderTimes.join(', '), - style: TextStyle( - fontSize: 12, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ], - ), - if (supplement.notes != null && supplement.notes!.isNotEmpty) ...[ - const SizedBox(height: 8), - Text( - supplement.notes!, - style: TextStyle( - fontSize: 12, - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontStyle: FontStyle.italic, - ), - ), - ], - const SizedBox(height: 12), - - // Take supplement section - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.3), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: Theme.of(context).colorScheme.outline.withOpacity(0.5), - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'Take Supplement', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurface, + // Today's intake times (if any) - only show in expanded view + if (todayIntakes.isNotEmpty) ...[ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: isCompletelyTaken + ? Colors.green.shade700.withOpacity(0.8) + : Theme.of(context).colorScheme.secondaryContainer.withOpacity(0.7), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isCompletelyTaken + ? Colors.green.shade500 + : Theme.of(context).colorScheme.secondary, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.check_circle_outline, + size: 16, + color: isCompletelyTaken + ? Colors.green.shade200 + : Theme.of(context).colorScheme.onSecondaryContainer, + ), + const SizedBox(width: 6), + Text( + 'Taken today:', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: isCompletelyTaken + ? Colors.green.shade200 + : Theme.of(context).colorScheme.onSecondaryContainer, + ), + ), + ], + ), + const SizedBox(height: 6), + Wrap( + spacing: 8, + runSpacing: 4, + children: todayIntakes.map((intake) { + final units = intake['units'] as double; + final unitsText = units == 1.0 + ? '${widget.supplement.unitType}' + : '${units.toStringAsFixed(units % 1 == 0 ? 0 : 1)} ${widget.supplement.unitType}'; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: isCompletelyTaken + ? Colors.green.shade600 + : Theme.of(context).colorScheme.secondary, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + '${intake['time']} • $unitsText', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w500, + color: isCompletelyTaken + ? Colors.white + : Theme.of(context).colorScheme.onSecondary, + ), + ), + ); + }).toList(), + ), + ], + ), + ), + const SizedBox(height: 16), + ], + + // Ingredients section + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.3), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Ingredients per ${widget.supplement.unitType}:', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 4, + children: widget.supplement.ingredients.map((ingredient) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context).colorScheme.primary.withOpacity(0.3), + ), + ), + child: Text( + '${ingredient.name} ${ingredient.amount}${ingredient.unit}', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.primary, + ), + ), + ); + }).toList(), + ), + ], ), ), - const SizedBox(height: 8), + + const SizedBox(height: 12), + + // Schedule and dosage info Row( children: [ Expanded( - child: Row( - children: [ - Text( - 'Amount: ', - style: TextStyle( - fontSize: 12, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - Text( - '${supplement.numberOfUnits} ${supplement.unitType}', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - Text( - ' (${supplement.totalDosagePerIntake} ${supplement.unit})', - style: TextStyle( - fontSize: 10, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ], + child: _InfoChip( + icon: Icons.schedule, + label: '${widget.supplement.frequencyPerDay}x daily', + context: context, ), ), - ElevatedButton.icon( - onPressed: onTake, - icon: const Icon(Icons.medication, size: 16), - label: const Text('Take', style: TextStyle(fontSize: 12)), - style: ElevatedButton.styleFrom( - backgroundColor: Theme.of(context).colorScheme.primary, - foregroundColor: Theme.of(context).colorScheme.onPrimary, - minimumSize: const Size(80, 32), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + const SizedBox(width: 8), + Expanded( + child: _InfoChip( + icon: Icons.medication, + label: '${widget.supplement.numberOfUnits} ${widget.supplement.unitType}', + context: context, ), ), ], ), + + if (widget.supplement.reminderTimes.isNotEmpty) ...[ + const SizedBox(height: 8), + _InfoChip( + icon: Icons.notifications, + label: 'Reminders: ${widget.supplement.reminderTimes.join(', ')}', + context: context, + fullWidth: true, + ), + ], + + if (widget.supplement.notes != null && widget.supplement.notes!.isNotEmpty) ...[ + const SizedBox(height: 8), + Container( + width: double.infinity, + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.2), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + widget.supplement.notes!, + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + ), + ), + ], + + const SizedBox(height: 16), + + // Take button + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: isCompletelyTaken ? null : widget.onTake, + icon: Icon( + isCompletelyTaken ? Icons.check_circle : Icons.medication, + size: 18, + ), + label: Text( + isCompletelyTaken + ? 'All doses taken today' + : isTakenToday + ? 'Take next dose' + : 'Take supplement', + style: const TextStyle(fontWeight: FontWeight.w600), + ), + style: ElevatedButton.styleFrom( + backgroundColor: isCompletelyTaken + ? Colors.green.shade500 + : Theme.of(context).colorScheme.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + elevation: isCompletelyTaken ? 0 : 2, + ), + ), + ), ], ), ), - ], - ), + ), + ); + }, + ); + } +} + +class _InfoChip extends StatelessWidget { + final IconData icon; + final String label; + final BuildContext context; + final bool fullWidth; + + const _InfoChip({ + required this.icon, + required this.label, + required this.context, + this.fullWidth = false, + }); + + @override + Widget build(BuildContext context) { + return Container( + width: fullWidth ? double.infinity : null, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.4), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: fullWidth ? MainAxisSize.max : MainAxisSize.min, + children: [ + Icon( + icon, + size: 14, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 4), + Flexible( + child: Text( + label, + style: TextStyle( + fontSize: 11, + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w500, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], ), ); } diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 5f189c0..5d2cc21 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,9 +6,11 @@ import FlutterMacOS import Foundation import flutter_local_notifications +import shared_preferences_foundation import sqflite_darwin func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) } diff --git a/pubspec.lock b/pubspec.lock index b27c1d1..ec8a8b3 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -81,19 +81,19 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" flutter: dependency: "direct main" description: flutter source: sdk version: "0.0.0" - flutter_datetime_picker_plus: - dependency: "direct main" - description: - name: flutter_datetime_picker_plus - sha256: "7d82da02c4e070bb28a9107de119ad195e2319b45c786fecc13482a9ffcc51da" - url: "https://pub.dev" - source: hosted - version: "2.2.0" flutter_lints: dependency: "direct dev" description: @@ -139,6 +139,11 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" http: dependency: transitive description: @@ -235,6 +240,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" petitparser: dependency: transitive description: @@ -267,6 +296,62 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.5+1" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + url: "https://pub.dev" + source: hosted + version: "2.5.3" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "5bcf0772a761b04f8c6bf814721713de6f3e5d9d89caf8d3fe031b02a342379e" + url: "https://pub.dev" + source: hosted + version: "2.4.11" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" sky_engine: dependency: transitive description: flutter @@ -442,4 +527,4 @@ packages: version: "6.6.1" sdks: dart: ">=3.9.0 <4.0.0" - flutter: ">=3.24.0" + flutter: ">=3.27.0" diff --git a/pubspec.yaml b/pubspec.yaml index fcad970..da1a586 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -43,15 +43,15 @@ dependencies: # State management provider: ^6.1.1 + # Settings persistence + shared_preferences: ^2.2.2 + # Local notifications flutter_local_notifications: ^19.4.1 timezone: ^0.10.1 # Date time handling intl: ^0.20.2 - - # UI components - flutter_datetime_picker_plus: ^2.1.0 dev_dependencies: flutter_test: