mirror of
https://github.com/vleeuwenmenno/supplements.git
synced 2025-09-11 18:29:12 +02:00
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:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user