486 lines
15 KiB
Dart
486 lines
15 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
import '../models/recipe.dart';
|
|
import '../providers/app_state.dart';
|
|
|
|
class RecipeDialog extends StatefulWidget {
|
|
final Recipe? recipe; // null for add, non-null for edit
|
|
|
|
const RecipeDialog({super.key, this.recipe});
|
|
|
|
@override
|
|
State<RecipeDialog> createState() => _RecipeDialogState();
|
|
}
|
|
|
|
class _RecipeDialogState extends State<RecipeDialog> {
|
|
final _formKey = GlobalKey<FormState>();
|
|
late final TextEditingController _nameController;
|
|
late final TextEditingController _grindSizeController;
|
|
late final TextEditingController _coffeeAmountController;
|
|
late final TextEditingController _waterAmountController;
|
|
late final TextEditingController _brewTimeController;
|
|
late final TextEditingController _instructionsController;
|
|
late final TextEditingController _notesController;
|
|
|
|
late ServingTemp _selectedServingTemp;
|
|
late MilkType? _selectedMilkType;
|
|
late BrewMethod _selectedBrewMethod;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
|
|
final recipe = widget.recipe;
|
|
_nameController = TextEditingController(text: recipe?.name ?? '');
|
|
_grindSizeController = TextEditingController(text: recipe?.grindSize.toString() ?? '');
|
|
_coffeeAmountController = TextEditingController(text: recipe?.coffeeAmount.toString() ?? '');
|
|
_waterAmountController = TextEditingController(text: recipe?.waterAmount.toString() ?? '');
|
|
_brewTimeController = TextEditingController(text: recipe?.brewTime.toString() ?? '');
|
|
_instructionsController = TextEditingController(text: recipe?.instructions ?? '');
|
|
_notesController = TextEditingController(text: recipe?.notes ?? '');
|
|
|
|
_selectedServingTemp = recipe?.servingTemp ?? ServingTemp.hot;
|
|
_selectedMilkType = recipe?.milkType;
|
|
_selectedBrewMethod = recipe?.brewMethod ?? BrewMethod.espresso;
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_nameController.dispose();
|
|
_grindSizeController.dispose();
|
|
_coffeeAmountController.dispose();
|
|
_waterAmountController.dispose();
|
|
_brewTimeController.dispose();
|
|
_instructionsController.dispose();
|
|
_notesController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Dialog(
|
|
child: Container(
|
|
width: MediaQuery.of(context).size.width > 600 ? 600 : double.infinity,
|
|
height: MediaQuery.of(context).size.height * 0.9,
|
|
padding: const EdgeInsets.all(24),
|
|
child: Column(
|
|
children: [
|
|
// Header
|
|
Row(
|
|
children: [
|
|
Icon(
|
|
Icons.menu_book,
|
|
color: Theme.of(context).colorScheme.primary,
|
|
size: 28,
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(
|
|
widget.recipe == null ? 'Add New Recipe' : 'Edit Recipe',
|
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.close),
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
),
|
|
],
|
|
),
|
|
const Divider(),
|
|
// Form
|
|
Expanded(
|
|
child: Form(
|
|
key: _formKey,
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_buildBasicInfoSection(),
|
|
const SizedBox(height: 24),
|
|
_buildBrewingParametersSection(),
|
|
const SizedBox(height: 24),
|
|
_buildInstructionsSection(),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
// Actions
|
|
const Divider(),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
children: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
child: const Text('Cancel'),
|
|
),
|
|
const SizedBox(width: 12),
|
|
ElevatedButton(
|
|
onPressed: _saveRecipe,
|
|
child: Text(widget.recipe == null ? 'Add Recipe' : 'Update Recipe'),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildBasicInfoSection() {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Basic Information',
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
TextFormField(
|
|
controller: _nameController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Recipe Name *',
|
|
hintText: 'e.g., Morning Espresso, Chemex Pour Over',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
validator: (value) {
|
|
if (value == null || value.trim().isEmpty) {
|
|
return 'Recipe name is required';
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
const SizedBox(height: 16),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: DropdownButtonFormField<BrewMethod>(
|
|
value: _selectedBrewMethod,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Brew Method',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
items: BrewMethod.values.map((method) {
|
|
return DropdownMenuItem(
|
|
value: method,
|
|
child: Text(_formatBrewMethod(method)),
|
|
);
|
|
}).toList(),
|
|
onChanged: (value) {
|
|
if (value != null) {
|
|
setState(() {
|
|
_selectedBrewMethod = value;
|
|
});
|
|
}
|
|
},
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: DropdownButtonFormField<ServingTemp>(
|
|
value: _selectedServingTemp,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Serving Temperature',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
items: ServingTemp.values.map((temp) {
|
|
return DropdownMenuItem(
|
|
value: temp,
|
|
child: Text(_formatServingTemp(temp)),
|
|
);
|
|
}).toList(),
|
|
onChanged: (value) {
|
|
if (value != null) {
|
|
setState(() {
|
|
_selectedServingTemp = value;
|
|
});
|
|
}
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
DropdownButtonFormField<MilkType?>(
|
|
value: _selectedMilkType,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Milk Type (Optional)',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
items: [
|
|
const DropdownMenuItem<MilkType?>(
|
|
value: null,
|
|
child: Text('No Milk'),
|
|
),
|
|
...MilkType.values.map((milk) {
|
|
return DropdownMenuItem(
|
|
value: milk,
|
|
child: Text(_formatMilkType(milk)),
|
|
);
|
|
}),
|
|
],
|
|
onChanged: (value) {
|
|
setState(() {
|
|
_selectedMilkType = value;
|
|
});
|
|
},
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildBrewingParametersSection() {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Brewing Parameters',
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: TextFormField(
|
|
controller: _coffeeAmountController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Coffee Amount (g) *',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
keyboardType: TextInputType.number,
|
|
validator: (value) {
|
|
if (value == null || value.trim().isEmpty) {
|
|
return 'Coffee amount is required';
|
|
}
|
|
if (double.tryParse(value) == null) {
|
|
return 'Please enter a valid number';
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: TextFormField(
|
|
controller: _waterAmountController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Water Amount (ml) *',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
keyboardType: TextInputType.number,
|
|
validator: (value) {
|
|
if (value == null || value.trim().isEmpty) {
|
|
return 'Water amount is required';
|
|
}
|
|
if (double.tryParse(value) == null) {
|
|
return 'Please enter a valid number';
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: TextFormField(
|
|
controller: _grindSizeController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Grind Size *',
|
|
hintText: 'e.g., Fine, Medium, Coarse',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
validator: (value) {
|
|
if (value == null || value.trim().isEmpty) {
|
|
return 'Grind size is required';
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: TextFormField(
|
|
controller: _brewTimeController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Brew Time (seconds) *',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
keyboardType: TextInputType.number,
|
|
validator: (value) {
|
|
if (value == null || value.trim().isEmpty) {
|
|
return 'Brew time is required';
|
|
}
|
|
if (int.tryParse(value) == null) {
|
|
return 'Please enter a valid number';
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildInstructionsSection() {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Instructions & Notes',
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
TextFormField(
|
|
controller: _instructionsController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Brewing Instructions *',
|
|
hintText: 'Step-by-step brewing instructions...',
|
|
border: OutlineInputBorder(),
|
|
alignLabelWithHint: true,
|
|
),
|
|
maxLines: 4,
|
|
validator: (value) {
|
|
if (value == null || value.trim().isEmpty) {
|
|
return 'Instructions are required';
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
const SizedBox(height: 16),
|
|
TextFormField(
|
|
controller: _notesController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Additional Notes',
|
|
hintText: 'Tips, variations, or other notes...',
|
|
border: OutlineInputBorder(),
|
|
alignLabelWithHint: true,
|
|
),
|
|
maxLines: 3,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
String _formatBrewMethod(BrewMethod method) {
|
|
switch (method) {
|
|
case BrewMethod.drip:
|
|
return 'Drip Coffee';
|
|
case BrewMethod.frenchPress:
|
|
return 'French Press';
|
|
case BrewMethod.pourOver:
|
|
return 'Pour Over';
|
|
case BrewMethod.espresso:
|
|
return 'Espresso';
|
|
}
|
|
}
|
|
|
|
String _formatServingTemp(ServingTemp temp) {
|
|
switch (temp) {
|
|
case ServingTemp.hot:
|
|
return 'Hot';
|
|
case ServingTemp.cold:
|
|
return 'Cold';
|
|
case ServingTemp.iced:
|
|
return 'Iced';
|
|
}
|
|
}
|
|
|
|
String _formatMilkType(MilkType milk) {
|
|
switch (milk) {
|
|
case MilkType.whole:
|
|
return 'Whole Milk';
|
|
case MilkType.skim:
|
|
return 'Skim Milk';
|
|
case MilkType.soy:
|
|
return 'Soy Milk';
|
|
case MilkType.almond:
|
|
return 'Almond Milk';
|
|
case MilkType.coconut:
|
|
return 'Coconut Milk';
|
|
case MilkType.oat:
|
|
return 'Oat Milk';
|
|
case MilkType.pistachio:
|
|
return 'Pistachio Milk';
|
|
}
|
|
}
|
|
|
|
void _saveRecipe() async {
|
|
if (!_formKey.currentState!.validate()) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
final recipe = Recipe(
|
|
id: widget.recipe?.id ?? DateTime.now().millisecondsSinceEpoch.toString(),
|
|
name: _nameController.text.trim(),
|
|
servingTemp: _selectedServingTemp,
|
|
milkType: _selectedMilkType,
|
|
brewMethod: _selectedBrewMethod,
|
|
grindSize: GrindSize.medium, // Parse from _grindSizeController if needed
|
|
coffeeAmount: double.parse(_coffeeAmountController.text.trim()),
|
|
waterAmount: double.parse(_waterAmountController.text.trim()),
|
|
brewTime: int.parse(_brewTimeController.text.trim()),
|
|
instructions: _instructionsController.text.trim(),
|
|
notes: _notesController.text.trim().isEmpty ? null : _notesController.text.trim(),
|
|
difficulty: Difficulty.intermediate,
|
|
equipmentNeeded: ['Grinder', 'Scale'],
|
|
yieldAmount: double.parse(_waterAmountController.text.trim()),
|
|
caffeinePer100ml: 50.0,
|
|
waterTemperature: 93,
|
|
bloomTime: 30,
|
|
totalExtractionTime: int.parse(_brewTimeController.text.trim()),
|
|
grindToWaterRatio: '1:16',
|
|
tags: [],
|
|
origin: 'User Created',
|
|
rating: 4.0,
|
|
popularity: 50,
|
|
createdBy: 'User',
|
|
isPublic: false,
|
|
lastModified: DateTime.now(),
|
|
);
|
|
|
|
final appState = Provider.of<AppState>(context, listen: false);
|
|
|
|
if (widget.recipe == null) {
|
|
await appState.addRecipe(recipe);
|
|
} else {
|
|
await appState.updateRecipe(recipe);
|
|
}
|
|
|
|
if (mounted) {
|
|
Navigator.of(context).pop();
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(widget.recipe == null
|
|
? 'Recipe added successfully!'
|
|
: 'Recipe updated successfully!'),
|
|
backgroundColor: Theme.of(context).colorScheme.primary,
|
|
),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Error saving recipe: $e'),
|
|
backgroundColor: Colors.red,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|