mirror of
https://github.com/vleeuwenmenno/supplements.git
synced 2025-09-11 18:29:12 +02:00
674 lines
24 KiB
Dart
674 lines
24 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
|
import 'package:uuid/uuid.dart';
|
|
|
|
import '../models/ingredient.dart';
|
|
import '../models/supplement.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,
|
|
syncId: const Uuid().v4(),
|
|
lastModified: DateTime.now(),
|
|
);
|
|
}
|
|
|
|
void dispose() {
|
|
nameController.dispose();
|
|
amountController.dispose();
|
|
}
|
|
}
|
|
|
|
class AddSupplementScreen extends StatefulWidget {
|
|
final Supplement? supplement;
|
|
|
|
const AddSupplementScreen({super.key, this.supplement});
|
|
|
|
@override
|
|
State<AddSupplementScreen> createState() => _AddSupplementScreenState();
|
|
}
|
|
|
|
class _AddSupplementScreenState extends State<AddSupplementScreen> {
|
|
final _formKey = GlobalKey<FormState>();
|
|
final _nameController = TextEditingController();
|
|
final _brandController = TextEditingController();
|
|
final _numberOfUnitsController = TextEditingController();
|
|
final _notesController = TextEditingController();
|
|
|
|
// Multi-ingredient support with persistent controllers
|
|
final _ingredientControllers = [];
|
|
|
|
String _selectedUnitType = 'capsules';
|
|
int _frequencyPerDay = 1;
|
|
List<String> _reminderTimes = ['08:00'];
|
|
|
|
final List<String> _units = ['mg', 'g', 'μg', 'IU', 'ml'];
|
|
final List<String> _unitTypes = ['capsules', 'tablets', 'softgels', 'drops', 'ml', 'scoops', 'gummies'];
|
|
|
|
@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>(
|
|
initialValue: 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;
|
|
_brandController.text = supplement.brand ?? '';
|
|
_numberOfUnitsController.text = supplement.numberOfUnits.toString();
|
|
_notesController.text = supplement.notes ?? '';
|
|
_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
|
|
Widget build(BuildContext context) {
|
|
final isEditing = widget.supplement != null;
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Text(isEditing ? 'Edit Supplement' : 'Add Supplement'),
|
|
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
|
actions: [
|
|
IconButton(
|
|
tooltip: isEditing ? 'Update Supplement' : 'Save Supplement',
|
|
onPressed: _saveSupplement,
|
|
icon: const Icon(Icons.save),
|
|
),
|
|
],
|
|
),
|
|
body: Form(
|
|
key: _formKey,
|
|
child: SingleChildScrollView(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Name field
|
|
TextFormField(
|
|
controller: _nameController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Supplement Name *',
|
|
border: OutlineInputBorder(),
|
|
hintText: 'e.g., Vitamin D3',
|
|
),
|
|
validator: (value) {
|
|
if (value == null || value.trim().isEmpty) {
|
|
return 'Please enter a supplement name';
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// 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),
|
|
|
|
// Number of units to take
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
flex: 2,
|
|
child: TextFormField(
|
|
controller: _numberOfUnitsController,
|
|
keyboardType: TextInputType.number,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Number to take *',
|
|
border: OutlineInputBorder(),
|
|
hintText: '2',
|
|
),
|
|
validator: (value) {
|
|
if (value == null || value.trim().isEmpty) {
|
|
return 'Please enter number to take';
|
|
}
|
|
if (int.tryParse(value) == null || int.parse(value) < 1) {
|
|
return 'Please enter a valid number (1 or more)';
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
flex: 1,
|
|
child: DropdownButtonFormField<String>(
|
|
initialValue: _selectedUnitType,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Type',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
items: _unitTypes.map((type) {
|
|
return DropdownMenuItem(
|
|
value: type,
|
|
child: Text(type),
|
|
);
|
|
}).toList(),
|
|
onChanged: (value) {
|
|
setState(() {
|
|
_selectedUnitType = value!;
|
|
});
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
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.withValues(alpha: 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
|
|
Row(
|
|
children: [
|
|
const Text('Frequency per day: '),
|
|
const SizedBox(width: 8),
|
|
DropdownButton<int>(
|
|
value: _frequencyPerDay,
|
|
items: List.generate(6, (index) => index + 1).map((freq) {
|
|
return DropdownMenuItem(
|
|
value: freq,
|
|
child: Text('$freq time${freq > 1 ? 's' : ''}'),
|
|
);
|
|
}).toList(),
|
|
onChanged: (value) {
|
|
setState(() {
|
|
_frequencyPerDay = value!;
|
|
_adjustReminderTimes();
|
|
});
|
|
},
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// Reminder times
|
|
const Text(
|
|
'Reminder Times',
|
|
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
|
),
|
|
const SizedBox(height: 8),
|
|
..._reminderTimes.asMap().entries.map((entry) {
|
|
final index = entry.key;
|
|
final time = entry.value;
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 8),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: InkWell(
|
|
onTap: () => _selectTime(index),
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: Container(
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
border: Border.all(color: Theme.of(context).colorScheme.outline),
|
|
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,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
if (_reminderTimes.length > 1)
|
|
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)
|
|
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),
|
|
|
|
// Notes
|
|
TextFormField(
|
|
controller: _notesController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Notes (optional)',
|
|
border: OutlineInputBorder(),
|
|
hintText: 'Take with food, before meal, etc.',
|
|
),
|
|
maxLines: 3,
|
|
),
|
|
const SizedBox(height: 24),
|
|
|
|
// Save is now in the AppBar for consistency with app-wide pattern
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _adjustReminderTimes() {
|
|
if (_reminderTimes.length > _frequencyPerDay) {
|
|
_reminderTimes = _reminderTimes.take(_frequencyPerDay).toList();
|
|
} else if (_reminderTimes.length < _frequencyPerDay) {
|
|
while (_reminderTimes.length < _frequencyPerDay) {
|
|
_reminderTimes.add('08:00');
|
|
}
|
|
}
|
|
}
|
|
|
|
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) {
|
|
setState(() {
|
|
_reminderTimes.add('08:00');
|
|
});
|
|
}
|
|
}
|
|
|
|
void _removeReminderTime(int index) {
|
|
if (_reminderTimes.length > 1) {
|
|
setState(() {
|
|
_reminderTimes.removeAt(index);
|
|
});
|
|
}
|
|
}
|
|
|
|
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.0,
|
|
unit: controller.selectedUnit,
|
|
syncId: const Uuid().v4(),
|
|
lastModified: DateTime.now(),
|
|
))
|
|
.toList();
|
|
|
|
if (validIngredients.isEmpty) {
|
|
final sonner = ShadSonner.of(context);
|
|
final id = DateTime.now().millisecondsSinceEpoch;
|
|
sonner.show(
|
|
ShadToast(
|
|
id: id,
|
|
title: const Text('Please add at least one ingredient with name and amount'),
|
|
action: ShadButton(
|
|
size: ShadButtonSize.sm,
|
|
child: const Text('Dismiss'),
|
|
onPressed: () => sonner.hide(id),
|
|
),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
final supplement = Supplement(
|
|
id: widget.supplement?.id,
|
|
name: _nameController.text.trim(),
|
|
brand: _brandController.text.trim().isNotEmpty
|
|
? _brandController.text.trim()
|
|
: null,
|
|
ingredients: validIngredients,
|
|
numberOfUnits: int.parse(_numberOfUnitsController.text),
|
|
unitType: _selectedUnitType,
|
|
frequencyPerDay: _frequencyPerDay,
|
|
reminderTimes: _reminderTimes,
|
|
notes: _notesController.text.trim().isNotEmpty
|
|
? _notesController.text.trim()
|
|
: null,
|
|
createdAt: widget.supplement?.createdAt ?? DateTime.now(),
|
|
syncId: widget.supplement?.syncId, // Preserve syncId on update
|
|
lastModified: DateTime.now(), // Always update lastModified on save
|
|
);
|
|
|
|
final provider = context.read<SupplementProvider>();
|
|
|
|
try {
|
|
if (widget.supplement != null) {
|
|
await provider.updateSupplement(supplement);
|
|
} else {
|
|
await provider.addSupplement(supplement);
|
|
}
|
|
|
|
if (mounted) {
|
|
Navigator.of(context).pop();
|
|
|
|
final sonner = ShadSonner.of(context);
|
|
final id = DateTime.now().millisecondsSinceEpoch;
|
|
sonner.show(
|
|
ShadToast(
|
|
id: id,
|
|
title: Text(widget.supplement != null
|
|
? 'Supplement updated successfully!'
|
|
: 'Supplement added successfully!'),
|
|
action: ShadButton(
|
|
size: ShadButtonSize.sm,
|
|
child: const Text('Dismiss'),
|
|
onPressed: () => sonner.hide(id),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
final sonner = ShadSonner.of(context);
|
|
final id = DateTime.now().millisecondsSinceEpoch;
|
|
sonner.show(
|
|
ShadToast(
|
|
id: id,
|
|
title: const Text('Error'),
|
|
description: Text('${e.toString()}'),
|
|
action: ShadButton(
|
|
size: ShadButtonSize.sm,
|
|
child: const Text('Dismiss'),
|
|
onPressed: () => sonner.hide(id),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_nameController.dispose();
|
|
_brandController.dispose();
|
|
_numberOfUnitsController.dispose();
|
|
_notesController.dispose();
|
|
|
|
// Dispose all ingredient controllers
|
|
for (final controller in _ingredientControllers) {
|
|
controller.dispose();
|
|
}
|
|
|
|
super.dispose();
|
|
}
|
|
}
|