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

@@ -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'];
@@ -28,26 +57,159 @@ class _AddSupplementScreenState extends State<AddSupplementScreen> {
final List<String> _units = ['mg', 'g', 'μg', 'IU', 'ml'];
final List<String> _unitTypes = ['capsules', 'tablets', 'softgels', 'drops', 'ml', 'scoops', 'gummies'];
@override
@override
void initState() {
super.initState();
if (widget.supplement != null) {
_initializeWithExistingSupplement();
} else {
_numberOfUnitsController.text = '1'; // Default to 1 unit
// Start with one empty ingredient
_ingredientControllers.add(IngredientController());
}
}
void _addIngredient() {
setState(() {
_ingredientControllers.add(IngredientController());
});
}
void _removeIngredient(int index) {
if (_ingredientControllers.length > 1) {
setState(() {
_ingredientControllers[index].dispose();
_ingredientControllers.removeAt(index);
});
}
}
void _updateIngredient(int index, String field, dynamic value) {
if (index < _ingredientControllers.length) {
setState(() {
if (field == 'unit') {
_ingredientControllers[index].selectedUnit = value as String;
}
// Note: name and amount are handled by the TextEditingControllers directly
});
}
}
Widget _buildIngredientRow(int index, IngredientController controller) {
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'Ingredient ${index + 1}',
style: Theme.of(context).textTheme.titleSmall,
),
const Spacer(),
if (_ingredientControllers.length > 1)
IconButton(
onPressed: () => _removeIngredient(index),
icon: const Icon(Icons.remove_circle_outline),
color: Colors.red,
tooltip: 'Remove ingredient',
),
],
),
const SizedBox(height: 12),
TextFormField(
controller: controller.nameController,
decoration: const InputDecoration(
labelText: 'Ingredient Name *',
border: OutlineInputBorder(),
hintText: 'e.g., Vitamin D3, Magnesium',
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Please enter ingredient name';
}
return null;
},
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
flex: 2,
child: TextFormField(
controller: controller.amountController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Amount *',
border: OutlineInputBorder(),
hintText: '100',
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Enter amount';
}
if (double.tryParse(value) == null || double.parse(value) <= 0) {
return 'Enter valid amount';
}
return null;
},
),
),
const SizedBox(width: 12),
Expanded(
flex: 1,
child: DropdownButtonFormField<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,53 +245,36 @@ 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,
decoration: const InputDecoration(
labelText: 'Amount per unit *',
border: OutlineInputBorder(),
hintText: '187',
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Please enter amount per unit';
}
if (double.tryParse(value) == null) {
return 'Please enter a valid number';
}
return null;
},
),
),
const SizedBox(width: 12),
Expanded(
flex: 1,
child: DropdownButtonFormField<String>(
value: _selectedUnit,
decoration: const InputDecoration(
labelText: 'Unit',
border: OutlineInputBorder(),
),
items: _units.map((unit) {
return DropdownMenuItem(
value: unit,
child: Text(unit),
);
}).toList(),
onChanged: (value) {
setState(() {
_selectedUnit = value!;
});
},
),
),
],
// Brand field
TextFormField(
controller: _brandController,
decoration: const InputDecoration(
labelText: 'Brand (Optional)',
border: OutlineInputBorder(),
hintText: 'e.g., Nature Made, NOW Foods',
),
),
const SizedBox(height: 16),
// Ingredients section
Text(
'Ingredients',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
..._ingredientControllers.asMap().entries.map((entry) {
final index = entry.key;
final controller = entry.value;
return _buildIngredientRow(index, controller);
}),
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: _addIngredient,
icon: const Icon(Icons.add),
label: const Text('Add Ingredient'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
),
const SizedBox(height: 16),
@@ -181,15 +326,41 @@ class _AddSupplementScreenState extends State<AddSupplementScreen> {
),
],
),
const SizedBox(height: 8),
Text(
'Total per intake: ${_dosageAmountController.text.isNotEmpty && _numberOfUnitsController.text.isNotEmpty ? (double.tryParse(_dosageAmountController.text) ?? 0) * (int.tryParse(_numberOfUnitsController.text) ?? 0) : 0} $_selectedUnit',
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
const SizedBox(height: 16),
// Show ingredients summary
if (_ingredientControllers.isNotEmpty && _ingredientControllers.any((c) => c.nameController.text.isNotEmpty && (double.tryParse(c.amountController.text) ?? 0) > 0))
Card(
color: Theme.of(context).colorScheme.primaryContainer.withOpacity(0.3),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Per ${_numberOfUnitsController.text.isNotEmpty ? _numberOfUnitsController.text : "1"} $_selectedUnitType:',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(height: 4),
..._ingredientControllers.where((c) => c.nameController.text.isNotEmpty && (double.tryParse(c.amountController.text) ?? 0) > 0).map((controller) {
final amount = double.tryParse(controller.amountController.text) ?? 0;
final totalAmount = amount * (int.tryParse(_numberOfUnitsController.text) ?? 1);
return Text(
'${totalAmount.toStringAsFixed(totalAmount % 1 == 0 ? 0 : 1)}${controller.selectedUnit} ${controller.nameController.text}',
style: TextStyle(
fontSize: 11,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
);
}),
],
),
),
),
),
const SizedBox(height: 16),
// Frequency per day
@@ -232,30 +403,66 @@ class _AddSupplementScreenState extends State<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(
onPressed: () => _removeReminderTime(index),
icon: const Icon(Icons.remove_circle_outline),
Padding(
padding: const EdgeInsets.only(left: 8),
child: IconButton(
onPressed: () => _removeReminderTime(index),
icon: const Icon(Icons.remove_circle_outline),
color: Colors.red,
tooltip: 'Remove reminder time',
),
),
],
),
);
}),
if (_reminderTimes.length < _frequencyPerDay)
TextButton.icon(
onPressed: _addReminderTime,
icon: const Icon(Icons.add),
label: const Text('Add Reminder Time'),
Padding(
padding: const EdgeInsets.only(top: 8),
child: OutlinedButton.icon(
onPressed: _addReminderTime,
icon: const Icon(Icons.add),
label: const Text('Add Reminder Time'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
),
),
const SizedBox(height: 16),
@@ -299,17 +506,35 @@ class _AddSupplementScreenState extends State<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')}';
});
void _selectTime(int index) async {
// Parse current time or use default
TimeOfDay currentTime = TimeOfDay(hour: 8, minute: 0);
if (index < _reminderTimes.length) {
final timeParts = _reminderTimes[index].split(':');
if (timeParts.length >= 2) {
currentTime = TimeOfDay(
hour: int.tryParse(timeParts[0]) ?? 8,
minute: int.tryParse(timeParts[1]) ?? 0,
);
}
}
final TimeOfDay? picked = await showTimePicker(
context: context,
initialTime: currentTime,
builder: (context, child) {
return MediaQuery(
data: MediaQuery.of(context).copyWith(alwaysUse24HourFormat: true),
child: child!,
);
},
currentTime: DateTime.now(),
);
if (picked != null) {
setState(() {
_reminderTimes[index] = '${picked.hour.toString().padLeft(2, '0')}:${picked.minute.toString().padLeft(2, '0')}';
});
}
}
void _addReminderTime() {
@@ -330,12 +555,31 @@ class _AddSupplementScreenState extends State<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(),
),
);
},
tooltip: 'Archived Supplements',
),
],
),
body: Consumer<SupplementProvider>(
builder: (context, provider, child) {
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'),
),
],
),
);
}
}