diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 4f3691ee..cd39298f 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -16,6 +16,7 @@ model User { passwordHash String role String @default("user") isPremium Boolean @default(false) + canShareRecipes Boolean @default(true) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/backend/src/recipes/dto/set-recipe-visibility.dto.ts b/backend/src/recipes/dto/set-recipe-visibility.dto.ts new file mode 100644 index 00000000..9bec33d8 --- /dev/null +++ b/backend/src/recipes/dto/set-recipe-visibility.dto.ts @@ -0,0 +1,6 @@ +import { IsBoolean } from 'class-validator'; + +export class SetRecipeVisibilityDto { + @IsBoolean() + isPublic!: boolean; +} diff --git a/backend/src/recipes/dto/share-recipe.dto.ts b/backend/src/recipes/dto/share-recipe.dto.ts new file mode 100644 index 00000000..92f64a5c --- /dev/null +++ b/backend/src/recipes/dto/share-recipe.dto.ts @@ -0,0 +1,7 @@ +import { IsString, MinLength } from 'class-validator'; + +export class ShareRecipeDto { + @IsString() + @MinLength(2) + username!: string; +} diff --git a/backend/src/recipes/recipes.controller.ts b/backend/src/recipes/recipes.controller.ts index bcd56dd9..c648e496 100644 --- a/backend/src/recipes/recipes.controller.ts +++ b/backend/src/recipes/recipes.controller.ts @@ -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 { RecipesService } from './recipes.service'; import { CreateRecipeDto } from './dto/create-recipe.dto'; import { ParseMarkdownDto } from './dto/parse-markdown.dto'; 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 { @IsString() @@ -25,8 +27,11 @@ export class RecipesController { } @Get(':id/inventory-preview') - getInventoryPreview(@Param('id', ParseIntPipe) id: number) { - return this.recipesService.getInventoryPreview(id); + getInventoryPreview( + @Param('id', ParseIntPipe) id: number, + @CurrentUser() user: { userId: number }, + ) { + return this.recipesService.getInventoryPreview(id, user.userId); } @Get(':id') @@ -67,7 +72,35 @@ export class RecipesController { async updateImage( @Param('id', ParseIntPipe) id: number, @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()); } } \ No newline at end of file diff --git a/backend/src/recipes/recipes.service.ts b/backend/src/recipes/recipes.service.ts index 320daf7d..41a2b9c0 100644 --- a/backend/src/recipes/recipes.service.ts +++ b/backend/src/recipes/recipes.service.ts @@ -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 * as fs from 'node:fs/promises'; import * as path from 'node:path'; @@ -31,9 +31,16 @@ export class RecipesService { constructor(private readonly prisma: PrismaService) {} - async getInventoryPreview(id: number) { - const recipe = await this.prisma.recipe.findUnique({ - where: { id }, + async getInventoryPreview(id: number, userId: number) { + const recipe = await this.prisma.recipe.findFirst({ + where: { + id, + OR: [ + { isPublic: true }, + { ownerId: userId }, + { shares: { some: { userId } } }, + ], + }, include: { ingredients: { 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 } }); if (!existingRecipe) { 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); return this.prisma.recipe.update({ where: { id }, 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) { this.logger.log( `[create] Incoming imageUrl from client: ${createRecipeDto.imageUrl ?? 'null'}`, diff --git a/backend/src/users/users.controller.ts b/backend/src/users/users.controller.ts index 8b2fcb7a..b74ca351 100644 --- a/backend/src/users/users.controller.ts +++ b/backend/src/users/users.controller.ts @@ -14,6 +14,11 @@ class SetPremiumDto { isPremium: boolean; } +class SetRecipeSharingDto { + @IsBoolean() + canShareRecipes: boolean; +} + class AdminCreateUserDto { @IsString() @MinLength(2) @@ -113,6 +118,16 @@ export class UsersController { 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') @Post() async adminCreateUser( diff --git a/backend/src/users/users.service.ts b/backend/src/users/users.service.ts index 00404668..30ef267e 100644 --- a/backend/src/users/users.service.ts +++ b/backend/src/users/users.service.ts @@ -25,7 +25,17 @@ export class UsersService { findAll() { 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' }, }); } @@ -38,6 +48,10 @@ export class UsersService { 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 }) { const existing = await this.prisma.user.findFirst({ where: { OR: [{ username: data.username }, { email: data.email }] }, diff --git a/flutter/lib/core/api/api_paths.dart b/flutter/lib/core/api/api_paths.dart index 07a7c739..a0cf945f 100644 --- a/flutter/lib/core/api/api_paths.dart +++ b/flutter/lib/core/api/api_paths.dart @@ -30,6 +30,9 @@ class RecipeApiPaths { static String detail(int id) => '/recipes/$id'; static String update(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 const parseMarkdown = '/recipes/parse-markdown'; } @@ -52,6 +55,7 @@ class UserApiPaths { static const list = '/users'; static String setRole(int id) => '/users/$id/role'; 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 delete(int id) => '/users/$id'; static String resetPassword(int id) => '/users/$id/reset-password'; diff --git a/flutter/lib/core/auth/jwt_decoder.dart b/flutter/lib/core/auth/jwt_decoder.dart index 3bfbbaf4..4580c1af 100644 --- a/flutter/lib/core/auth/jwt_decoder.dart +++ b/flutter/lib/core/auth/jwt_decoder.dart @@ -24,3 +24,22 @@ String jwtRole(String? token) { /// Returns true if the JWT token contains role == '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; +} diff --git a/flutter/lib/features/admin/data/admin_repository.dart b/flutter/lib/features/admin/data/admin_repository.dart index 54e9fa50..f1545fbf 100644 --- a/flutter/lib/features/admin/data/admin_repository.dart +++ b/flutter/lib/features/admin/data/admin_repository.dart @@ -49,6 +49,19 @@ class AdminRepository { return UserAdmin.fromJson(data); } + Future 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 updateEmail(int userId, String email) async { final token = await _token(); await guardedApiCall( diff --git a/flutter/lib/features/admin/domain/user_admin.dart b/flutter/lib/features/admin/domain/user_admin.dart index 52f91b12..1b2a463f 100644 --- a/flutter/lib/features/admin/domain/user_admin.dart +++ b/flutter/lib/features/admin/domain/user_admin.dart @@ -7,6 +7,7 @@ class UserAdmin { final String? lastName; final String role; final bool isPremium; + final bool canShareRecipes; final DateTime? createdAt; const UserAdmin({ @@ -17,6 +18,7 @@ class UserAdmin { this.lastName, required this.role, required this.isPremium, + required this.canShareRecipes, this.createdAt, }); @@ -28,6 +30,7 @@ class UserAdmin { lastName: json['lastName'] as String?, role: json['role'] as String? ?? 'user', isPremium: json['isPremium'] as bool? ?? false, + canShareRecipes: json['canShareRecipes'] as bool? ?? true, createdAt: json['createdAt'] != null ? DateTime.tryParse(json['createdAt'] as String) : null, ); diff --git a/flutter/lib/features/admin/presentation/admin_users_panel.dart b/flutter/lib/features/admin/presentation/admin_users_panel.dart index 0ebb82d7..6b7ae69c 100644 --- a/flutter/lib/features/admin/presentation/admin_users_panel.dart +++ b/flutter/lib/features/admin/presentation/admin_users_panel.dart @@ -81,6 +81,26 @@ class _AdminUsersPanelState extends ConsumerState { } } + Future _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 _resetPassword(UserAdmin user) async { final confirmed = await _confirm( context, @@ -319,6 +339,7 @@ class _AdminUsersPanelState extends ConsumerState { user: _users[i], onChangeRole: () => _changeRole(_users[i]), onTogglePremium: () => _togglePremium(_users[i]), + onToggleRecipeSharing: () => _toggleRecipeSharing(_users[i]), onEditEmail: () => _editEmail(_users[i]), onResetPassword: () => _resetPassword(_users[i]), onDelete: () => _deleteUser(_users[i]), @@ -364,6 +385,7 @@ class _UserTile extends StatelessWidget { final UserAdmin user; final VoidCallback onChangeRole; final VoidCallback onTogglePremium; + final VoidCallback onToggleRecipeSharing; final VoidCallback onEditEmail; final VoidCallback onResetPassword; final VoidCallback onDelete; @@ -372,6 +394,7 @@ class _UserTile extends StatelessWidget { required this.user, required this.onChangeRole, required this.onTogglePremium, + required this.onToggleRecipeSharing, required this.onEditEmail, required this.onResetPassword, required this.onDelete, @@ -418,6 +441,16 @@ class _UserTile extends StatelessWidget { 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': onTogglePremium(); break; + case 'sharing': + onToggleRecipeSharing(); + break; case 'email': onEditEmail(); break; @@ -454,6 +490,14 @@ class _UserTile extends StatelessWidget { value: 'premium', child: Text(user.isPremium ? 'Ta bort Premium' : 'Ge Premium'), ), + PopupMenuItem( + value: 'sharing', + child: Text( + user.canShareRecipes + ? 'Blockera receptdelning' + : 'Tillåt receptdelning', + ), + ), const PopupMenuItem( value: 'email', child: Text('Ändra e-post'), diff --git a/flutter/lib/features/import/presentation/recipe_import_tab.dart b/flutter/lib/features/import/presentation/recipe_import_tab.dart index c1d2e909..ebc26811 100644 --- a/flutter/lib/features/import/presentation/recipe_import_tab.dart +++ b/flutter/lib/features/import/presentation/recipe_import_tab.dart @@ -3,7 +3,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/utils/global_error_handler.dart'; import '../../auth/data/auth_providers.dart'; import '../data/import_providers.dart'; @@ -23,17 +22,17 @@ class RecipeImportTab extends ConsumerStatefulWidget { } class _RecipeImportTabState extends ConsumerState { - // Shared state bool _isLoading = false; - String? _error; - - // File mode PlatformFile? _pickedFile; - - // URL mode _Method _method = _Method.file; final _urlCtrl = TextEditingController(); + @override + void initState() { + super.initState(); + _urlCtrl.addListener(() => setState(() {})); + } + @override void dispose() { _urlCtrl.dispose(); @@ -59,17 +58,15 @@ class _RecipeImportTabState extends ConsumerState { Future _submit() async { 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; } - setState(() { - _isLoading = true; - _error = null; - }); + setState(() => _isLoading = true); 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 result = _method == _Method.file ? await repo.importFile( @@ -83,7 +80,10 @@ class _RecipeImportTabState extends ConsumerState { ); if (!mounted) return; - context.push('/recipes/create', extra: result); + context.push('/recipes/create', extra: { + 'markdown': result.markdown, + 'imageUrl': result.imageUrl, + }); } catch (e) { showGlobalErrorDialog(context, 'Ett fel uppstod vid import: $e'); } finally { @@ -172,7 +172,6 @@ class _RecipeImportTabState extends ConsumerState { prefixIcon: Icon(Icons.link), border: OutlineInputBorder(), ), - onChanged: (_) => setState(() {}), onSubmitted: (_) { if (_canSubmit) _submit(); }, @@ -193,31 +192,6 @@ class _RecipeImportTabState extends ConsumerState { 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 ─────────────────────────────────────────────────────── FilledButton.icon( diff --git a/flutter/lib/features/recipes/data/recipe_repository.dart b/flutter/lib/features/recipes/data/recipe_repository.dart index dde489c4..48ce7dae 100644 --- a/flutter/lib/features/recipes/data/recipe_repository.dart +++ b/flutter/lib/features/recipes/data/recipe_repository.dart @@ -94,6 +94,65 @@ class RecipeRepository { } } + Future 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) { + 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 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) { + 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 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) { + 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 fetchInventoryPreview(int id, {String? token}) async { try { diff --git a/flutter/lib/features/recipes/domain/recipe.dart b/flutter/lib/features/recipes/domain/recipe.dart index 10964338..aaffb0d3 100644 --- a/flutter/lib/features/recipes/domain/recipe.dart +++ b/flutter/lib/features/recipes/domain/recipe.dart @@ -8,6 +8,10 @@ class Recipe { final int? servings; final String? instructions; final List ingredients; + final bool isPublic; + final int? ownerId; + final String? ownerUsername; + final List sharedWithUserIds; const Recipe({ required this.id, @@ -17,6 +21,10 @@ class Recipe { this.servings, this.instructions, this.ingredients = const [], + this.isPublic = false, + this.ownerId, + this.ownerUsername, + this.sharedWithUserIds = const [], }); factory Recipe.fromJson(Map json) { @@ -27,6 +35,8 @@ class Recipe { final dynamic rawServings = json['servings']; final rawIngredients = json['ingredients'] as List? ?? []; final normalizedImageUrl = rawImageUrl?.toString().trim(); + final ownerJson = json['owner'] as Map?; + final sharesJson = json['shares'] as List? ?? const []; return Recipe( id: rawId is num ? rawId.toInt() : int.parse(rawId.toString()), @@ -45,6 +55,18 @@ class Recipe { ingredients: rawIngredients .map((i) => RecipeIngredient.fromJson(i as Map)) .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)['userId']) + .whereType() + .map((id) => id.toInt()) + .toList(), ); } } diff --git a/flutter/lib/features/recipes/presentation/recipe_detail_screen.dart b/flutter/lib/features/recipes/presentation/recipe_detail_screen.dart index 00295cd3..894fa743 100644 --- a/flutter/lib/features/recipes/presentation/recipe_detail_screen.dart +++ b/flutter/lib/features/recipes/presentation/recipe_detail_screen.dart @@ -4,12 +4,18 @@ import 'package:go_router/go_router.dart'; import '../../../core/api/api_error_mapper.dart'; import '../../../core/api/api_exception.dart'; +import '../../../core/auth/jwt_decoder.dart'; import '../../../core/ui/async_state_views.dart'; import '../../auth/data/auth_providers.dart'; import '../data/recipe_providers.dart'; import '../domain/recipe.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 { final int recipeId; @@ -18,18 +24,34 @@ class RecipeDetailScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { 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( appBar: AppBar( - title: Text(recipeAsync.maybeWhen(data: (d) => d, orElse: () => null)?.title ?? 'Recept'), + title: const SizedBox.shrink(), leading: IconButton( icon: const Icon(Icons.arrow_back), onPressed: () => context.go('/recipes'), 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( tooltip: 'Redigera', icon: const Icon(Icons.edit_outlined), @@ -41,7 +63,7 @@ class RecipeDetailScreen extends ConsumerWidget { icon: const Icon(Icons.inventory_2_outlined), onPressed: () => context.go('/inventory'), ), - _DeleteButton(recipe: recipeAsync.value!), + if (isOwner) _DeleteButton(recipe: recipe), ], ), body: recipeAsync.when( @@ -54,23 +76,75 @@ class RecipeDetailScreen extends ConsumerWidget { physics: const BouncingScrollPhysics(), slivers: [ SliverAppBar( - expandedHeight: MediaQuery.of(context).size.height * 2 / 3, + expandedHeight: MediaQuery.of(context).size.height * 0.42, flexibleSpace: FlexibleSpaceBar( - background: recipe.imageUrl != null - ? Image.network( - recipe.imageUrl!, - fit: BoxFit.cover, - ) - : Container(color: Colors.grey[200]), + background: Stack( + fit: StackFit.expand, + children: [ + recipe.imageUrl != null + ? Image.network( + recipe.imageUrl!, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => _ImagePlaceholder(), + ) + : _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, - floating: false, ), SliverToBoxAdapter( child: Container( - decoration: const BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.only( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: const BorderRadius.only( topLeft: Radius.circular(20), topRight: Radius.circular(20), ), @@ -83,6 +157,140 @@ class RecipeDetailScreen extends ConsumerWidget { ), ); } + + Future _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 _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 { @@ -124,7 +332,8 @@ class _DeleteButton extends ConsumerWidget { if (confirmed != true || !context.mounted) return; 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, token: token); ref.invalidate(recipesProvider); @@ -143,23 +352,15 @@ class _RecipeBody extends StatelessWidget { 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( + return Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Bilden visas endast som bakgrund i detaljvyn - Text(recipe.title, style: theme.textTheme.headlineSmall), + // Titel visas som overlay på bilden — inte upprepas här if (recipe.description != null) ...[ const SizedBox(height: 8), Text(recipe.description!, @@ -180,22 +381,48 @@ class _RecipeBody extends StatelessWidget { if (recipe.ingredients.isNotEmpty) ...[ const SizedBox(height: 24), Text('Ingredienser', style: theme.textTheme.titleMedium), - const SizedBox(height: 8), + const SizedBox(height: 12), ...recipe.ingredients.map((ing) { - final qtyStr = _formatQty(ing.quantity); - final parts = [ + final qtyStr = ing.quantity == 0 ? '' : _fmtQty(ing.quantity); + final measureParts = [ if (qtyStr.isNotEmpty) qtyStr, if (ing.unit.isNotEmpty) ing.unit, - ing.productName, - if (ing.note != null) '(${ing.note})', ]; + final measure = measureParts.join(' '); return Padding( - padding: const EdgeInsets.symmetric(vertical: 3), + padding: const EdgeInsets.symmetric(vertical: 5), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('• '), - Expanded(child: Text(parts.join(' '))), + if (measure.isNotEmpty) ...[ + 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 && recipe.instructions!.isNotEmpty) ...[ - const SizedBox(height: 24), + const SizedBox(height: 32), 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: 16), + ..._buildSteps(recipe.instructions!, theme), ], _InventoryPreviewSection(recipeId: recipe.id), const SizedBox(height: 40), @@ -216,6 +441,54 @@ class _RecipeBody extends StatelessWidget { ), ); } + + List _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 { @@ -367,18 +640,15 @@ class _IngredientPreviewRow extends StatelessWidget { IngredientStatus.missing => (Icons.cancel_outlined, cs.error), }; - String _fmt(double v) => - v == v.truncateToDouble() ? v.toInt().toString() : v.toString(); - final requiredStr = - '${_fmt(ingredient.requiredQuantity)} ${ingredient.requiredUnit}'.trim(); + '${_fmtQty(ingredient.requiredQuantity)} ${ingredient.requiredUnit}'.trim(); final availableStr = - '${_fmt(ingredient.availableQuantity)} ${ingredient.requiredUnit}'.trim(); + '${_fmtQty(ingredient.availableQuantity)} ${ingredient.requiredUnit}'.trim(); final subtitle = switch (ingredient.status) { IngredientStatus.enough => 'Tillgängligt: $availableStr', IngredientStatus.missing => ingredient.availableQuantity > 0 - ? 'Saknar ${_fmt(ingredient.missingQuantity)} ${ingredient.requiredUnit} ' + ? 'Saknar ${_fmtQty(ingredient.missingQuantity)} ${ingredient.requiredUnit} ' '(har $availableStr)' : 'Saknas helt', IngredientStatus.unitMismatch => diff --git a/flutter/lib/features/recipes/presentation/recipes_screen.dart b/flutter/lib/features/recipes/presentation/recipes_screen.dart index 95a2d3fb..9e13809a 100644 --- a/flutter/lib/features/recipes/presentation/recipes_screen.dart +++ b/flutter/lib/features/recipes/presentation/recipes_screen.dart @@ -6,6 +6,7 @@ import '../../../core/api/api_error_mapper.dart'; import '../../../core/ui/async_state_views.dart'; import '../data/recipe_providers.dart'; import '../data/recipes_grid_provider.dart'; +import '../domain/recipe.dart'; class RecipesScreen extends ConsumerWidget { const RecipesScreen({super.key}); @@ -47,21 +48,7 @@ class RecipesScreen extends ConsumerWidget { final recipe = recipes[index]; return GestureDetector( onTap: () => context.push('/recipes/${recipe.id}'), - child: Container( - 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, - ), + child: _RecipeImageCard(recipe: recipe), ); }, ); @@ -80,20 +67,11 @@ class RecipesScreen extends ConsumerWidget { children: [ ClipRRect( borderRadius: BorderRadius.circular(6), - child: recipe.imageUrl != null - ? Image.network( - recipe.imageUrl!, - width: 72, - height: 72, - fit: BoxFit.cover, - ) - : Container( - width: 72, - height: 72, - color: Colors.grey[200], - child: const Icon(Icons.restaurant, - size: 32), - ), + child: SizedBox( + width: 72, + height: 72, + child: _RecipeImageCard(recipe: recipe, compact: true), + ), ), const SizedBox(width: 12), Expanded( @@ -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, + ), + ), + ), + ), + ], + ), + ); + } +}