feat: Add settings provider for theme and time range management

- Implemented SettingsProvider to manage user preferences for theme options and time ranges for reminders.
- Added persistent reminder settings with configurable retry intervals and maximum attempts.
- Created UI for settings screen to allow users to customize their preferences.
- Integrated shared_preferences for persistent storage of user settings.

feat: Introduce Ingredient model

- Created Ingredient model to represent nutritional components with properties for id, name, amount, and unit.
- Added methods for serialization and deserialization of Ingredient objects.

feat: Develop Archived Supplements Screen

- Implemented ArchivedSupplementsScreen to display archived supplements with options to unarchive or delete.
- Added UI components for listing archived supplements and handling user interactions.

chore: Update dependencies in pubspec.yaml and pubspec.lock

- Updated shared_preferences dependency to the latest version.
- Removed flutter_datetime_picker_plus dependency and added file dependency.
- Updated Flutter SDK constraint to >=3.27.0.
This commit is contained in:
2025-08-26 17:19:54 +02:00
parent e6181add08
commit 2aec59ec35
18 changed files with 3756 additions and 376 deletions

View File

@@ -8,7 +8,8 @@
<application
android:label="supplements"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
android:icon="@mipmap/ic_launcher"
android:enableOnBackInvokedCallback="true">
<activity
android:name=".MainActivity"
android:exported="true"
@@ -46,6 +47,10 @@
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</receiver>
<!-- Notification action receiver for handling action button clicks -->
<receiver
android:name="com.dexterous.flutterlocalnotifications.ActionBroadcastReceiver"
android:exported="false" />
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->

View File

@@ -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,9 +13,18 @@ class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
return MultiProvider(
providers: [
ChangeNotifierProvider(
create: (context) => SupplementProvider()..initialize(),
child: MaterialApp(
),
ChangeNotifierProvider(
create: (context) => SettingsProvider()..initialize(),
),
],
child: Consumer<SettingsProvider>(
builder: (context, settingsProvider, child) {
return MaterialApp(
title: 'Supplements Tracker',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
@@ -30,9 +40,11 @@ class MyApp extends StatelessWidget {
),
useMaterial3: true,
),
themeMode: ThemeMode.system, // Follows system theme
themeMode: settingsProvider.themeMode,
home: const HomeScreen(),
debugShowCheckedModeBanner: false,
);
},
),
);
}

View File

@@ -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<String, dynamic> toMap() {
return {
'id': id,
'name': name,
'amount': amount,
'unit': unit,
};
}
factory Ingredient.fromMap(Map<String, dynamic> 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;
}
}

View File

@@ -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<Ingredient> 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<String> 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<String, dynamic> 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<String, dynamic> map) {
List<Ingredient> 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<String, dynamic>))
.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<Ingredient>? ingredients,
int? numberOfUnits,
String? unit,
String? unitType,
int? frequencyPerDay,
List<String>? 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,

View File

@@ -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<void> 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<void> setThemeOption(ThemeOption option) async {
_themeOption = option;
notifyListeners();
final prefs = await SharedPreferences.getInstance();
await prefs.setInt('theme_option', option.index);
}
Future<void> 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<String> 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<void> setPersistentReminders(bool enabled) async {
_persistentReminders = enabled;
notifyListeners();
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('persistent_reminders', enabled);
}
Future<void> setReminderRetryInterval(int minutes) async {
_reminderRetryInterval = minutes;
notifyListeners();
final prefs = await SharedPreferences.getInstance();
await prefs.setInt('reminder_retry_interval', minutes);
}
Future<void> setMaxRetryAttempts(int attempts) async {
_maxRetryAttempts = attempts;
notifyListeners();
final prefs = await SharedPreferences.getInstance();
await prefs.setInt('max_retry_attempts', attempts);
}
}

View File

