mirror of
https://gitlab.com/Zer0interrupt/recipe-manager.git
synced 2026-11-02 07:28:25 -05:00
408 lines
14 KiB
Dart
408 lines
14 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import '../../core/constants/app_theme.dart';
|
|
import '../../core/models/recipe.dart';
|
|
import '../../core/providers.dart';
|
|
|
|
class RecipeEditScreen extends ConsumerStatefulWidget {
|
|
final Recipe? recipe;
|
|
|
|
const RecipeEditScreen({super.key, this.recipe});
|
|
|
|
@override
|
|
ConsumerState<RecipeEditScreen> createState() => _RecipeEditScreenState();
|
|
}
|
|
|
|
class _RecipeEditScreenState extends ConsumerState<RecipeEditScreen> {
|
|
final _formKey = GlobalKey<FormState>();
|
|
|
|
late TextEditingController _titleController;
|
|
late TextEditingController _descController;
|
|
late TextEditingController _servingsController;
|
|
late TextEditingController _prepTimeController;
|
|
late TextEditingController _cookTimeController;
|
|
late TextEditingController _sourceUrlController;
|
|
late TextEditingController _imageUrlController;
|
|
late TextEditingController _ingredientsController;
|
|
late TextEditingController _instructionsController;
|
|
|
|
bool _isSaving = false;
|
|
bool _isPolishing = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
final r = widget.recipe;
|
|
_titleController = TextEditingController(text: r?.title ?? '');
|
|
_descController = TextEditingController(text: r?.description ?? '');
|
|
_servingsController = TextEditingController(text: r?.servings ?? '');
|
|
_prepTimeController = TextEditingController(text: r?.prepTime ?? '');
|
|
_cookTimeController = TextEditingController(text: r?.cookTime ?? '');
|
|
_sourceUrlController = TextEditingController(text: r?.sourceUrl ?? '');
|
|
_imageUrlController = TextEditingController(text: r?.image ?? '');
|
|
_ingredientsController = TextEditingController(text: r?.ingredients.join('\n') ?? '');
|
|
_instructionsController = TextEditingController(text: r?.instructions.join('\n') ?? '');
|
|
|
|
_prepTimeController.addListener(_updateTimes);
|
|
_cookTimeController.addListener(_updateTimes);
|
|
}
|
|
|
|
void _updateTimes() {
|
|
setState(() {});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_titleController.dispose();
|
|
_descController.dispose();
|
|
_servingsController.dispose();
|
|
_prepTimeController.dispose();
|
|
_cookTimeController.dispose();
|
|
_sourceUrlController.dispose();
|
|
_imageUrlController.dispose();
|
|
_ingredientsController.dispose();
|
|
_instructionsController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
String get _calculatedTotal {
|
|
return RecipeTimeUtils.calculateTotalTime(
|
|
prepTime: _prepTimeController.text.trim(),
|
|
cookTime: _cookTimeController.text.trim(),
|
|
totalTime: widget.recipe?.totalTime,
|
|
);
|
|
}
|
|
|
|
Future<void> _handleAiPolish() async {
|
|
final title = _titleController.text.trim();
|
|
final rawIng = _ingredientsController.text.trim();
|
|
final rawInst = _instructionsController.text.trim();
|
|
|
|
if (rawIng.isEmpty && rawInst.isEmpty) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Please add ingredients or instructions first to polish with AI.')),
|
|
);
|
|
return;
|
|
}
|
|
|
|
setState(() => _isPolishing = true);
|
|
try {
|
|
final api = ref.read(apiClientProvider);
|
|
final result = await api.aiPolishRecipe(
|
|
title: title,
|
|
ingredients: rawIng.split('\n').map((s) => s.trim()).where((s) => s.isNotEmpty).toList(),
|
|
instructions: rawInst.split('\n').map((s) => s.trim()).where((s) => s.isNotEmpty).toList(),
|
|
);
|
|
|
|
if (mounted) {
|
|
if (result['title'] != null && result['title'].toString().isNotEmpty) {
|
|
_titleController.text = result['title'].toString();
|
|
}
|
|
if (result['description'] != null && result['description'].toString().isNotEmpty) {
|
|
_descController.text = result['description'].toString();
|
|
}
|
|
if (result['servings'] != null && result['servings'].toString().isNotEmpty) {
|
|
_servingsController.text = result['servings'].toString();
|
|
}
|
|
if (result['prepTime'] != null && result['prepTime'].toString().isNotEmpty) {
|
|
_prepTimeController.text = result['prepTime'].toString();
|
|
}
|
|
if (result['cookTime'] != null && result['cookTime'].toString().isNotEmpty) {
|
|
_cookTimeController.text = result['cookTime'].toString();
|
|
}
|
|
if (result['ingredients'] is List) {
|
|
final list = (result['ingredients'] as List).map((e) => e.toString()).toList();
|
|
_ingredientsController.text = list.join('\n');
|
|
}
|
|
if (result['instructions'] is List) {
|
|
final list = (result['instructions'] as List).map((e) => e.toString()).toList();
|
|
_instructionsController.text = list.join('\n');
|
|
}
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
backgroundColor: AppTheme.secondary,
|
|
content: Row(
|
|
children: [
|
|
Icon(Icons.auto_awesome, color: Colors.white, size: 20),
|
|
SizedBox(width: 10),
|
|
Text('Recipe polished and organized by Gemini AI!'),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
backgroundColor: AppTheme.danger,
|
|
content: Text('AI Polish failed: $e'),
|
|
),
|
|
);
|
|
}
|
|
} finally {
|
|
if (mounted) setState(() => _isPolishing = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _handleSave() async {
|
|
if (!_formKey.currentState!.validate()) return;
|
|
|
|
setState(() => _isSaving = true);
|
|
final isNew = widget.recipe == null;
|
|
|
|
final prep = _prepTimeController.text.trim();
|
|
final cook = _cookTimeController.text.trim();
|
|
final total = RecipeTimeUtils.calculateTotalTime(
|
|
prepTime: prep,
|
|
cookTime: cook,
|
|
totalTime: widget.recipe?.totalTime,
|
|
);
|
|
|
|
final ingList = _ingredientsController.text
|
|
.split('\n')
|
|
.map((s) => s.trim())
|
|
.where((s) => s.isNotEmpty)
|
|
.toList();
|
|
|
|
final instList = _instructionsController.text
|
|
.split('\n')
|
|
.map((s) => s.trim())
|
|
.where((s) => s.isNotEmpty)
|
|
.toList();
|
|
|
|
final updated = Recipe(
|
|
id: widget.recipe?.id,
|
|
title: _titleController.text.trim(),
|
|
description: _descController.text.trim(),
|
|
servings: _servingsController.text.trim(),
|
|
prepTime: prep,
|
|
cookTime: cook,
|
|
totalTime: total,
|
|
sourceUrl: _sourceUrlController.text.trim(),
|
|
image: _imageUrlController.text.trim().isNotEmpty
|
|
? _imageUrlController.text.trim()
|
|
: widget.recipe?.image,
|
|
images: widget.recipe?.images ?? [],
|
|
ingredients: ingList,
|
|
instructions: instList,
|
|
shareToken: widget.recipe?.shareToken,
|
|
);
|
|
|
|
try {
|
|
await ref.read(recipeListProvider.notifier).addOrUpdate(updated);
|
|
if (mounted) {
|
|
Navigator.pop(context, true);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
backgroundColor: AppTheme.secondary,
|
|
content: Text(isNew ? 'Created "${updated.title}"' : 'Updated "${updated.title}"'),
|
|
),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
backgroundColor: AppTheme.danger,
|
|
content: Text('Failed to save recipe: $e'),
|
|
),
|
|
);
|
|
}
|
|
} finally {
|
|
if (mounted) setState(() => _isSaving = false);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final isNew = widget.recipe == null;
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Text(isNew ? 'New Recipe' : 'Edit Recipe'),
|
|
actions: [
|
|
if (_isPolishing)
|
|
const Padding(
|
|
padding: EdgeInsets.symmetric(horizontal: 16),
|
|
child: Center(child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: AppTheme.secondary))),
|
|
)
|
|
else
|
|
TextButton.icon(
|
|
onPressed: _handleAiPolish,
|
|
icon: const Icon(Icons.auto_awesome, color: AppTheme.secondary, size: 18),
|
|
label: const Text('AI Polish', style: TextStyle(color: AppTheme.secondary, fontWeight: FontWeight.w700)),
|
|
),
|
|
const SizedBox(width: 4),
|
|
Padding(
|
|
padding: const EdgeInsets.only(right: 12),
|
|
child: _isSaving
|
|
? const Center(child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: AppTheme.primary)))
|
|
: ElevatedButton(
|
|
onPressed: _handleSave,
|
|
style: ElevatedButton.styleFrom(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
minimumSize: const Size(60, 36),
|
|
),
|
|
child: const Text('Save'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
body: Form(
|
|
key: _formKey,
|
|
child: ListView(
|
|
padding: const EdgeInsets.all(16),
|
|
children: [
|
|
// Title Field
|
|
TextFormField(
|
|
controller: _titleController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Recipe Title *',
|
|
hintText: 'e.g. Creamy Tuscan Garlic Chicken',
|
|
prefixIcon: Icon(Icons.restaurant_rounded),
|
|
),
|
|
validator: (val) => (val == null || val.trim().isEmpty) ? 'Please enter a recipe title' : null,
|
|
),
|
|
const SizedBox(height: 14),
|
|
|
|
// Description Field
|
|
TextFormField(
|
|
controller: _descController,
|
|
maxLines: 2,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Description',
|
|
hintText: 'A short overview or tasting notes',
|
|
prefixIcon: Icon(Icons.description_outlined),
|
|
),
|
|
),
|
|
const SizedBox(height: 14),
|
|
|
|
// Servings, Prep Time, Cook Time Row
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: TextFormField(
|
|
controller: _servingsController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Servings',
|
|
hintText: 'e.g. 4',
|
|
isDense: true,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: TextFormField(
|
|
controller: _prepTimeController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Prep Time',
|
|
hintText: '15 mins',
|
|
isDense: true,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: TextFormField(
|
|
controller: _cookTimeController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Cook Time',
|
|
hintText: '30 mins',
|
|
isDense: true,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
|
|
// Total Time live preview badge
|
|
if (_calculatedTotal.isNotEmpty) ...[
|
|
const SizedBox(height: 8),
|
|
Row(
|
|
children: [
|
|
const Icon(Icons.timer_rounded, size: 14, color: AppTheme.primary),
|
|
const SizedBox(width: 6),
|
|
Text(
|
|
'Calculated Total Time: $_calculatedTotal',
|
|
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: AppTheme.primary),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
const SizedBox(height: 14),
|
|
|
|
// Source URL
|
|
TextFormField(
|
|
controller: _sourceUrlController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Source URL',
|
|
hintText: 'https://...',
|
|
prefixIcon: Icon(Icons.link_rounded),
|
|
isDense: true,
|
|
),
|
|
),
|
|
const SizedBox(height: 14),
|
|
|
|
// Image URL
|
|
TextFormField(
|
|
controller: _imageUrlController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Cover Image URL',
|
|
hintText: 'https://...',
|
|
prefixIcon: Icon(Icons.image_outlined),
|
|
isDense: true,
|
|
),
|
|
),
|
|
const SizedBox(height: 20),
|
|
|
|
// Ingredients Section
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
const Text('Ingredients', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: AppTheme.primary)),
|
|
Text(
|
|
'One per line',
|
|
style: TextStyle(fontSize: 12, color: AppTheme.textMuted.withOpacity(0.8)),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
TextFormField(
|
|
controller: _ingredientsController,
|
|
maxLines: 8,
|
|
decoration: const InputDecoration(
|
|
hintText: '2 chicken breasts, diced\n1 cup heavy cream\n2 cloves garlic, minced\n1 cup baby spinach',
|
|
alignLabelWithHint: true,
|
|
),
|
|
),
|
|
const SizedBox(height: 20),
|
|
|
|
// Instructions Section
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
const Text('Instructions', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: AppTheme.primary)),
|
|
Text(
|
|
'One step per line',
|
|
style: TextStyle(fontSize: 12, color: AppTheme.textMuted.withOpacity(0.8)),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
TextFormField(
|
|
controller: _instructionsController,
|
|
maxLines: 8,
|
|
decoration: const InputDecoration(
|
|
hintText: 'Heat olive oil in a skillet over medium heat.\nAdd garlic and cook until fragrant.\nPour in heavy cream and simmer until thickened.',
|
|
alignLabelWithHint: true,
|
|
),
|
|
),
|
|
const SizedBox(height: 32),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|