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:
@@ -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;
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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'}`,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 }] },
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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(
|
||||||
? Image.network(
|
fit: StackFit.expand,
|
||||||
recipe.imageUrl!,
|
children: [
|
||||||
fit: BoxFit.cover,
|
recipe.imageUrl != null
|
||||||
)
|
? Image.network(
|
||||||
: Container(color: Colors.grey[200]),
|
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,
|
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,20 +67,11 @@ class RecipesScreen extends ConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(6),
|
||||||
child: recipe.imageUrl != null
|
child: SizedBox(
|
||||||
? Image.network(
|
width: 72,
|
||||||
recipe.imageUrl!,
|
height: 72,
|
||||||
width: 72,
|
child: _RecipeImageCard(recipe: recipe, compact: true),
|
||||||
height: 72,
|
),
|
||||||
fit: BoxFit.cover,
|
|
||||||
)
|
|
||||||
: Container(
|
|
||||||
width: 72,
|
|
||||||
height: 72,
|
|
||||||
color: Colors.grey[200],
|
|
||||||
child: const Icon(Icons.restaurant,
|
|
||||||
size: 32),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user