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:
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -49,6 +49,19 @@ class AdminRepository {
|
||||
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 {
|
||||
final token = await _token();
|
||||
await guardedApiCall(
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
final confirmed = await _confirm(
|
||||
context,
|
||||
@@ -319,6 +339,7 @@ class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
|
||||
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'),
|
||||
|
||||
@@ -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<RecipeImportTab> {
|
||||
// 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<RecipeImportTab> {
|
||||
|
||||
Future<void> _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<RecipeImportTab> {
|
||||
);
|
||||
|
||||
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<RecipeImportTab> {
|
||||
prefixIcon: Icon(Icons.link),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onChanged: (_) => setState(() {}),
|
||||
onSubmitted: (_) {
|
||||
if (_canSubmit) _submit();
|
||||
},
|
||||
@@ -193,31 +192,6 @@ class _RecipeImportTabState extends ConsumerState<RecipeImportTab> {
|
||||
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(
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user