feat(recipes): add recipe visibility and sharing features

- Implemented functionality to set recipe visibility (public/private) with appropriate checks for user permissions.
- Added ability to share recipes with other users, including validation for existing users and permissions.
- Introduced new DTOs for setting visibility and sharing recipes.
- Updated RecipesController and RecipesService to handle new endpoints for visibility and sharing.
- Enhanced inventory preview to consider user permissions and shared recipes.
- Updated front-end to support new sharing and visibility features, including UI changes for recipe detail and admin user management.
This commit is contained in:
Nils-Johan Gynther
2026-05-02 09:19:59 +02:00
parent f67bf8baef
commit 41ae7d4d06
17 changed files with 742 additions and 124 deletions
@@ -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,
{String? token}) async {
try {
@@ -8,6 +8,10 @@ class Recipe {
final int? servings;
final String? instructions;
final List<RecipeIngredient> ingredients;
final bool isPublic;
final int? ownerId;
final String? ownerUsername;
final List<int> 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<String, dynamic> json) {
@@ -27,6 +35,8 @@ class Recipe {
final dynamic rawServings = json['servings'];
final rawIngredients = json['ingredients'] as List<dynamic>? ?? [];
final normalizedImageUrl = rawImageUrl?.toString().trim();
final ownerJson = json['owner'] as Map<String, dynamic>?;
final sharesJson = json['shares'] as List<dynamic>? ?? 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<String, dynamic>))
.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_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<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 {
@@ -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<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 {
@@ -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 =>
@@ -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,
),
),
),
),
],
),
);
}
}