feat: add recipe creation, editing, and detail screens; enhance recipe model with instructions and ingredients

This commit is contained in:
Nils-Johan Gynther
2026-04-22 07:53:25 +02:00
parent 2ea18503ef
commit ed4e18dc31
10 changed files with 1017 additions and 44 deletions
+34 -4
View File
@@ -7,6 +7,9 @@ import '../ui/async_state_views.dart';
import '../../features/auth/data/auth_providers.dart';
import '../../features/auth/presentation/login_screen.dart';
import '../../features/profile/presentation/profile_screen.dart';
import '../../features/recipes/presentation/create_recipe_screen.dart';
import '../../features/recipes/presentation/recipe_detail_screen.dart';
import '../../features/recipes/presentation/recipe_edit_screen.dart';
import '../../features/recipes/presentation/recipes_screen.dart';
final appRouterProvider = Provider<GoRouter>((ref) {
@@ -22,22 +25,18 @@ final appRouterProvider = Provider<GoRouter>((ref) {
final isSplash = location == '/';
final isLogin = location == '/login';
// Keep user on splash while auth state is being resolved from storage.
if (isLoading) {
return isSplash ? null : '/';
}
// Redirect away from splash once auth is known.
if (isSplash) {
return isLoggedIn ? '/recipes' : '/login';
}
// Unauthenticated user trying to reach a protected route.
if (!isLoggedIn && !isLogin) {
return '/login';
}
// Authenticated user landing on login.
if (isLoggedIn && isLogin) {
return '/recipes';
}
@@ -55,6 +54,37 @@ final appRouterProvider = Provider<GoRouter>((ref) {
path: '/login',
builder: (context, state) => const LoginScreen(),
),
// Detail routes — outside ShellRoute to get full-screen with back button.
// /recipes/create must be listed before /recipes/:id to avoid conflict.
GoRoute(
path: '/recipes/create',
builder: (context, state) => const CreateRecipeScreen(),
),
GoRoute(
path: '/recipes/:id',
redirect: (context, state) {
final raw = state.pathParameters['id'] ?? '';
if (int.tryParse(raw) == null) return '/recipes';
return null;
},
builder: (context, state) {
final id = int.parse(state.pathParameters['id']!);
return RecipeDetailScreen(recipeId: id);
},
),
GoRoute(
path: '/recipes/:id/edit',
redirect: (context, state) {
final raw = state.pathParameters['id'] ?? '';
if (int.tryParse(raw) == null) return '/recipes';
return null;
},
builder: (context, state) {
final id = int.parse(state.pathParameters['id']!);
return RecipeEditScreen(recipeId: id);
},
),
// Shell routes — shared AppShell with navigation bar.
ShellRoute(
builder: (context, state, child) {
return AppShell(location: state.uri.path, child: child);
@@ -17,3 +17,12 @@ final recipesProvider = FutureProvider<List<Recipe>>((ref) async {
() => ref.read(recipeRepositoryProvider).fetchRecipes(token: token),
);
});
final recipeDetailProvider =
FutureProvider.family<Recipe, int>((ref, id) async {
final token = await ref.watch(authStateProvider.future);
return guardedApiCall(
ref,
() => ref.read(recipeRepositoryProvider).fetchRecipeDetail(id, token: token),
);
});
@@ -1,5 +1,6 @@
import '../../../core/api/api_client.dart';
import '../../../core/api/api_exception.dart';
import '../domain/parsed_recipe.dart';
import '../domain/recipe.dart';
class RecipeRepository {
@@ -10,14 +11,10 @@ class RecipeRepository {
Future<List<Recipe>> fetchRecipes({String? token}) async {
try {
final data = await _api.getJson('/recipes', token: token);
if (data is! List) {
throw const ApiException(
type: ApiErrorType.unknown,
message: 'Ogiltigt svar fran servern.',
);
type: ApiErrorType.unknown, message: 'Ogiltigt svar fran servern.');
}
return data
.map((e) => Recipe.fromJson(e as Map<String, dynamic>))
.toList();
@@ -25,9 +22,91 @@ class RecipeRepository {
rethrow;
} catch (_) {
throw const ApiException(
type: ApiErrorType.network,
message: 'Kunde inte hamta recept.',
type: ApiErrorType.network, message: 'Kunde inte hamta recept.');
}
}
Future<Recipe> fetchRecipeDetail(int id, {String? token}) async {
try {
final data = await _api.getJson('/recipes/$id', token: token);
if (data is! Map<String, dynamic>) {
throw const ApiException(
type: ApiErrorType.unknown, message: 'Ogiltigt svar fran servern.');
}
return Recipe.fromJson(data);
} on ApiException {
rethrow;
} catch (_) {
throw const ApiException(
type: ApiErrorType.network, message: 'Kunde inte hamta recept.');
}
}
Future<Recipe> createRecipe(Map<String, dynamic> body,
{String? token}) async {
try {
final data =
await _api.postJson('/recipes', body: body, token: token);
if (data is! Map<String, dynamic>) {
throw const ApiException(
type: ApiErrorType.unknown, message: 'Ogiltigt svar fran servern.');
}
return Recipe.fromJson(data);
} on ApiException {
rethrow;
} catch (_) {
throw const ApiException(
type: ApiErrorType.network, message: 'Kunde inte spara recept.');
}
}
Future<Recipe> updateRecipe(int id, Map<String, dynamic> body,
{String? token}) async {
try {
final data =
await _api.putJson('/recipes/$id', body: body, token: token);
if (data is! Map<String, dynamic>) {
throw const ApiException(
type: ApiErrorType.unknown, message: 'Ogiltigt svar fran servern.');
}
return Recipe.fromJson(data);
} on ApiException {
rethrow;
} catch (_) {
throw const ApiException(
type: ApiErrorType.network, message: 'Kunde inte uppdatera recept.');
}
}
Future<void> deleteRecipe(int id, {String? token}) async {
try {
await _api.deleteJson('/recipes/$id', token: token);
} on ApiException {
rethrow;
} catch (_) {
throw const ApiException(
type: ApiErrorType.network, message: 'Kunde inte ta bort recept.');
}
}
Future<ParsedRecipe> parseMarkdown(String markdown,
{String? token}) async {
try {
final data = await _api.postJson(
'/recipes/parse-markdown',
body: {'markdown': markdown},
token: token,
);
if (data is! Map<String, dynamic>) {
throw const ApiException(
type: ApiErrorType.unknown, message: 'Ogiltigt svar fran servern.');
}
return ParsedRecipe.fromJson(data);
} on ApiException {
rethrow;
} catch (_) {
throw const ApiException(
type: ApiErrorType.network, message: 'Kunde inte tolka receptet.');
}
}
}
@@ -0,0 +1,73 @@
class IngredientSuggestion {
final int productId;
final String productName;
final double score;
const IngredientSuggestion({
required this.productId,
required this.productName,
required this.score,
});
factory IngredientSuggestion.fromJson(Map<String, dynamic> json) =>
IngredientSuggestion(
productId: (json['productId'] as num).toInt(),
productName: json['productName'] as String? ?? '',
score: (json['score'] as num).toDouble(),
);
}
class ParsedIngredient {
final String rawName;
final double quantity;
final String unit;
final String? note;
final List<IngredientSuggestion> suggestions;
const ParsedIngredient({
required this.rawName,
required this.quantity,
required this.unit,
this.note,
required this.suggestions,
});
factory ParsedIngredient.fromJson(Map<String, dynamic> json) {
final rawSuggestions = json['suggestions'] as List<dynamic>? ?? [];
return ParsedIngredient(
rawName: json['rawName'] as String? ?? '',
quantity: (json['quantity'] as num? ?? 0).toDouble(),
unit: json['unit'] as String? ?? '',
note: json['note'] as String?,
suggestions: rawSuggestions
.map((s) => IngredientSuggestion.fromJson(s as Map<String, dynamic>))
.toList(),
);
}
}
class ParsedRecipe {
final String name;
final String? description;
final String? instructions;
final List<ParsedIngredient> ingredients;
const ParsedRecipe({
required this.name,
this.description,
this.instructions,
required this.ingredients,
});
factory ParsedRecipe.fromJson(Map<String, dynamic> json) {
final rawIngredients = json['ingredients'] as List<dynamic>? ?? [];
return ParsedRecipe(
name: json['name'] as String? ?? '',
description: json['description'] as String?,
instructions: json['instructions'] as String?,
ingredients: rawIngredients
.map((i) => ParsedIngredient.fromJson(i as Map<String, dynamic>))
.toList(),
);
}
}
@@ -1,9 +1,13 @@
import 'recipe_ingredient.dart';
class Recipe {
final int id;
final String title;
final String? description;
final String? imageUrl;
final int? servings;
final String? instructions;
final List<RecipeIngredient> ingredients;
const Recipe({
required this.id,
@@ -11,6 +15,8 @@ class Recipe {
this.description,
this.imageUrl,
this.servings,
this.instructions,
this.ingredients = const [],
});
factory Recipe.fromJson(Map<String, dynamic> json) {
@@ -19,6 +25,7 @@ class Recipe {
final dynamic rawDescription = json['description'];
final dynamic rawImageUrl = json['imageUrl'];
final dynamic rawServings = json['servings'];
final rawIngredients = json['ingredients'] as List<dynamic>? ?? [];
return Recipe(
id: rawId is num ? rawId.toInt() : int.parse(rawId.toString()),
@@ -30,6 +37,10 @@ class Recipe {
: (rawServings is num
? rawServings.toInt()
: int.tryParse(rawServings.toString())),
instructions: json['instructions'] as String?,
ingredients: rawIngredients
.map((i) => RecipeIngredient.fromJson(i as Map<String, dynamic>))
.toList(),
);
}
}
@@ -0,0 +1,32 @@
class RecipeIngredient {
final int id;
final int productId;
final String productName;
final double quantity;
final String unit;
final String? note;
const RecipeIngredient({
required this.id,
required this.productId,
required this.productName,
required this.quantity,
required this.unit,
this.note,
});
factory RecipeIngredient.fromJson(Map<String, dynamic> json) {
final product = json['product'] as Map<String, dynamic>?;
final rawQty = json['quantity'];
return RecipeIngredient(
id: (json['id'] as num).toInt(),
productId: (json['productId'] as num).toInt(),
productName: product?['name'] as String? ?? '',
quantity: rawQty is num
? rawQty.toDouble()
: double.tryParse(rawQty?.toString() ?? '') ?? 0,
unit: json['unit'] as String? ?? '',
note: json['note'] as String?,
);
}
}
@@ -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(),
),
),
);
}
}
@@ -0,0 +1,190 @@
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 '../../../core/ui/async_state_views.dart';
import '../../auth/data/auth_providers.dart';
import '../data/recipe_providers.dart';
import '../domain/recipe.dart';
class RecipeDetailScreen extends ConsumerWidget {
final int recipeId;
const RecipeDetailScreen({super.key, required this.recipeId});
@override
Widget build(BuildContext context, WidgetRef ref) {
final recipeAsync = ref.watch(recipeDetailProvider(recipeId));
return Scaffold(
appBar: AppBar(
title: Text(recipeAsync.valueOrNull?.title ?? 'Recept'),
actions: recipeAsync.valueOrNull == null
? []
: [
IconButton(
tooltip: 'Redigera',
icon: const Icon(Icons.edit_outlined),
onPressed: () =>
context.push('/recipes/$recipeId/edit'),
),
_DeleteButton(recipe: recipeAsync.value!),
],
),
body: recipeAsync.when(
loading: () => const LoadingStateView(label: 'Laddar recept...'),
error: (error, _) => ErrorStateView(
message: mapErrorToUserMessage(error),
onRetry: () => ref.invalidate(recipeDetailProvider(recipeId)),
),
data: (recipe) => _RecipeBody(recipe: recipe),
),
);
}
}
class _DeleteButton extends ConsumerWidget {
final Recipe recipe;
const _DeleteButton({required this.recipe});
@override
Widget build(BuildContext context, WidgetRef ref) {
return IconButton(
tooltip: 'Ta bort',
icon: const Icon(Icons.delete_outline),
onPressed: () => _confirmDelete(context, ref),
);
}
Future<void> _confirmDelete(BuildContext context, WidgetRef ref) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('Ta bort recept?'),
content: Text(
'Vill du ta bort "${recipe.title}"? Åtgärden kan inte ångras.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Avbryt'),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error),
onPressed: () => Navigator.pop(context, true),
child: const Text('Ta bort'),
),
],
),
);
if (confirmed != true || !context.mounted) return;
try {
final token = await ref.read(authStateProvider.future);
await ref.read(recipeRepositoryProvider).deleteRecipe(recipe.id,
token: token);
ref.invalidate(recipesProvider);
if (context.mounted) context.go('/recipes');
} on ApiException catch (e) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(mapErrorToUserMessage(e))),
);
}
}
}
class _RecipeBody extends StatelessWidget {
final Recipe recipe;
const _RecipeBody({required this.recipe});
String _formatQty(double qty) {
if (qty == 0) return '';
return qty == qty.truncateToDouble()
? qty.toInt().toString()
: qty.toString();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (recipe.imageUrl != null)
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: AspectRatio(
aspectRatio: 16 / 9,
child: Image.network(
recipe.imageUrl!,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
),
),
),
if (recipe.imageUrl != null) const SizedBox(height: 20),
Text(recipe.title, style: theme.textTheme.headlineSmall),
if (recipe.description != null) ...[
const SizedBox(height: 8),
Text(recipe.description!,
style: theme.textTheme.bodyMedium
?.copyWith(color: theme.colorScheme.onSurfaceVariant)),
],
if (recipe.servings != null) ...[
const SizedBox(height: 8),
Row(
children: [
const Icon(Icons.people_outline, size: 16),
const SizedBox(width: 4),
Text('${recipe.servings} portioner',
style: theme.textTheme.bodySmall),
],
),
],
if (recipe.ingredients.isNotEmpty) ...[
const SizedBox(height: 24),
Text('Ingredienser', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
...recipe.ingredients.map((ing) {
final qtyStr = _formatQty(ing.quantity);
final parts = [
if (qtyStr.isNotEmpty) qtyStr,
if (ing.unit.isNotEmpty) ing.unit,
ing.productName,
if (ing.note != null) '(${ing.note})',
];
return Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(''),
Expanded(child: Text(parts.join(' '))),
],
),
);
}),
],
if (recipe.instructions != null &&
recipe.instructions!.isNotEmpty) ...[
const SizedBox(height: 24),
Text('Tillvägagångssätt', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
Text(recipe.instructions!,
style: theme.textTheme.bodyMedium
?.copyWith(height: 1.6)),
],
const SizedBox(height: 40),
],
),
);
}
}
@@ -0,0 +1,204 @@
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 '../../../core/ui/async_state_views.dart';
import '../../auth/data/auth_providers.dart';
import '../data/recipe_providers.dart';
import '../domain/recipe.dart';
class RecipeEditScreen extends ConsumerStatefulWidget {
final int recipeId;
const RecipeEditScreen({super.key, required this.recipeId});
@override
ConsumerState<RecipeEditScreen> createState() => _RecipeEditScreenState();
}
class _RecipeEditScreenState extends ConsumerState<RecipeEditScreen> {
final _formKey = GlobalKey<FormState>();
bool _initialized = false;
late TextEditingController _nameCtrl;
late TextEditingController _descCtrl;
late TextEditingController _servingsCtrl;
late TextEditingController _instructionsCtrl;
bool _isSaving = false;
String? _saveError;
@override
void dispose() {
if (_initialized) {
_nameCtrl.dispose();
_descCtrl.dispose();
_servingsCtrl.dispose();
_instructionsCtrl.dispose();
}
super.dispose();
}
void _initControllers(Recipe recipe) {
_nameCtrl = TextEditingController(text: recipe.title);
_descCtrl = TextEditingController(text: recipe.description ?? '');
_servingsCtrl =
TextEditingController(text: recipe.servings?.toString() ?? '');
_instructionsCtrl =
TextEditingController(text: recipe.instructions ?? '');
_initialized = true;
}
Future<void> _save() async {
if (!(_formKey.currentState?.validate() ?? false)) return;
setState(() {
_isSaving = true;
_saveError = null;
});
try {
final token = await ref.read(authStateProvider.future);
final servings = int.tryParse(_servingsCtrl.text.trim());
await ref.read(recipeRepositoryProvider).updateRecipe(
widget.recipeId,
{
'name': _nameCtrl.text.trim(),
'description': _descCtrl.text.trim().isEmpty
? null
: _descCtrl.text.trim(),
if (servings != null) 'servings': servings,
'instructions': _instructionsCtrl.text.trim().isEmpty
? null
: _instructionsCtrl.text.trim(),
},
token: token,
);
ref.invalidate(recipeDetailProvider(widget.recipeId));
ref.invalidate(recipesProvider);
if (mounted) context.pop();
} 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) {
final recipeAsync = ref.watch(recipeDetailProvider(widget.recipeId));
return Scaffold(
appBar: AppBar(
title: const Text('Redigera recept'),
actions: [
if (_initialized)
TextButton(
onPressed: _isSaving ? null : _save,
child: _isSaving
? const SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Spara'),
),
],
),
body: recipeAsync.when(
loading: () => const LoadingStateView(label: 'Laddar recept...'),
error: (error, _) => ErrorStateView(
message: mapErrorToUserMessage(error),
onRetry: () => ref.invalidate(recipeDetailProvider(widget.recipeId)),
),
data: (recipe) {
if (!_initialized) _initControllers(recipe);
return _buildForm(context);
},
),
);
}
Widget _buildForm(BuildContext context) {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextFormField(
controller: _nameCtrl,
decoration: const InputDecoration(labelText: 'Receptnamn'),
validator: (v) =>
(v == null || v.trim().isEmpty) ? 'Ange ett receptnamn.' : null,
),
const SizedBox(height: 16),
TextFormField(
controller: _descCtrl,
decoration:
const InputDecoration(labelText: 'Beskrivning (valfritt)'),
maxLines: 3,
),
const SizedBox(height: 16),
TextFormField(
controller: _servingsCtrl,
decoration:
const InputDecoration(labelText: 'Antal portioner (valfritt)'),
keyboardType: TextInputType.number,
validator: (v) {
if (v == null || v.trim().isEmpty) return null;
if (int.tryParse(v.trim()) == null) {
return 'Ange ett heltal.';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _instructionsCtrl,
decoration:
const InputDecoration(labelText: 'Tillvägagångssätt (valfritt)'),
maxLines: 10,
textAlignVertical: TextAlignVertical.top,
),
const SizedBox(height: 8),
Text(
'Ingredienser redigeras via Skapa recept-flödet.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant),
),
if (_saveError != null) ...[
const SizedBox(height: 16),
Text(
_saveError!,
style:
TextStyle(color: Theme.of(context).colorScheme.error),
textAlign: TextAlign.center,
),
],
const SizedBox(height: 24),
FilledButton(
onPressed: _isSaving ? null : _save,
child: _isSaving
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Spara ändringar'),
),
const SizedBox(height: 40),
],
),
),
);
}
}
@@ -1,5 +1,6 @@
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/ui/async_state_views.dart';
@@ -11,40 +12,49 @@ class RecipesScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final recipesAsync = ref.watch(recipesProvider);
return recipesAsync.when(
loading: () => const LoadingStateView(label: 'Laddar recept...'),
error: (error, _) => ErrorStateView(
message: mapErrorToUserMessage(error),
onRetry: () => ref.invalidate(recipesProvider),
),
data: (recipes) {
if (recipes.isEmpty) {
return const EmptyStateView(
title: 'Inga recept hittades',
description: 'Lagg till ett recept for att komma igang.',
);
}
return ListView.builder(
itemCount: recipes.length,
itemBuilder: (context, index) {
final recipe = recipes[index];
return ListTile(
leading: recipe.imageUrl != null
? Image.network(recipe.imageUrl!, width: 56, fit: BoxFit.cover)
: const Icon(Icons.restaurant),
title: Text(recipe.title),
subtitle: recipe.description != null
? Text(
recipe.description!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
)
: null,
return Scaffold(
body: recipesAsync.when(
loading: () => const LoadingStateView(label: 'Laddar recept...'),
error: (error, _) => ErrorStateView(
message: mapErrorToUserMessage(error),
onRetry: () => ref.invalidate(recipesProvider),
),
data: (recipes) {
if (recipes.isEmpty) {
return const EmptyStateView(
title: 'Inga recept hittades',
description: 'Lägg till ett recept för att komma igång.',
);
},
);
},
}
return ListView.builder(
itemCount: recipes.length,
itemBuilder: (context, index) {
final recipe = recipes[index];
return ListTile(
leading: recipe.imageUrl != null
? Image.network(recipe.imageUrl!, width: 56, fit: BoxFit.cover)
: const Icon(Icons.restaurant),
title: Text(recipe.title),
subtitle: recipe.description != null
? Text(
recipe.description!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
)
: null,
trailing: const Icon(Icons.chevron_right),
onTap: () => context.push('/recipes/${recipe.id}'),
);
},
);
},
),
floatingActionButton: FloatingActionButton(
tooltip: 'Nytt recept',
onPressed: () => context.push('/recipes/create'),
child: const Icon(Icons.add),
),
);
}
}