feat(recipes): add recipe visibility and sharing features

- Implemented functionality to set recipe visibility (public/private) with appropriate checks for user permissions.
- Added ability to share recipes with other users, including validation for existing users and permissions.
- Introduced new DTOs for setting visibility and sharing recipes.
- Updated RecipesController and RecipesService to handle new endpoints for visibility and sharing.
- Enhanced inventory preview to consider user permissions and shared recipes.
- Updated front-end to support new sharing and visibility features, including UI changes for recipe detail and admin user management.
This commit is contained in:
Nils-Johan Gynther
2026-05-02 09:19:59 +02:00
parent f67bf8baef
commit 41ae7d4d06
17 changed files with 742 additions and 124 deletions
+1
View File
@@ -16,6 +16,7 @@ model User {
passwordHash String passwordHash String
role String @default("user") role String @default("user")
isPremium Boolean @default(false) isPremium Boolean @default(false)
canShareRecipes Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -0,0 +1,6 @@
import { IsBoolean } from 'class-validator';
export class SetRecipeVisibilityDto {
@IsBoolean()
isPublic!: boolean;
}
@@ -0,0 +1,7 @@
import { IsString, MinLength } from 'class-validator';
export class ShareRecipeDto {
@IsString()
@MinLength(2)
username!: string;
}
+37 -4
View File
@@ -1,9 +1,11 @@
import { Body, Controller, Delete, Get, HttpCode, Param, ParseIntPipe, Post, Patch, Request } from '@nestjs/common'; import { Body, Controller, Delete, Get, HttpCode, Param, ParseIntPipe, Post, Patch } from '@nestjs/common';
import { IsString } from 'class-validator'; import { IsString } from 'class-validator';
import { RecipesService } from './recipes.service'; import { RecipesService } from './recipes.service';
import { CreateRecipeDto } from './dto/create-recipe.dto'; import { CreateRecipeDto } from './dto/create-recipe.dto';
import { ParseMarkdownDto } from './dto/parse-markdown.dto'; import { ParseMarkdownDto } from './dto/parse-markdown.dto';
import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { ShareRecipeDto } from './dto/share-recipe.dto';
import { SetRecipeVisibilityDto } from './dto/set-recipe-visibility.dto';
class UpdateImageDto { class UpdateImageDto {
@IsString() @IsString()
@@ -25,8 +27,11 @@ export class RecipesController {
} }
@Get(':id/inventory-preview') @Get(':id/inventory-preview')
getInventoryPreview(@Param('id', ParseIntPipe) id: number) { getInventoryPreview(
return this.recipesService.getInventoryPreview(id); @Param('id', ParseIntPipe) id: number,
@CurrentUser() user: { userId: number },
) {
return this.recipesService.getInventoryPreview(id, user.userId);
} }
@Get(':id') @Get(':id')
@@ -67,7 +72,35 @@ export class RecipesController {
async updateImage( async updateImage(
@Param('id', ParseIntPipe) id: number, @Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateImageDto, @Body() dto: UpdateImageDto,
@CurrentUser() user: { userId: number },
) { ) {
return this.recipesService.updateImage(id, dto.sourceUrl); return this.recipesService.updateImage(id, dto.sourceUrl, user.userId);
}
@Patch(':id/visibility')
async setVisibility(
@Param('id', ParseIntPipe) id: number,
@Body() dto: SetRecipeVisibilityDto,
@CurrentUser() user: { userId: number },
) {
return this.recipesService.setVisibility(id, user.userId, dto.isPublic);
}
@Post(':id/share')
async shareRecipe(
@Param('id', ParseIntPipe) id: number,
@Body() dto: ShareRecipeDto,
@CurrentUser() user: { userId: number },
) {
return this.recipesService.shareWithUser(id, user.userId, dto.username.trim());
}
@Delete(':id/share/:username')
async unshareRecipe(
@Param('id', ParseIntPipe) id: number,
@Param('username') username: string,
@CurrentUser() user: { userId: number },
) {
return this.recipesService.unshareWithUser(id, user.userId, username.trim());
} }
} }
+108 -6
View File
@@ -1,4 +1,4 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import * as fs from 'node:fs/promises'; import * as fs from 'node:fs/promises';
import * as path from 'node:path'; import * as path from 'node:path';
@@ -31,9 +31,16 @@ export class RecipesService {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
async getInventoryPreview(id: number) { async getInventoryPreview(id: number, userId: number) {
const recipe = await this.prisma.recipe.findUnique({ const recipe = await this.prisma.recipe.findFirst({
where: { id }, where: {
id,
OR: [
{ isPublic: true },
{ ownerId: userId },
{ shares: { some: { userId } } },
],
},
include: { include: {
ingredients: { ingredients: {
include: { include: {
@@ -287,21 +294,116 @@ export class RecipesService {
} }
} }
async updateImage(id: number, sourceUrl: string) { async updateImage(id: number, sourceUrl: string, userId: number) {
const existingRecipe = await this.prisma.recipe.findUnique({ where: { id } }); const existingRecipe = await this.prisma.recipe.findUnique({ where: { id } });
if (!existingRecipe) { if (!existingRecipe) {
throw new NotFoundException(`Recipe with id ${id} not found`); throw new NotFoundException(`Recipe with id ${id} not found`);
} }
if (existingRecipe.ownerId !== userId) {
throw new NotFoundException(`Recipe with id ${id} not found`);
}
const imageUrl = await downloadAndOptimizeImage(sourceUrl, IMAGE_DEST_DIR); const imageUrl = await downloadAndOptimizeImage(sourceUrl, IMAGE_DEST_DIR);
return this.prisma.recipe.update({ return this.prisma.recipe.update({
where: { id }, where: { id },
data: { imageUrl }, data: { imageUrl },
include: { ingredients: { include: { product: { include: { nutrition: true } } } } }, include: {
ingredients: { include: { product: { include: { nutrition: true } } } },
owner: { select: { id: true, username: true } },
shares: { select: { userId: true } },
},
}); });
} }
async setVisibility(id: number, userId: number, isPublic: boolean) {
const existingRecipe = await this.prisma.recipe.findUnique({ where: { id } });
if (!existingRecipe || existingRecipe.ownerId !== userId) {
throw new NotFoundException(`Recipe with id ${id} not found`);
}
if (isPublic) {
const owner = await this.prisma.user.findUnique({
where: { id: userId },
select: { canShareRecipes: true },
});
if (!owner?.canShareRecipes) {
throw new ForbiddenException('Du har inte behörighet att dela recept.');
}
}
return this.prisma.recipe.update({
where: { id },
data: { isPublic },
include: {
ingredients: { include: { product: { include: { nutrition: true } } } },
owner: { select: { id: true, username: true } },
shares: { select: { userId: true } },
},
});
}
async shareWithUser(id: number, ownerId: number, username: string) {
const recipe = await this.prisma.recipe.findUnique({
where: { id },
select: { id: true, ownerId: true },
});
if (!recipe || recipe.ownerId !== ownerId) {
throw new NotFoundException(`Recipe with id ${id} not found`);
}
const owner = await this.prisma.user.findUnique({
where: { id: ownerId },
select: { canShareRecipes: true },
});
if (!owner?.canShareRecipes) {
throw new ForbiddenException('Du har inte behörighet att dela recept.');
}
const targetUser = await this.prisma.user.findUnique({
where: { username },
select: { id: true },
});
if (!targetUser) {
throw new NotFoundException(`User ${username} not found`);
}
if (targetUser.id === ownerId) {
return this.findOne(id, ownerId);
}
await this.prisma.recipeShare.upsert({
where: { recipeId_userId: { recipeId: id, userId: targetUser.id } },
create: { recipeId: id, userId: targetUser.id },
update: {},
});
return this.findOne(id, ownerId);
}
async unshareWithUser(id: number, ownerId: number, username: string) {
const recipe = await this.prisma.recipe.findUnique({
where: { id },
select: { id: true, ownerId: true },
});
if (!recipe || recipe.ownerId !== ownerId) {
throw new NotFoundException(`Recipe with id ${id} not found`);
}
const targetUser = await this.prisma.user.findUnique({
where: { username },
select: { id: true },
});
if (!targetUser) {
throw new NotFoundException(`User ${username} not found`);
}
await this.prisma.recipeShare.deleteMany({
where: { recipeId: id, userId: targetUser.id },
});
return this.findOne(id, ownerId);
}
async create(createRecipeDto: CreateRecipeDto, userId: number) { async create(createRecipeDto: CreateRecipeDto, userId: number) {
this.logger.log( this.logger.log(
`[create] Incoming imageUrl from client: ${createRecipeDto.imageUrl ?? 'null'}`, `[create] Incoming imageUrl from client: ${createRecipeDto.imageUrl ?? 'null'}`,
+15
View File
@@ -14,6 +14,11 @@ class SetPremiumDto {
isPremium: boolean; isPremium: boolean;
} }
class SetRecipeSharingDto {
@IsBoolean()
canShareRecipes: boolean;
}
class AdminCreateUserDto { class AdminCreateUserDto {
@IsString() @IsString()
@MinLength(2) @MinLength(2)
@@ -113,6 +118,16 @@ export class UsersController {
return { id: updated.id, username: updated.username, isPremium: updated.isPremium }; return { id: updated.id, username: updated.username, isPremium: updated.isPremium };
} }
@Roles('admin')
@Patch(':id/recipe-sharing')
async setRecipeSharing(
@Param('id', ParseIntPipe) id: number,
@Body() dto: SetRecipeSharingDto,
) {
const updated = await this.usersService.setRecipeSharing(id, dto.canShareRecipes);
return { id: updated.id, username: updated.username, canShareRecipes: updated.canShareRecipes };
}
@Roles('admin') @Roles('admin')
@Post() @Post()
async adminCreateUser( async adminCreateUser(
+15 -1
View File
@@ -25,7 +25,17 @@ export class UsersService {
findAll() { findAll() {
return this.prisma.user.findMany({ return this.prisma.user.findMany({
select: { id: true, username: true, email: true, firstName: true, lastName: true, role: true, isPremium: true, createdAt: true }, select: {
id: true,
username: true,
email: true,
firstName: true,
lastName: true,
role: true,
isPremium: true,
canShareRecipes: true,
createdAt: true,
},
orderBy: { username: 'asc' }, orderBy: { username: 'asc' },
}); });
} }
@@ -38,6 +48,10 @@ export class UsersService {
return this.prisma.user.update({ where: { id }, data: { isPremium } }); return this.prisma.user.update({ where: { id }, data: { isPremium } });
} }
setRecipeSharing(id: number, canShareRecipes: boolean) {
return this.prisma.user.update({ where: { id }, data: { canShareRecipes } });
}
async adminCreate(data: { username: string; email: string; password: string; role?: string }) { async adminCreate(data: { username: string; email: string; password: string; role?: string }) {
const existing = await this.prisma.user.findFirst({ const existing = await this.prisma.user.findFirst({
where: { OR: [{ username: data.username }, { email: data.email }] }, where: { OR: [{ username: data.username }, { email: data.email }] },
+4
View File
@@ -30,6 +30,9 @@ class RecipeApiPaths {
static String detail(int id) => '/recipes/$id'; static String detail(int id) => '/recipes/$id';
static String update(int id) => '/recipes/$id'; static String update(int id) => '/recipes/$id';
static String remove(int id) => '/recipes/$id'; static String remove(int id) => '/recipes/$id';
static String setVisibility(int id) => '/recipes/$id/visibility';
static String share(int id) => '/recipes/$id/share';
static String unshare(int id, String username) => '/recipes/$id/share/${Uri.encodeComponent(username)}';
static String inventoryPreview(int id) => '/recipes/$id/inventory-preview'; static String inventoryPreview(int id) => '/recipes/$id/inventory-preview';
static const parseMarkdown = '/recipes/parse-markdown'; static const parseMarkdown = '/recipes/parse-markdown';
} }
@@ -52,6 +55,7 @@ class UserApiPaths {
static const list = '/users'; static const list = '/users';
static String setRole(int id) => '/users/$id/role'; static String setRole(int id) => '/users/$id/role';
static String setPremium(int id) => '/users/$id/premium'; static String setPremium(int id) => '/users/$id/premium';
static String setRecipeSharing(int id) => '/users/$id/recipe-sharing';
static String updateEmail(int id) => '/users/$id/email'; static String updateEmail(int id) => '/users/$id/email';
static String delete(int id) => '/users/$id'; static String delete(int id) => '/users/$id';
static String resetPassword(int id) => '/users/$id/reset-password'; static String resetPassword(int id) => '/users/$id/reset-password';
+19
View File
@@ -24,3 +24,22 @@ String jwtRole(String? token) {
/// Returns true if the JWT token contains role == 'admin'. /// Returns true if the JWT token contains role == 'admin'.
bool jwtIsAdmin(String? token) => jwtRole(token) == 'admin'; bool jwtIsAdmin(String? token) => jwtRole(token) == 'admin';
/// Returns username claim from JWT token, if present.
String? jwtUsername(String? token) {
if (token == null || token.isEmpty) return null;
final claims = decodeJwtPayload(token);
final value = claims['username']?.toString().trim();
if (value == null || value.isEmpty) return null;
return value;
}
/// Returns user id claim from JWT token, if present.
int? jwtUserId(String? token) {
if (token == null || token.isEmpty) return null;
final claims = decodeJwtPayload(token);
final raw = claims['sub'] ?? claims['userId'] ?? claims['id'];
if (raw is num) return raw.toInt();
if (raw is String) return int.tryParse(raw);
return null;
}
@@ -49,6 +49,19 @@ class AdminRepository {
return UserAdmin.fromJson(data); return UserAdmin.fromJson(data);
} }
Future<UserAdmin> setRecipeSharing(int userId, {required bool canShareRecipes}) async {
final token = await _token();
final data = await guardedApiCall(
_ref,
() => _apiClient.patchJson(
UserApiPaths.setRecipeSharing(userId),
body: {'canShareRecipes': canShareRecipes},
token: token,
),
);
return UserAdmin.fromJson(data);
}
Future<void> updateEmail(int userId, String email) async { Future<void> updateEmail(int userId, String email) async {
final token = await _token(); final token = await _token();
await guardedApiCall( await guardedApiCall(
@@ -7,6 +7,7 @@ class UserAdmin {
final String? lastName; final String? lastName;
final String role; final String role;
final bool isPremium; final bool isPremium;
final bool canShareRecipes;
final DateTime? createdAt; final DateTime? createdAt;
const UserAdmin({ const UserAdmin({
@@ -17,6 +18,7 @@ class UserAdmin {
this.lastName, this.lastName,
required this.role, required this.role,
required this.isPremium, required this.isPremium,
required this.canShareRecipes,
this.createdAt, this.createdAt,
}); });
@@ -28,6 +30,7 @@ class UserAdmin {
lastName: json['lastName'] as String?, lastName: json['lastName'] as String?,
role: json['role'] as String? ?? 'user', role: json['role'] as String? ?? 'user',
isPremium: json['isPremium'] as bool? ?? false, isPremium: json['isPremium'] as bool? ?? false,
canShareRecipes: json['canShareRecipes'] as bool? ?? true,
createdAt: json['createdAt'] != null ? DateTime.tryParse(json['createdAt'] as String) : null, createdAt: json['createdAt'] != null ? DateTime.tryParse(json['createdAt'] as String) : null,
); );
@@ -81,6 +81,26 @@ class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
} }
} }
Future<void> _toggleRecipeSharing(UserAdmin user) async {
final newValue = !user.canShareRecipes;
final confirmed = await _confirm(
context,
newValue ? 'Tillåt receptdelning' : 'Blockera receptdelning',
'${newValue ? 'Tillåt' : 'Blockera'} receptdelning för ${user.username}?',
);
if (!confirmed || !mounted) return;
try {
await ref
.read(adminRepositoryProvider)
.setRecipeSharing(user.id, canShareRecipes: newValue);
if (!mounted) return;
_load();
} catch (e) {
if (!mounted) return;
_showError(e);
}
}
Future<void> _resetPassword(UserAdmin user) async { Future<void> _resetPassword(UserAdmin user) async {
final confirmed = await _confirm( final confirmed = await _confirm(
context, context,
@@ -319,6 +339,7 @@ class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
user: _users[i], user: _users[i],
onChangeRole: () => _changeRole(_users[i]), onChangeRole: () => _changeRole(_users[i]),
onTogglePremium: () => _togglePremium(_users[i]), onTogglePremium: () => _togglePremium(_users[i]),
onToggleRecipeSharing: () => _toggleRecipeSharing(_users[i]),
onEditEmail: () => _editEmail(_users[i]), onEditEmail: () => _editEmail(_users[i]),
onResetPassword: () => _resetPassword(_users[i]), onResetPassword: () => _resetPassword(_users[i]),
onDelete: () => _deleteUser(_users[i]), onDelete: () => _deleteUser(_users[i]),
@@ -364,6 +385,7 @@ class _UserTile extends StatelessWidget {
final UserAdmin user; final UserAdmin user;
final VoidCallback onChangeRole; final VoidCallback onChangeRole;
final VoidCallback onTogglePremium; final VoidCallback onTogglePremium;
final VoidCallback onToggleRecipeSharing;
final VoidCallback onEditEmail; final VoidCallback onEditEmail;
final VoidCallback onResetPassword; final VoidCallback onResetPassword;
final VoidCallback onDelete; final VoidCallback onDelete;
@@ -372,6 +394,7 @@ class _UserTile extends StatelessWidget {
required this.user, required this.user,
required this.onChangeRole, required this.onChangeRole,
required this.onTogglePremium, required this.onTogglePremium,
required this.onToggleRecipeSharing,
required this.onEditEmail, required this.onEditEmail,
required this.onResetPassword, required this.onResetPassword,
required this.onDelete, required this.onDelete,
@@ -418,6 +441,16 @@ class _UserTile extends StatelessWidget {
backgroundColor: theme.colorScheme.tertiaryContainer, backgroundColor: theme.colorScheme.tertiaryContainer,
), ),
], ],
const SizedBox(width: 4),
Chip(
label: Text(user.canShareRecipes ? 'Delning: På' : 'Delning: Av'),
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
labelStyle: theme.textTheme.labelSmall,
backgroundColor: user.canShareRecipes
? theme.colorScheme.secondaryContainer
: theme.colorScheme.errorContainer,
),
], ],
), ),
], ],
@@ -432,6 +465,9 @@ class _UserTile extends StatelessWidget {
case 'premium': case 'premium':
onTogglePremium(); onTogglePremium();
break; break;
case 'sharing':
onToggleRecipeSharing();
break;
case 'email': case 'email':
onEditEmail(); onEditEmail();
break; break;
@@ -454,6 +490,14 @@ class _UserTile extends StatelessWidget {
value: 'premium', value: 'premium',
child: Text(user.isPremium ? 'Ta bort Premium' : 'Ge Premium'), child: Text(user.isPremium ? 'Ta bort Premium' : 'Ge Premium'),
), ),
PopupMenuItem(
value: 'sharing',
child: Text(
user.canShareRecipes
? 'Blockera receptdelning'
: 'Tillåt receptdelning',
),
),
const PopupMenuItem( const PopupMenuItem(
value: 'email', value: 'email',
child: Text('Ändra e-post'), child: Text('Ändra e-post'),
@@ -3,7 +3,6 @@ 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 'package:go_router/go_router.dart';
import '../../../core/api/api_error_mapper.dart';
import '../../../core/utils/global_error_handler.dart'; import '../../../core/utils/global_error_handler.dart';
import '../../auth/data/auth_providers.dart'; import '../../auth/data/auth_providers.dart';
import '../data/import_providers.dart'; import '../data/import_providers.dart';
@@ -23,17 +22,17 @@ class RecipeImportTab extends ConsumerStatefulWidget {
} }
class _RecipeImportTabState extends ConsumerState<RecipeImportTab> { class _RecipeImportTabState extends ConsumerState<RecipeImportTab> {
// Shared state
bool _isLoading = false; bool _isLoading = false;
String? _error;
// File mode
PlatformFile? _pickedFile; PlatformFile? _pickedFile;
// URL mode
_Method _method = _Method.file; _Method _method = _Method.file;
final _urlCtrl = TextEditingController(); final _urlCtrl = TextEditingController();
@override
void initState() {
super.initState();
_urlCtrl.addListener(() => setState(() {}));
}
@override @override
void dispose() { void dispose() {
_urlCtrl.dispose(); _urlCtrl.dispose();
@@ -59,17 +58,15 @@ class _RecipeImportTabState extends ConsumerState<RecipeImportTab> {
Future<void> _submit() async { Future<void> _submit() async {
if (_pickedFile == null && _method == _Method.file) { if (_pickedFile == null && _method == _Method.file) {
setState(() => _error = 'Vänligen välj en fil först'); showGlobalErrorDialog(context, 'Vänligen välj en fil först');
return; return;
} }
setState(() { setState(() => _isLoading = true);
_isLoading = true;
_error = null;
});
try { try {
final token = await ref.read(authStateProvider.future); final token = ref.read(authStateProvider).valueOrNull ??
await ref.read(authStateProvider.future);
final repo = ref.read(importRepositoryProvider); final repo = ref.read(importRepositoryProvider);
final result = _method == _Method.file final result = _method == _Method.file
? await repo.importFile( ? await repo.importFile(
@@ -83,7 +80,10 @@ class _RecipeImportTabState extends ConsumerState<RecipeImportTab> {
); );
if (!mounted) return; if (!mounted) return;
context.push('/recipes/create', extra: result); context.push('/recipes/create', extra: {
'markdown': result.markdown,
'imageUrl': result.imageUrl,
});
} catch (e) { } catch (e) {
showGlobalErrorDialog(context, 'Ett fel uppstod vid import: $e'); showGlobalErrorDialog(context, 'Ett fel uppstod vid import: $e');
} finally { } finally {
@@ -172,7 +172,6 @@ class _RecipeImportTabState extends ConsumerState<RecipeImportTab> {
prefixIcon: Icon(Icons.link), prefixIcon: Icon(Icons.link),
border: OutlineInputBorder(), border: OutlineInputBorder(),
), ),
onChanged: (_) => setState(() {}),
onSubmitted: (_) { onSubmitted: (_) {
if (_canSubmit) _submit(); if (_canSubmit) _submit();
}, },
@@ -193,31 +192,6 @@ class _RecipeImportTabState extends ConsumerState<RecipeImportTab> {
const SizedBox(height: 16), const SizedBox(height: 16),
], ],
// ── Felmeddelande ───────────────────────────────────────────────
if (_error != null) ...[
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(Icons.error_outline,
color: theme.colorScheme.onErrorContainer, size: 18),
const SizedBox(width: 8),
Expanded(
child: Text(
_error!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onErrorContainer),
),
),
],
),
),
const SizedBox(height: 16),
],
// ── Knapp ─────────────────────────────────────────────────────── // ── Knapp ───────────────────────────────────────────────────────
FilledButton.icon( FilledButton.icon(
@@ -94,6 +94,65 @@ class RecipeRepository {
} }
} }
Future<Recipe> setRecipeVisibility(int id, {required bool isPublic, String? token}) async {
try {
final data = await _api.patchJson(
RecipeApiPaths.setVisibility(id),
body: {'isPublic': isPublic},
token: token,
);
if (data is! Map<String, dynamic>) {
throw const ApiException(
type: ApiErrorType.unknown, message: 'Ogiltigt svar från servern.');
}
return Recipe.fromJson(data);
} on ApiException {
rethrow;
} catch (_) {
throw const ApiException(
type: ApiErrorType.network, message: 'Kunde inte uppdatera synlighet.');
}
}
Future<Recipe> shareRecipeWithUsername(int id, {required String username, String? token}) async {
try {
final data = await _api.postJson(
RecipeApiPaths.share(id),
body: {'username': username},
token: token,
);
if (data is! Map<String, dynamic>) {
throw const ApiException(
type: ApiErrorType.unknown, message: 'Ogiltigt svar från servern.');
}
return Recipe.fromJson(data);
} on ApiException {
rethrow;
} catch (_) {
throw const ApiException(
type: ApiErrorType.network, message: 'Kunde inte dela receptet.');
}
}
Future<Recipe> unshareRecipeWithUsername(int id, {required String username, String? token}) async {
try {
final data = await _api.deleteJson(
RecipeApiPaths.unshare(id, username),
token: token,
);
if (data is! Map<String, dynamic>) {
throw const ApiException(
type: ApiErrorType.unknown, message: 'Ogiltigt svar från servern.');
}
return Recipe.fromJson(data);
} on ApiException {
rethrow;
} catch (_) {
throw const ApiException(
type: ApiErrorType.network, message: 'Kunde inte ta bort delning.');
}
}
Future<InventoryPreview> fetchInventoryPreview(int id, Future<InventoryPreview> fetchInventoryPreview(int id,
{String? token}) async { {String? token}) async {
try { try {
@@ -8,6 +8,10 @@ class Recipe {
final int? servings; final int? servings;
final String? instructions; final String? instructions;
final List<RecipeIngredient> ingredients; final List<RecipeIngredient> ingredients;
final bool isPublic;
final int? ownerId;
final String? ownerUsername;
final List<int> sharedWithUserIds;
const Recipe({ const Recipe({
required this.id, required this.id,
@@ -17,6 +21,10 @@ class Recipe {
this.servings, this.servings,
this.instructions, this.instructions,
this.ingredients = const [], this.ingredients = const [],
this.isPublic = false,
this.ownerId,
this.ownerUsername,
this.sharedWithUserIds = const [],
}); });
factory Recipe.fromJson(Map<String, dynamic> json) { factory Recipe.fromJson(Map<String, dynamic> json) {
@@ -27,6 +35,8 @@ class Recipe {
final dynamic rawServings = json['servings']; final dynamic rawServings = json['servings'];
final rawIngredients = json['ingredients'] as List<dynamic>? ?? []; final rawIngredients = json['ingredients'] as List<dynamic>? ?? [];
final normalizedImageUrl = rawImageUrl?.toString().trim(); final normalizedImageUrl = rawImageUrl?.toString().trim();
final ownerJson = json['owner'] as Map<String, dynamic>?;
final sharesJson = json['shares'] as List<dynamic>? ?? const [];
return Recipe( return Recipe(
id: rawId is num ? rawId.toInt() : int.parse(rawId.toString()), id: rawId is num ? rawId.toInt() : int.parse(rawId.toString()),
@@ -45,6 +55,18 @@ class Recipe {
ingredients: rawIngredients ingredients: rawIngredients
.map((i) => RecipeIngredient.fromJson(i as Map<String, dynamic>)) .map((i) => RecipeIngredient.fromJson(i as Map<String, dynamic>))
.toList(), .toList(),
isPublic: json['isPublic'] == true,
ownerId: ownerJson == null
? null
: (ownerJson['id'] is num
? (ownerJson['id'] as num).toInt()
: int.tryParse('${ownerJson['id']}')),
ownerUsername: ownerJson?['username']?.toString(),
sharedWithUserIds: sharesJson
.map((s) => (s as Map<String, dynamic>)['userId'])
.whereType<num>()
.map((id) => id.toInt())
.toList(),
); );
} }
} }
@@ -4,12 +4,18 @@ import 'package:go_router/go_router.dart';
import '../../../core/api/api_error_mapper.dart'; import '../../../core/api/api_error_mapper.dart';
import '../../../core/api/api_exception.dart'; import '../../../core/api/api_exception.dart';
import '../../../core/auth/jwt_decoder.dart';
import '../../../core/ui/async_state_views.dart'; import '../../../core/ui/async_state_views.dart';
import '../../auth/data/auth_providers.dart'; import '../../auth/data/auth_providers.dart';
import '../data/recipe_providers.dart'; import '../data/recipe_providers.dart';
import '../domain/recipe.dart'; import '../domain/recipe.dart';
import '../domain/inventory_preview.dart'; import '../domain/inventory_preview.dart';
String _fmtQty(double v) =>
v == v.truncateToDouble() ? v.toInt().toString() : v.toString();
enum _ShareAction { share, unshare }
class RecipeDetailScreen extends ConsumerWidget { class RecipeDetailScreen extends ConsumerWidget {
final int recipeId; final int recipeId;
@@ -18,18 +24,34 @@ class RecipeDetailScreen extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final recipeAsync = ref.watch(recipeDetailProvider(recipeId)); final recipeAsync = ref.watch(recipeDetailProvider(recipeId));
final token = ref.watch(authStateProvider).valueOrNull;
final currentUserId = jwtUserId(token);
final recipe = recipeAsync.valueOrNull;
final isOwner = recipe != null && currentUserId != null && recipe.ownerId == currentUserId;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(recipeAsync.maybeWhen(data: (d) => d, orElse: () => null)?.title ?? 'Recept'), title: const SizedBox.shrink(),
leading: IconButton( leading: IconButton(
icon: const Icon(Icons.arrow_back), icon: const Icon(Icons.arrow_back),
onPressed: () => context.go('/recipes'), onPressed: () => context.go('/recipes'),
tooltip: 'Tillbaka till receptlistan', tooltip: 'Tillbaka till receptlistan',
), ),
actions: recipeAsync.maybeWhen(data: (d) => d, orElse: () => null) == null actions: recipe == null
? [] ? []
: [ : [
if (isOwner)
IconButton(
tooltip: recipe.isPublic ? 'Gör privat' : 'Gör publik',
icon: Icon(recipe.isPublic ? Icons.public : Icons.lock_outline),
onPressed: () => _toggleVisibility(context, ref, recipe),
),
if (isOwner)
IconButton(
tooltip: 'Dela med användare',
icon: const Icon(Icons.person_add_alt_1_outlined),
onPressed: () => _shareRecipe(context, ref, recipe),
),
IconButton( IconButton(
tooltip: 'Redigera', tooltip: 'Redigera',
icon: const Icon(Icons.edit_outlined), icon: const Icon(Icons.edit_outlined),
@@ -41,7 +63,7 @@ class RecipeDetailScreen extends ConsumerWidget {
icon: const Icon(Icons.inventory_2_outlined), icon: const Icon(Icons.inventory_2_outlined),
onPressed: () => context.go('/inventory'), onPressed: () => context.go('/inventory'),
), ),
_DeleteButton(recipe: recipeAsync.value!), if (isOwner) _DeleteButton(recipe: recipe),
], ],
), ),
body: recipeAsync.when( body: recipeAsync.when(
@@ -54,23 +76,75 @@ class RecipeDetailScreen extends ConsumerWidget {
physics: const BouncingScrollPhysics(), physics: const BouncingScrollPhysics(),
slivers: [ slivers: [
SliverAppBar( SliverAppBar(
expandedHeight: MediaQuery.of(context).size.height * 2 / 3, expandedHeight: MediaQuery.of(context).size.height * 0.42,
flexibleSpace: FlexibleSpaceBar( flexibleSpace: FlexibleSpaceBar(
background: recipe.imageUrl != null background: Stack(
fit: StackFit.expand,
children: [
recipe.imageUrl != null
? Image.network( ? Image.network(
recipe.imageUrl!, recipe.imageUrl!,
fit: BoxFit.cover, fit: BoxFit.cover,
errorBuilder: (_, __, ___) => _ImagePlaceholder(),
) )
: Container(color: Colors.grey[200]), : _ImagePlaceholder(),
// Gradient + title overlay
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Container(
padding: const EdgeInsets.fromLTRB(16, 40, 16, 16),
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, Colors.black87],
),
),
child: Text(
recipe.title,
style: const TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.bold,
shadows: [Shadow(blurRadius: 4, color: Colors.black54)],
),
),
),
),
if (recipe.isPublic &&
recipe.ownerUsername != null &&
recipe.ownerUsername!.isNotEmpty)
Positioned(
right: 12,
top: 12,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.45),
borderRadius: BorderRadius.circular(14),
),
child: Text(
'@${recipe.ownerUsername}',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
),
],
),
), ),
pinned: true, pinned: true,
floating: false,
), ),
SliverToBoxAdapter( SliverToBoxAdapter(
child: Container( child: Container(
decoration: const BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.only( borderRadius: const BorderRadius.only(
topLeft: Radius.circular(20), topLeft: Radius.circular(20),
topRight: Radius.circular(20), topRight: Radius.circular(20),
), ),
@@ -83,6 +157,140 @@ class RecipeDetailScreen extends ConsumerWidget {
), ),
); );
} }
Future<void> _toggleVisibility(
BuildContext context,
WidgetRef ref,
Recipe recipe,
) async {
try {
final token = ref.read(authStateProvider).valueOrNull ??
await ref.read(authStateProvider.future);
await ref.read(recipeRepositoryProvider).setRecipeVisibility(
recipe.id,
isPublic: !recipe.isPublic,
token: token,
);
ref.invalidate(recipeDetailProvider(recipe.id));
ref.invalidate(recipesProvider);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
!recipe.isPublic
? 'Receptet är nu publikt.'
: 'Receptet är nu privat.',
),
),
);
} on ApiException catch (e) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(mapErrorToUserMessage(e, context))),
);
}
}
Future<void> _shareRecipe(
BuildContext context,
WidgetRef ref,
Recipe recipe,
) async {
final ctrl = TextEditingController();
final result = await showDialog<(_ShareAction, String)>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Dela recept'),
content: TextField(
controller: ctrl,
autofocus: true,
decoration: const InputDecoration(
labelText: 'Användarnamn',
hintText: 't.ex. anna',
),
onSubmitted: (_) => Navigator.pop(
context,
(_ShareAction.share, ctrl.text.trim()),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Avbryt'),
),
TextButton(
onPressed: () => Navigator.pop(
context,
(_ShareAction.unshare, ctrl.text.trim()),
),
child: const Text('Ta bort delning'),
),
FilledButton(
onPressed: () => Navigator.pop(
context,
(_ShareAction.share, ctrl.text.trim()),
),
child: const Text('Dela'),
),
],
),
);
ctrl.dispose();
final action = result?.$1;
final trimmed = result?.$2.trim() ?? '';
if (trimmed.isEmpty) return;
try {
final token = ref.read(authStateProvider).valueOrNull ??
await ref.read(authStateProvider.future);
if (action == _ShareAction.unshare) {
await ref.read(recipeRepositoryProvider).unshareRecipeWithUsername(
recipe.id,
username: trimmed,
token: token,
);
} else {
await ref.read(recipeRepositoryProvider).shareRecipeWithUsername(
recipe.id,
username: trimmed,
token: token,
);
}
ref.invalidate(recipeDetailProvider(recipe.id));
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
action == _ShareAction.unshare
? 'Delning borttagen för $trimmed.'
: 'Receptet delades med $trimmed.',
),
),
);
} on ApiException catch (e) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(mapErrorToUserMessage(e, context))),
);
}
}
}
class _ImagePlaceholder extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: Center(
child: Icon(
Icons.restaurant,
size: 64,
color: Theme.of(context).colorScheme.onSurfaceVariant.withOpacity(0.4),
),
),
);
}
} }
class _DeleteButton extends ConsumerWidget { class _DeleteButton extends ConsumerWidget {
@@ -124,7 +332,8 @@ class _DeleteButton extends ConsumerWidget {
if (confirmed != true || !context.mounted) return; if (confirmed != true || !context.mounted) return;
try { try {
final token = await ref.read(authStateProvider.future); final token = ref.read(authStateProvider).valueOrNull ??
await ref.read(authStateProvider.future);
await ref.read(recipeRepositoryProvider).deleteRecipe(recipe.id, await ref.read(recipeRepositoryProvider).deleteRecipe(recipe.id,
token: token); token: token);
ref.invalidate(recipesProvider); ref.invalidate(recipesProvider);
@@ -143,23 +352,15 @@ class _RecipeBody extends StatelessWidget {
const _RecipeBody({required this.recipe}); const _RecipeBody({required this.recipe});
String _formatQty(double qty) {
if (qty == 0) return '';
return qty == qty.truncateToDouble()
? qty.toInt().toString()
: qty.toString();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
return SingleChildScrollView( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Bilden visas endast som bakgrund i detaljvyn // Titel visas som overlay på bilden — inte upprepas här
Text(recipe.title, style: theme.textTheme.headlineSmall),
if (recipe.description != null) ...[ if (recipe.description != null) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
Text(recipe.description!, Text(recipe.description!,
@@ -180,22 +381,48 @@ class _RecipeBody extends StatelessWidget {
if (recipe.ingredients.isNotEmpty) ...[ if (recipe.ingredients.isNotEmpty) ...[
const SizedBox(height: 24), const SizedBox(height: 24),
Text('Ingredienser', style: theme.textTheme.titleMedium), Text('Ingredienser', style: theme.textTheme.titleMedium),
const SizedBox(height: 8), const SizedBox(height: 12),
...recipe.ingredients.map((ing) { ...recipe.ingredients.map((ing) {
final qtyStr = _formatQty(ing.quantity); final qtyStr = ing.quantity == 0 ? '' : _fmtQty(ing.quantity);
final parts = [ final measureParts = [
if (qtyStr.isNotEmpty) qtyStr, if (qtyStr.isNotEmpty) qtyStr,
if (ing.unit.isNotEmpty) ing.unit, if (ing.unit.isNotEmpty) ing.unit,
ing.productName,
if (ing.note != null) '(${ing.note})',
]; ];
final measure = measureParts.join(' ');
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 3), padding: const EdgeInsets.symmetric(vertical: 5),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Text(''), if (measure.isNotEmpty) ...[
Expanded(child: Text(parts.join(' '))), Container(
width: 72,
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(6),
),
child: Text(
measure,
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: 10),
] else
const SizedBox(width: 82),
Expanded(
child: Text(
ing.note != null
? '${ing.productName} (${ing.note})'
: ing.productName,
style: theme.textTheme.bodyMedium,
),
),
], ],
), ),
); );
@@ -203,12 +430,10 @@ class _RecipeBody extends StatelessWidget {
], ],
if (recipe.instructions != null && if (recipe.instructions != null &&
recipe.instructions!.isNotEmpty) ...[ recipe.instructions!.isNotEmpty) ...[
const SizedBox(height: 24), const SizedBox(height: 32),
Text('Tillvägagångssätt', style: theme.textTheme.titleMedium), Text('Tillvägagångssätt', style: theme.textTheme.titleMedium),
const SizedBox(height: 8), const SizedBox(height: 16),
Text(recipe.instructions!, ..._buildSteps(recipe.instructions!, theme),
style: theme.textTheme.bodyMedium
?.copyWith(height: 1.6)),
], ],
_InventoryPreviewSection(recipeId: recipe.id), _InventoryPreviewSection(recipeId: recipe.id),
const SizedBox(height: 40), const SizedBox(height: 40),
@@ -216,6 +441,54 @@ class _RecipeBody extends StatelessWidget {
), ),
); );
} }
List<Widget> _buildSteps(String instructions, ThemeData theme) {
final steps = instructions
.split(RegExp(r'\n{2,}'))
.map((s) => s.trim())
.where((s) => s.isNotEmpty)
.toList();
return steps.asMap().entries.map((entry) {
final index = entry.key + 1;
final text = entry.value;
return Padding(
padding: const EdgeInsets.only(bottom: 20),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 32,
height: 32,
alignment: Alignment.center,
decoration: BoxDecoration(
color: theme.colorScheme.primary,
shape: BoxShape.circle,
),
child: Text(
'$index',
style: TextStyle(
color: theme.colorScheme.onPrimary,
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
text,
style: theme.textTheme.bodyMedium?.copyWith(height: 1.6),
),
),
),
],
),
);
}).toList();
}
} }
class _InventoryPreviewSection extends ConsumerStatefulWidget { class _InventoryPreviewSection extends ConsumerStatefulWidget {
@@ -367,18 +640,15 @@ class _IngredientPreviewRow extends StatelessWidget {
IngredientStatus.missing => (Icons.cancel_outlined, cs.error), IngredientStatus.missing => (Icons.cancel_outlined, cs.error),
}; };
String _fmt(double v) =>
v == v.truncateToDouble() ? v.toInt().toString() : v.toString();
final requiredStr = final requiredStr =
'${_fmt(ingredient.requiredQuantity)} ${ingredient.requiredUnit}'.trim(); '${_fmtQty(ingredient.requiredQuantity)} ${ingredient.requiredUnit}'.trim();
final availableStr = final availableStr =
'${_fmt(ingredient.availableQuantity)} ${ingredient.requiredUnit}'.trim(); '${_fmtQty(ingredient.availableQuantity)} ${ingredient.requiredUnit}'.trim();
final subtitle = switch (ingredient.status) { final subtitle = switch (ingredient.status) {
IngredientStatus.enough => 'Tillgängligt: $availableStr', IngredientStatus.enough => 'Tillgängligt: $availableStr',
IngredientStatus.missing => ingredient.availableQuantity > 0 IngredientStatus.missing => ingredient.availableQuantity > 0
? 'Saknar ${_fmt(ingredient.missingQuantity)} ${ingredient.requiredUnit} ' ? 'Saknar ${_fmtQty(ingredient.missingQuantity)} ${ingredient.requiredUnit} '
'(har $availableStr)' '(har $availableStr)'
: 'Saknas helt', : 'Saknas helt',
IngredientStatus.unitMismatch => IngredientStatus.unitMismatch =>
@@ -6,6 +6,7 @@ import '../../../core/api/api_error_mapper.dart';
import '../../../core/ui/async_state_views.dart'; import '../../../core/ui/async_state_views.dart';
import '../data/recipe_providers.dart'; import '../data/recipe_providers.dart';
import '../data/recipes_grid_provider.dart'; import '../data/recipes_grid_provider.dart';
import '../domain/recipe.dart';
class RecipesScreen extends ConsumerWidget { class RecipesScreen extends ConsumerWidget {
const RecipesScreen({super.key}); const RecipesScreen({super.key});
@@ -47,21 +48,7 @@ class RecipesScreen extends ConsumerWidget {
final recipe = recipes[index]; final recipe = recipes[index];
return GestureDetector( return GestureDetector(
onTap: () => context.push('/recipes/${recipe.id}'), onTap: () => context.push('/recipes/${recipe.id}'),
child: Container( child: _RecipeImageCard(recipe: recipe),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8.0),
color: Colors.grey[200],
image: recipe.imageUrl != null
? DecorationImage(
image: NetworkImage(recipe.imageUrl!),
fit: BoxFit.cover,
)
: null,
),
child: recipe.imageUrl == null
? const Center(child: Icon(Icons.restaurant, size: 32))
: null,
),
); );
}, },
); );
@@ -80,19 +67,10 @@ class RecipesScreen extends ConsumerWidget {
children: [ children: [
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(6),
child: recipe.imageUrl != null child: SizedBox(
? Image.network(
recipe.imageUrl!,
width: 72, width: 72,
height: 72, height: 72,
fit: BoxFit.cover, child: _RecipeImageCard(recipe: recipe, compact: true),
)
: Container(
width: 72,
height: 72,
color: Colors.grey[200],
child: const Icon(Icons.restaurant,
size: 32),
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
@@ -126,3 +104,57 @@ class RecipesScreen extends ConsumerWidget {
); );
} }
} }
class _RecipeImageCard extends StatelessWidget {
final Recipe recipe;
final bool compact;
const _RecipeImageCard({required this.recipe, this.compact = false});
@override
Widget build(BuildContext context) {
final showStamp = recipe.isPublic == true &&
recipe.ownerUsername != null &&
recipe.ownerUsername.toString().isNotEmpty;
final radius = compact ? 0.0 : 8.0;
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(radius),
color: Colors.grey[200],
image: recipe.imageUrl != null
? DecorationImage(
image: NetworkImage(recipe.imageUrl!),
fit: BoxFit.cover,
)
: null,
),
child: Stack(
children: [
if (recipe.imageUrl == null)
const Center(child: Icon(Icons.restaurant, size: 32)),
if (showStamp)
Positioned(
right: 4,
bottom: 4,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.45),
borderRadius: BorderRadius.circular(10),
),
child: Text(
'@${recipe.ownerUsername}',
style: TextStyle(
color: Colors.white,
fontSize: compact ? 8 : 10,
fontWeight: FontWeight.w600,
),
),
),
),
],
),
);
}
}