feat: add recipe creation, editing, and detail screens; enhance recipe model with instructions and ingredients
This commit is contained in:
@@ -7,6 +7,9 @@ import '../ui/async_state_views.dart';
|
|||||||
import '../../features/auth/data/auth_providers.dart';
|
import '../../features/auth/data/auth_providers.dart';
|
||||||
import '../../features/auth/presentation/login_screen.dart';
|
import '../../features/auth/presentation/login_screen.dart';
|
||||||
import '../../features/profile/presentation/profile_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';
|
import '../../features/recipes/presentation/recipes_screen.dart';
|
||||||
|
|
||||||
final appRouterProvider = Provider<GoRouter>((ref) {
|
final appRouterProvider = Provider<GoRouter>((ref) {
|
||||||
@@ -22,22 +25,18 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
|||||||
final isSplash = location == '/';
|
final isSplash = location == '/';
|
||||||
final isLogin = location == '/login';
|
final isLogin = location == '/login';
|
||||||
|
|
||||||
// Keep user on splash while auth state is being resolved from storage.
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return isSplash ? null : '/';
|
return isSplash ? null : '/';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redirect away from splash once auth is known.
|
|
||||||
if (isSplash) {
|
if (isSplash) {
|
||||||
return isLoggedIn ? '/recipes' : '/login';
|
return isLoggedIn ? '/recipes' : '/login';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unauthenticated user trying to reach a protected route.
|
|
||||||
if (!isLoggedIn && !isLogin) {
|
if (!isLoggedIn && !isLogin) {
|
||||||
return '/login';
|
return '/login';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Authenticated user landing on login.
|
|
||||||
if (isLoggedIn && isLogin) {
|
if (isLoggedIn && isLogin) {
|
||||||
return '/recipes';
|
return '/recipes';
|
||||||
}
|
}
|
||||||
@@ -55,6 +54,37 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
|||||||
path: '/login',
|
path: '/login',
|
||||||
builder: (context, state) => const LoginScreen(),
|
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(
|
ShellRoute(
|
||||||
builder: (context, state, child) {
|
builder: (context, state, child) {
|
||||||
return AppShell(location: state.uri.path, child: 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),
|
() => 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_client.dart';
|
||||||
import '../../../core/api/api_exception.dart';
|
import '../../../core/api/api_exception.dart';
|
||||||
|
import '../domain/parsed_recipe.dart';
|
||||||
import '../domain/recipe.dart';
|
import '../domain/recipe.dart';
|
||||||
|
|
||||||
class RecipeRepository {
|
class RecipeRepository {
|
||||||
@@ -10,14 +11,10 @@ class RecipeRepository {
|
|||||||
Future<List<Recipe>> fetchRecipes({String? token}) async {
|
Future<List<Recipe>> fetchRecipes({String? token}) async {
|
||||||
try {
|
try {
|
||||||
final data = await _api.getJson('/recipes', token: token);
|
final data = await _api.getJson('/recipes', token: token);
|
||||||
|
|
||||||
if (data is! List) {
|
if (data is! List) {
|
||||||
throw const ApiException(
|
throw const ApiException(
|
||||||
type: ApiErrorType.unknown,
|
type: ApiErrorType.unknown, message: 'Ogiltigt svar fran servern.');
|
||||||
message: 'Ogiltigt svar fran servern.',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return data
|
return data
|
||||||
.map((e) => Recipe.fromJson(e as Map<String, dynamic>))
|
.map((e) => Recipe.fromJson(e as Map<String, dynamic>))
|
||||||
.toList();
|
.toList();
|
||||||
@@ -25,9 +22,91 @@ class RecipeRepository {
|
|||||||
rethrow;
|
rethrow;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
throw const ApiException(
|
throw const ApiException(
|
||||||
type: ApiErrorType.network,
|
type: ApiErrorType.network, message: 'Kunde inte hamta recept.');
|
||||||
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 {
|
class Recipe {
|
||||||
final int id;
|
final int id;
|
||||||
final String title;
|
final String title;
|
||||||
final String? description;
|
final String? description;
|
||||||
final String? imageUrl;
|
final String? imageUrl;
|
||||||
final int? servings;
|
final int? servings;
|
||||||
|
final String? instructions;
|
||||||
|
final List<RecipeIngredient> ingredients;
|
||||||
|
|
||||||
const Recipe({
|
const Recipe({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -11,6 +15,8 @@ class Recipe {
|
|||||||
this.description,
|
this.description,
|
||||||
this.imageUrl,
|
this.imageUrl,
|
||||||
this.servings,
|
this.servings,
|
||||||
|
this.instructions,
|
||||||
|
this.ingredients = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
factory Recipe.fromJson(Map<String, dynamic> json) {
|
factory Recipe.fromJson(Map<String, dynamic> json) {
|
||||||
@@ -19,6 +25,7 @@ class Recipe {
|
|||||||
final dynamic rawDescription = json['description'];
|
final dynamic rawDescription = json['description'];
|
||||||
final dynamic rawImageUrl = json['imageUrl'];
|
final dynamic rawImageUrl = json['imageUrl'];
|
||||||
final dynamic rawServings = json['servings'];
|
final dynamic rawServings = json['servings'];
|
||||||
|
final rawIngredients = json['ingredients'] as List<dynamic>? ?? [];
|
||||||
|
|
||||||
return Recipe(
|
return Recipe(
|
||||||
id: rawId is num ? rawId.toInt() : int.parse(rawId.toString()),
|
id: rawId is num ? rawId.toInt() : int.parse(rawId.toString()),
|
||||||
@@ -30,6 +37,10 @@ class Recipe {
|
|||||||
: (rawServings is num
|
: (rawServings is num
|
||||||
? rawServings.toInt()
|
? rawServings.toInt()
|
||||||
: int.tryParse(rawServings.toString())),
|
: 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/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.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_error_mapper.dart';
|
||||||
import '../../../core/ui/async_state_views.dart';
|
import '../../../core/ui/async_state_views.dart';
|
||||||
@@ -11,40 +12,49 @@ class RecipesScreen extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final recipesAsync = ref.watch(recipesProvider);
|
final recipesAsync = ref.watch(recipesProvider);
|
||||||
return recipesAsync.when(
|
return Scaffold(
|
||||||
loading: () => const LoadingStateView(label: 'Laddar recept...'),
|
body: recipesAsync.when(
|
||||||
error: (error, _) => ErrorStateView(
|
loading: () => const LoadingStateView(label: 'Laddar recept...'),
|
||||||
message: mapErrorToUserMessage(error),
|
error: (error, _) => ErrorStateView(
|
||||||
onRetry: () => ref.invalidate(recipesProvider),
|
message: mapErrorToUserMessage(error),
|
||||||
),
|
onRetry: () => ref.invalidate(recipesProvider),
|
||||||
data: (recipes) {
|
),
|
||||||
if (recipes.isEmpty) {
|
data: (recipes) {
|
||||||
return const EmptyStateView(
|
if (recipes.isEmpty) {
|
||||||
title: 'Inga recept hittades',
|
return const EmptyStateView(
|
||||||
description: 'Lagg till ett recept for att komma igang.',
|
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,
|
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
);
|
|
||||||
},
|
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),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user