Files
supplements/lib/screens/add_supplement_screen.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();
}
}