@@ -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<Map<String, dynamic>> _todayIntakes = [];
List<Map<String, dynamic>> _monthlyIntakes = [];
bool _isLoading = false;
Timer? _persistentReminderTimer;
List<Supplement> get supplements => _supplements;
List<Map<String, dynamic>> get todayIntakes => _todayIntakes;
@@ -20,9 +23,115 @@ class SupplementProvider with ChangeNotifier {
Future<void> initialize() async {
await _notificationService.initialize();
// 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<void> _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<void> 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<void> _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<void> loadSupplements() async {
@@ -103,11 +212,11 @@ class SupplementProvider with ChangeNotifier {
}
}
Future<void> recordIntake(int supplementId, double dosage, {double? unitsTaken, String? notes}) async {
Future<void> 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<Supplement> _archivedSupplements = [];
List<Supplement> get archivedSupplements => _archivedSupplements;
Future<void> loadArchivedSupplements() async {
try {
_archivedSupplements = await _databaseHelper.getArchivedSupplements();
notifyListeners();
} catch (e) {
if (kDebugMode) {
print('Error loading archived supplements: $e');
}
}
}
Future<void> 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<void> 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<void> 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<void> testNotifications() async {
await _notificationService.testNotification();
}
Future<void> testScheduledNotification() async {
await _notificationService.testScheduledNotification();
}
Future<void> testNotificationActions() async {
await _notificationService.testNotificationWithActions();
}
Future<List<PendingNotificationRequest>> getPendingNotifications() async {
return await _notificationService.getPendingNotifications();
}
// Debug method to test notification persistence
Future<void> rescheduleAllNotifications() async {
await _rescheduleAllNotifications();
}
// Debug method to cancel all notifications
Future<void> cancelAllNotifications() async {
await _notificationService.cancelAllReminders();
}
}

View File

@@ -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<AddSupplementScreen> {
final _formKey = GlobalKey<FormState>();
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<IngredientController> _ingredientControllers = [];
String _selectedUnitType = 'capsules';
int _frequencyPerDay = 1;
List<String> _reminderTimes = ['08:00'];
@@ -35,19 +64,152 @@ class _AddSupplementScreenState extends State<AddSupplementScreen> {
_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<String>(
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,54 +245,37 @@ class _AddSupplementScreenState extends State<AddSupplementScreen> {
),
const SizedBox(height: 16),
// Dosage amount per unit
Row(
children: [
Expanded(
flex: 2,
child: TextFormField(
controller: _dosageAmountController,
keyboardType: TextInputType.number,
// Brand field
TextFormField(
controller: _brandController,
decoration: const InputDecoration(
labelText: 'Amount per unit *',
labelText: 'Brand (Optional)',
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;
},
hintText: 'e.g., Nature Made, NOW Foods',
),
),
const SizedBox(width: 12),
Expanded(
flex: 1,
child: DropdownButtonFormField<String>(
value: _selectedUnit,
decoration: const InputDecoration(
labelText: 'Unit',
border: OutlineInputBorder(),
const SizedBox(height: 16),
// Ingredients section
Text(
'Ingredients',
style: Theme.of(context).textTheme.titleMedium,
),
items: _units.map((unit) {
return DropdownMenuItem(
value: unit,
child: Text(unit),
);
}).toList(),
onChanged: (value) {
setState(() {
_selectedUnit = value!;
});
},
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),
// Number of units to take
@@ -181,13 +326,39 @@ class _AddSupplementScreenState extends State<AddSupplementScreen> {
),
],
),
const SizedBox(height: 8),
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(
'Total per intake: ${_dosageAmountController.text.isNotEmpty && _numberOfUnitsController.text.isNotEmpty ? (double.tryParse(_dosageAmountController.text) ?? 0) * (int.tryParse(_numberOfUnitsController.text) ?? 0) : 0} $_selectedUnit',
'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,
fontStyle: FontStyle.italic,
),
);
}),
],
),
),
),
const SizedBox(height: 16),
@@ -232,30 +403,66 @@ class _AddSupplementScreenState extends State<AddSupplementScreen> {
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(
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(
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,18 +506,36 @@ class _AddSupplementScreenState extends State<AddSupplementScreen> {
}
}
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')}';
});
},
currentTime: DateTime.now(),
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!,
);
},
);
if (picked != null) {
setState(() {
_reminderTimes[index] = '${picked.hour.toString().padLeft(2, '0')}:${picked.minute.toString().padLeft(2, '0')}';
});
}
}
void _addReminderTime() {
if (_reminderTimes.length < _frequencyPerDay) {
@@ -330,12 +555,31 @@ class _AddSupplementScreenState extends State<AddSupplementScreen> {
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<AddSupplementScreen> {
@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();
}
}

View File

@@ -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<ArchivedSupplementsScreen> createState() => _ArchivedSupplementsScreenState();
}
class _ArchivedSupplementsScreenState extends State<ArchivedSupplementsScreen> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<SupplementProvider>().loadArchivedSupplements();
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Archived Supplements'),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
),
body: Consumer<SupplementProvider>(
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<SupplementProvider>().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<SupplementProvider>().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,
),
),
],
),
);
}
}

