feat: add recipe creation, editing, and detail screens; enhance recipe model with instructions and ingredients
This commit is contained in:
@@ -0,0 +1,335 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../core/api/api_error_mapper.dart';
|
||||
import '../../../core/api/api_exception.dart';
|
||||
import '../../auth/data/auth_providers.dart';
|
||||
import '../data/recipe_providers.dart';
|
||||
import '../domain/parsed_recipe.dart';
|
||||
|
||||
enum _Step { input, review }
|
||||
|
||||
class CreateRecipeScreen extends ConsumerStatefulWidget {
|
||||
const CreateRecipeScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<CreateRecipeScreen> createState() =>
|
||||
_CreateRecipeScreenState();
|
||||
}
|
||||
|
||||
class _CreateRecipeScreenState extends ConsumerState<CreateRecipeScreen> {
|
||||
_Step _step = _Step.input;
|
||||
|
||||
// Step 1 — markdown input
|
||||
final _markdownCtrl = TextEditingController();
|
||||
bool _isParsing = false;
|
||||
String? _parseError;
|
||||
|
||||
// Step 2 — review state
|
||||
ParsedRecipe? _parsed;
|
||||
late TextEditingController _nameCtrl;
|
||||
late TextEditingController _servingsCtrl;
|
||||
late List<bool> _included;
|
||||
late Map<int, int?> _selectedProductIds;
|
||||
late Map<int, String?> _selectedProductNames;
|
||||
|
||||
bool _isSaving = false;
|
||||
String? _saveError;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_markdownCtrl.dispose();
|
||||
if (_step == _Step.review) {
|
||||
_nameCtrl.dispose();
|
||||
_servingsCtrl.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _initReviewState(ParsedRecipe parsed) {
|
||||
_nameCtrl = TextEditingController(text: parsed.name);
|
||||
_servingsCtrl = TextEditingController();
|
||||
_included = List.generate(parsed.ingredients.length, (_) => true);
|
||||
_selectedProductIds = {};
|
||||
_selectedProductNames = {};
|
||||
for (var i = 0; i < parsed.ingredients.length; i++) {
|
||||
final suggestions = parsed.ingredients[i].suggestions;
|
||||
if (suggestions.isNotEmpty) {
|
||||
_selectedProductIds[i] = suggestions.first.productId;
|
||||
_selectedProductNames[i] = suggestions.first.productName;
|
||||
} else {
|
||||
_selectedProductIds[i] = null;
|
||||
_selectedProductNames[i] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _parseMarkdown() async {
|
||||
final markdown = _markdownCtrl.text.trim();
|
||||
if (markdown.isEmpty) {
|
||||
setState(() => _parseError = 'Klistra in eller skriv ett recept i Markdown-format.');
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_isParsing = true;
|
||||
_parseError = null;
|
||||
});
|
||||
try {
|
||||
final token = await ref.read(authStateProvider.future);
|
||||
final parsed = await ref
|
||||
.read(recipeRepositoryProvider)
|
||||
.parseMarkdown(markdown, token: token);
|
||||
_initReviewState(parsed);
|
||||
setState(() {
|
||||
_parsed = parsed;
|
||||
_step = _Step.review;
|
||||
_isParsing = false;
|
||||
});
|
||||
} on ApiException catch (e) {
|
||||
if (e.type == ApiErrorType.unauthorized) {
|
||||
await ref.read(authStateProvider.notifier).logout();
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_parseError = mapErrorToUserMessage(e);
|
||||
_isParsing = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
final name = _nameCtrl.text.trim();
|
||||
if (name.isEmpty) {
|
||||
setState(() => _saveError = 'Receptnamnet får inte vara tomt.');
|
||||
return;
|
||||
}
|
||||
|
||||
final ingredients = <Map<String, dynamic>>[];
|
||||
for (var i = 0; i < _parsed!.ingredients.length; i++) {
|
||||
if (!_included[i]) continue;
|
||||
final productId = _selectedProductIds[i];
|
||||
if (productId == null) continue;
|
||||
final ing = _parsed!.ingredients[i];
|
||||
ingredients.add({
|
||||
'productId': productId,
|
||||
'quantity': ing.quantity,
|
||||
'unit': ing.unit,
|
||||
if (ing.note != null) 'note': ing.note,
|
||||
});
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isSaving = true;
|
||||
_saveError = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final token = await ref.read(authStateProvider.future);
|
||||
final servingsRaw = int.tryParse(_servingsCtrl.text.trim());
|
||||
final created = await ref.read(recipeRepositoryProvider).createRecipe(
|
||||
{
|
||||
'name': name,
|
||||
if (_parsed!.description != null) 'description': _parsed!.description,
|
||||
if (servingsRaw != null) 'servings': servingsRaw,
|
||||
if (_parsed!.instructions != null)
|
||||
'instructions': _parsed!.instructions,
|
||||
'ingredients': ingredients,
|
||||
},
|
||||
token: token,
|
||||
);
|
||||
ref.invalidate(recipesProvider);
|
||||
if (mounted) context.go('/recipes/${created.id}');
|
||||
} on ApiException catch (e) {
|
||||
if (e.type == ApiErrorType.unauthorized) {
|
||||
await ref.read(authStateProvider.notifier).logout();
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_saveError = mapErrorToUserMessage(e);
|
||||
_isSaving = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title:
|
||||
Text(_step == _Step.input ? 'Nytt recept' : 'Granska ingredienser'),
|
||||
leading: _step == _Step.review
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => setState(() {
|
||||
_step = _Step.input;
|
||||
_nameCtrl.dispose();
|
||||
_servingsCtrl.dispose();
|
||||
}),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
body: _step == _Step.input ? _buildInputStep() : _buildReviewStep(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInputStep() {
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: TextField(
|
||||
controller: _markdownCtrl,
|
||||
maxLines: null,
|
||||
expands: true,
|
||||
textAlignVertical: TextAlignVertical.top,
|
||||
decoration: const InputDecoration(
|
||||
hintText: '# Receptnamn\n\n## Ingredienser\n- 500 g köttfärs\n- 1 st lök\n\n## Tillvägagångssätt\nStek löken...',
|
||||
border: OutlineInputBorder(),
|
||||
alignLabelWithHint: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_parseError != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
_parseError!,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: _isParsing ? null : _parseMarkdown,
|
||||
child: _isParsing
|
||||
? const SizedBox(
|
||||
height: 18,
|
||||
width: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Granska ingredienser'),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildReviewStep() {
|
||||
final parsed = _parsed!;
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
children: [
|
||||
TextField(
|
||||
controller: _nameCtrl,
|
||||
decoration: const InputDecoration(labelText: 'Receptnamn'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _servingsCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Antal portioner (valfritt)'),
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
if (parsed.ingredients.isNotEmpty) ...[
|
||||
const SizedBox(height: 20),
|
||||
Text('Ingredienser',
|
||||
style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Bocka av ingredienser att inkludera och välj rätt produkt.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
...List.generate(
|
||||
parsed.ingredients.length,
|
||||
(i) => _buildIngredientRow(i, parsed.ingredients[i])),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_saveError != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
_saveError!,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: _isSaving ? null : _save,
|
||||
child: _isSaving
|
||||
? const SizedBox(
|
||||
height: 18,
|
||||
width: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Spara recept'),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildIngredientRow(int index, ParsedIngredient ing) {
|
||||
final qtyStr = ing.quantity > 0
|
||||
? (ing.quantity == ing.quantity.truncateToDouble()
|
||||
? '${ing.quantity.toInt()} '
|
||||
: '${ing.quantity} ')
|
||||
: '';
|
||||
final unitStr = ing.unit.isNotEmpty ? '${ing.unit} ' : '';
|
||||
final noteStr = ing.note != null ? ' (${ing.note})' : '';
|
||||
final label = '$qtyStr$unitStr${ing.rawName}$noteStr';
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: CheckboxListTile(
|
||||
value: _included[index],
|
||||
onChanged: (v) => setState(() => _included[index] = v ?? false),
|
||||
title: Text(label),
|
||||
subtitle: ing.suggestions.isEmpty
|
||||
? Text(
|
||||
'Ingen produkt hittades — ingrediensen hoppas över.',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
fontSize: 12),
|
||||
)
|
||||
: DropdownButton<int>(
|
||||
value: _selectedProductIds[index],
|
||||
isExpanded: true,
|
||||
onChanged: _included[index]
|
||||
? (id) {
|
||||
if (id == null) return;
|
||||
setState(() {
|
||||
_selectedProductIds[index] = id;
|
||||
_selectedProductNames[index] = ing.suggestions
|
||||
.firstWhere((s) => s.productId == id)
|
||||
.productName;
|
||||
});
|
||||
}
|
||||
: null,
|
||||
items: ing.suggestions
|
||||
.map((s) => DropdownMenuItem(
|
||||
value: s.productId,
|
||||
child: Text(s.productName),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user