recipe-manager/mobile/lib/features/editor/recipe_edit_screen.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),
],
),
),
);
}
}