View File

@@ -15,6 +15,7 @@ class _HistoryScreenState extends State<HistoryScreen> 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<HistoryScreen> with SingleTickerProvider
),
Expanded(
child: FutureBuilder<List<Map<String, dynamic>>>(
key: ValueKey('daily_view_$_refreshKey'), // Use refresh key to force rebuild
future: context.read<SupplementProvider>().getIntakesForDate(_selectedDate),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
@@ -137,12 +139,10 @@ class _HistoryScreenState extends State<HistoryScreen> 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<HistoryScreen> 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<HistoryScreen> 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<HistoryScreen> 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<HistoryScreen> 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<HistoryScreen> 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<HistoryScreen> with SingleTickerProvider
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () {
context.read<SupplementProvider>().deleteIntake(intakeId);
onPressed: () async {
await context.read<SupplementProvider>().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<SupplementProvider>().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'),

View File

@@ -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<HomeScreen> {
final List<Widget> _screens = [
const SupplementsListScreen(),
const HistoryScreen(),
const SettingsScreen(),
];
@override
@@ -25,9 +28,42 @@ class _HomeScreenState extends State<HomeScreen> {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<SupplementProvider>().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<void> _checkPersistentReminders() async {
if (!mounted) return;
try {
final supplementProvider = context.read<SupplementProvider>();
final settingsProvider = context.read<SettingsProvider>();
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<HomeScreen> {
_currentIndex = index;
});
},
type: BottomNavigationBarType.fixed,
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.medication),
@@ -48,6 +85,10 @@ class _HomeScreenState extends State<HomeScreen> {
icon: Icon(Icons.history),
label: 'History',
),
BottomNavigationBarItem(
icon: Icon(Icons.settings),
label: 'Settings',
),
],
),
floatingActionButton: _currentIndex == 0

View File

@@ -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<SettingsProvider>(
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<ThemeOption>(
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<ThemeOption>(
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<ThemeOption>(
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<int>(
segments: const [
ButtonSegment(value: 5, label: Text('5 min')),
ButtonSegment(value: 10, label: Text('10 min')),
ButtonSegment(value: 15, label: Text('15 min')),
ButtonSegment(value: 30, label: Text('30 min')),
],
selected: {settingsProvider.reminderRetryInterval},
onSelectionChanged: (values) {
settingsProvider.setReminderRetryInterval(values.first);
},
),
const SizedBox(height: 16),
Text(
'Maximum Retry Attempts',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
SegmentedButton<int>(
segments: const [
ButtonSegment(value: 1, label: Text('1')),
ButtonSegment(value: 2, label: Text('2')),
ButtonSegment(value: 3, label: Text('3')),
ButtonSegment(value: 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<SupplementProvider>(
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<SupplementProvider>(
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';
}
}

View File

@@ -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(),
),
body: Consumer<SupplementProvider>(
builder: (context, provider, child) {
);
},
tooltip: 'Archived Supplements',
),
],
),
body: Consumer2<SupplementProvider, SettingsProvider>(
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<Supplement> 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<String, List<Supplement>> _groupSupplementsByTimeOfDay(List<Supplement> supplements, SettingsProvider settingsProvider) {
final Map<String, List<Supplement>> grouped = {
'morning': <Supplement>[],
'afternoon': <Supplement>[],
'evening': <Supplement>[],
'night': <Supplement>[],
'anytime': <Supplement>[],
};
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<bool>(
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<bool>(
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<SupplementProvider>().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<SupplementProvider>().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'),
),
],
),
);
}
}

View File

@@ -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<void> _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<List<Supplement>> getArchivedSupplements() async {
Database db = await database;
List<Map<String, dynamic>> maps = await db.query(
supplementsTable,
where: 'isActive = ?',
whereArgs: [0],
orderBy: 'name ASC',
);
return List.generate(maps.length, (i) => Supplement.fromMap(maps[i]));
}
Future<void> archiveSupplement(int id) async {
Database db = await database;
await db.update(
supplementsTable,
{'isActive': 0},
where: 'id = ?',
whereArgs: [id],
);
}
Future<void> unarchiveSupplement(int id) async {
Database db = await database;
await db.update(
supplementsTable,
{'isActive': 1},
where: 'id = ?',
whereArgs: [id],
);
}
Future<Supplement?> getSupplement(int id) async {
Database db = await database;
List<Map<String, dynamic>> maps = await db.query(
@@ -213,7 +341,10 @@ class DatabaseHelper {
String endDate = DateTime(date.year, date.month, date.day, 23, 59, 59).toIso8601String();
List<Map<String, dynamic>> 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<Map<String, dynamic>> 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<int> 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<void> markNotificationTaken(int notificationId) async {
Database db = await database;
await db.update(
notificationTrackingTable,
{'status': 'taken'},
where: 'notificationId = ?',
whereArgs: [notificationId],
);
}
Future<void> 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<List<Map<String, dynamic>>> getPendingNotifications() async {
Database db = await database;
return await db.query(
notificationTrackingTable,
where: 'status IN (?, ?)',
whereArgs: ['pending', 'retrying'],
);
}
Future<void> markNotificationExpired(int notificationId) async {
Database db = await database;
await db.update(
notificationTrackingTable,
{'status': 'expired'},
where: 'notificationId = ?',
whereArgs: [notificationId],
);
}
Future<void> 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<void> clearNotificationTracking(int supplementId) async {
Database db = await database;
await db.delete(
notificationTrackingTable,
where: 'supplementId = ?',
whereArgs: [supplementId],
);
}
}

View File

@@ -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<void> initialize() async {
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<void> 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<void> 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<void> _scheduleRetryNotification(Map<String, dynamic> 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<bool> requestPermissions() async {
print('📱 Requesting notification permissions...');
if (_permissionsRequested) {
print('📱 Permissions already requested');
return true;
}
try {
_permissionsRequested = true;
final androidPlugin = _notifications.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>();
if (androidPlugin != null) {
await androidPlugin.requestNotificationsPermission();
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<IOSFlutterLocalNotificationsPlugin>();
if (iosPlugin != null) {
await iosPlugin.requestPermissions(
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;
}
}
Future<void> 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
),
iOS: DarwinNotificationDetails(),
AndroidNotificationAction(
'snooze_10',
'Snooze 10min',
icon: DrawableResourceAndroidBitmap('@android:drawable/ic_menu_recent_history'),
showsUserInterface: true, // Changed to true to open app
),
],
),
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<void> 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<void> 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<void> 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<void> 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<List<PendingNotificationRequest>> getPendingNotifications() async {
return await _notifications.pendingNotificationRequests();
}
// Debug function to test notification actions
Future<void> 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<void> 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');
}
}

View File

@@ -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<SupplementCard> createState() => _SupplementCardState();
}
class _SupplementCardState extends State<SupplementCard> {
bool _isExpanded = false;
@override
Widget build(BuildContext context) {
return Consumer<SupplementProvider>(
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: 12),
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
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,
),
),
title: Row(
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)',
widget.supplement.name,
style: TextStyle(
fontSize: 14,
color: Theme.of(context).colorScheme.onSurfaceVariant,
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,
),
),
],
),
),
PopupMenuButton(
// 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,116 +250,270 @@ class SupplementCard extends StatelessWidget {
),
],
),
],
children: [
// 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,
),
const SizedBox(height: 12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.schedule, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant),
const SizedBox(width: 4),
Icon(
Icons.check_circle_outline,
size: 16,
color: isCompletelyTaken
? Colors.green.shade200
: Theme.of(context).colorScheme.onSecondaryContainer,
),
const SizedBox(width: 6),
Text(
'${supplement.frequencyPerDay}x daily',
'Taken today:',
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,
fontWeight: FontWeight.w600,
color: isCompletelyTaken
? Colors.green.shade200
: Theme.of(context).colorScheme.onSecondaryContainer,
),
),
],
),
if (supplement.notes != null && supplement.notes!.isNotEmpty) ...[
const SizedBox(height: 8),
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(
supplement.notes!,
'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: 12),
// Schedule and dosage info
Row(
children: [
Expanded(
child: _InfoChip(
icon: Icons.schedule,
label: '${widget.supplement.frequencyPerDay}x daily',
context: context,
),
),
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: 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),
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),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Take Supplement',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(height: 8),
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,
),
),
],
),
),
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),
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,
),
),
],
),
),
);
}
}

View File

@@ -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"))
}

View File

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

View File

@@ -43,6 +43,9 @@ 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
@@ -50,9 +53,6 @@ dependencies:
# Date time handling
intl: ^0.20.2
# UI components
flutter_datetime_picker_plus: ^2.1.0
dev_dependencies:
flutter_test:
sdk: flutter