diff --git a/flutter/lib/core/api/api_paths.dart b/flutter/lib/core/api/api_paths.dart index 433c9f33..1b9cc612 100644 --- a/flutter/lib/core/api/api_paths.dart +++ b/flutter/lib/core/api/api_paths.dart @@ -11,6 +11,7 @@ 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 inventoryPreview(int id) => '/recipes/$id/inventory-preview'; static const parseMarkdown = '/recipes/parse-markdown'; } diff --git a/flutter/lib/features/recipes/data/recipe_providers.dart b/flutter/lib/features/recipes/data/recipe_providers.dart index 42f2de70..ae2cbdd8 100644 --- a/flutter/lib/features/recipes/data/recipe_providers.dart +++ b/flutter/lib/features/recipes/data/recipe_providers.dart @@ -4,6 +4,7 @@ import '../../../core/api/api_providers.dart'; import '../../../core/api/guarded_api_call.dart'; import '../../../features/auth/data/auth_providers.dart'; import '../domain/recipe.dart'; +import '../domain/inventory_preview.dart'; import 'recipe_repository.dart'; final recipeRepositoryProvider = Provider((ref) { @@ -26,3 +27,14 @@ final recipeDetailProvider = () => ref.read(recipeRepositoryProvider).fetchRecipeDetail(id, token: token), ); }); + +final inventoryPreviewProvider = + FutureProvider.family((ref, id) async { + final token = await ref.watch(authStateProvider.future); + return guardedApiCall( + ref, + () => ref + .read(recipeRepositoryProvider) + .fetchInventoryPreview(id, token: token), + ); +}); diff --git a/flutter/lib/features/recipes/data/recipe_repository.dart b/flutter/lib/features/recipes/data/recipe_repository.dart index 5f4c6f26..27d632a2 100644 --- a/flutter/lib/features/recipes/data/recipe_repository.dart +++ b/flutter/lib/features/recipes/data/recipe_repository.dart @@ -3,6 +3,7 @@ import '../../../core/api/api_exception.dart'; import '../../../core/api/api_paths.dart'; import '../domain/parsed_recipe.dart'; import '../domain/recipe.dart'; +import '../domain/inventory_preview.dart'; class RecipeRepository { final ApiClient _api; @@ -93,6 +94,27 @@ class RecipeRepository { } } + Future fetchInventoryPreview(int id, + {String? token}) async { + try { + final data = await _api.getJson( + RecipeApiPaths.inventoryPreview(id), + token: token, + ); + if (data is! Map) { + throw const ApiException( + type: ApiErrorType.unknown, message: 'Ogiltigt svar från servern.'); + } + return InventoryPreview.fromJson(data); + } on ApiException { + rethrow; + } catch (_) { + throw const ApiException( + type: ApiErrorType.network, + message: 'Kunde inte hämta inventariestatus.'); + } + } + Future parseMarkdown(String markdown, {String? token}) async { try { diff --git a/flutter/lib/features/recipes/domain/inventory_preview.dart b/flutter/lib/features/recipes/domain/inventory_preview.dart new file mode 100644 index 00000000..bf49cff1 --- /dev/null +++ b/flutter/lib/features/recipes/domain/inventory_preview.dart @@ -0,0 +1,92 @@ +enum IngredientStatus { enough, missing, unitMismatch } + +class IngredientPreview { + final int ingredientId; + final int productId; + final String productName; + final double requiredQuantity; + final String requiredUnit; + final String? note; + final double availableQuantity; + final IngredientStatus status; + final double missingQuantity; + + const IngredientPreview({ + required this.ingredientId, + required this.productId, + required this.productName, + required this.requiredQuantity, + required this.requiredUnit, + this.note, + required this.availableQuantity, + required this.status, + required this.missingQuantity, + }); + + factory IngredientPreview.fromJson(Map json) { + final rawStatus = json['status'] as String? ?? 'missing'; + final status = switch (rawStatus) { + 'enough' => IngredientStatus.enough, + 'unit_mismatch' => IngredientStatus.unitMismatch, + _ => IngredientStatus.missing, + }; + return IngredientPreview( + ingredientId: json['ingredientId'] as int, + productId: json['productId'] as int, + productName: json['productName'] as String, + requiredQuantity: (json['requiredQuantity'] as num).toDouble(), + requiredUnit: json['requiredUnit'] as String? ?? '', + note: json['note'] as String?, + availableQuantity: (json['availableQuantity'] as num? ?? 0).toDouble(), + status: status, + missingQuantity: (json['missingQuantity'] as num? ?? 0).toDouble(), + ); + } +} + +class PreviewSummary { + final int totalIngredients; + final int enoughCount; + final int missingCount; + final int unitMismatchCount; + final bool canCookExactly; + + const PreviewSummary({ + required this.totalIngredients, + required this.enoughCount, + required this.missingCount, + required this.unitMismatchCount, + required this.canCookExactly, + }); + + factory PreviewSummary.fromJson(Map json) { + return PreviewSummary( + totalIngredients: json['totalIngredients'] as int, + enoughCount: json['enoughCount'] as int, + missingCount: json['missingCount'] as int, + unitMismatchCount: json['unitMismatchCount'] as int, + canCookExactly: json['canCookExactly'] as bool? ?? false, + ); + } +} + +class InventoryPreview { + final List ingredients; + final PreviewSummary summary; + + const InventoryPreview({ + required this.ingredients, + required this.summary, + }); + + factory InventoryPreview.fromJson(Map json) { + final rawIngredients = json['ingredients'] as List? ?? []; + return InventoryPreview( + ingredients: rawIngredients + .map((e) => IngredientPreview.fromJson(e as Map)) + .toList(), + summary: PreviewSummary.fromJson( + json['summary'] as Map? ?? {}), + ); + } +} diff --git a/flutter/lib/features/recipes/presentation/recipe_detail_screen.dart b/flutter/lib/features/recipes/presentation/recipe_detail_screen.dart index 938dce41..a1208647 100644 --- a/flutter/lib/features/recipes/presentation/recipe_detail_screen.dart +++ b/flutter/lib/features/recipes/presentation/recipe_detail_screen.dart @@ -8,6 +8,7 @@ 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'; class RecipeDetailScreen extends ConsumerWidget { final int recipeId; @@ -182,9 +183,208 @@ class _RecipeBody extends StatelessWidget { style: theme.textTheme.bodyMedium ?.copyWith(height: 1.6)), ], + _InventoryPreviewSection(recipeId: recipe.id), const SizedBox(height: 40), ], ), ); } } + +class _InventoryPreviewSection extends ConsumerStatefulWidget { + final int recipeId; + + const _InventoryPreviewSection({required this.recipeId}); + + @override + ConsumerState<_InventoryPreviewSection> createState() => + _InventoryPreviewSectionState(); +} + +class _InventoryPreviewSectionState + extends ConsumerState<_InventoryPreviewSection> { + bool _loaded = false; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 24), + Row( + children: [ + Text('Vad saknas?', style: theme.textTheme.titleMedium), + const Spacer(), + if (!_loaded) + FilledButton.tonalIcon( + onPressed: () => setState(() => _loaded = true), + icon: const Icon(Icons.search, size: 16), + label: const Text('Kontrollera inventarie'), + ), + if (_loaded) + IconButton( + tooltip: 'Uppdatera', + icon: const Icon(Icons.refresh), + onPressed: () { + ref.invalidate(inventoryPreviewProvider(widget.recipeId)); + }, + ), + ], + ), + if (_loaded) _InventoryPreviewResults(recipeId: widget.recipeId), + ], + ); + } +} + +class _InventoryPreviewResults extends ConsumerWidget { + final int recipeId; + + const _InventoryPreviewResults({required this.recipeId}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final previewAsync = ref.watch(inventoryPreviewProvider(recipeId)); + final theme = Theme.of(context); + + return previewAsync.when( + loading: () => const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Center(child: CircularProgressIndicator()), + ), + error: (error, _) => Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + mapErrorToUserMessage(error, context), + style: TextStyle(color: theme.colorScheme.error), + ), + ), + data: (preview) { + final summary = preview.summary; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8), + _SummaryChips(summary: summary), + const SizedBox(height: 12), + ...preview.ingredients.map( + (ing) => _IngredientPreviewRow(ingredient: ing), + ), + ], + ); + }, + ); + } +} + +class _SummaryChips extends StatelessWidget { + final PreviewSummary summary; + + const _SummaryChips({required this.summary}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final cs = theme.colorScheme; + return Wrap( + spacing: 8, + runSpacing: 4, + children: [ + if (summary.canCookExactly) + Chip( + avatar: Icon(Icons.check_circle, + color: cs.onPrimary, size: 16), + label: const Text('Kan lagas!'), + backgroundColor: cs.primary, + labelStyle: TextStyle(color: cs.onPrimary), + ) + else + Chip( + avatar: Icon(Icons.warning_amber, + color: cs.onErrorContainer, size: 16), + label: Text('Saknar ${summary.missingCount} ingrediens' + '${summary.missingCount == 1 ? '' : 'er'}'), + backgroundColor: cs.errorContainer, + labelStyle: TextStyle(color: cs.onErrorContainer), + ), + if (summary.unitMismatchCount > 0) + Chip( + avatar: Icon(Icons.swap_horiz, + color: cs.onTertiaryContainer, size: 16), + label: Text('${summary.unitMismatchCount} enhetsmismatch'), + backgroundColor: cs.tertiaryContainer, + labelStyle: TextStyle(color: cs.onTertiaryContainer), + ), + ], + ); + } +} + +class _IngredientPreviewRow extends StatelessWidget { + final IngredientPreview ingredient; + + const _IngredientPreviewRow({required this.ingredient}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final cs = theme.colorScheme; + + final (icon, color) = switch (ingredient.status) { + IngredientStatus.enough => (Icons.check_circle_outline, cs.primary), + IngredientStatus.unitMismatch => ( + Icons.swap_horiz, + cs.tertiary, + ), + 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(); + final availableStr = + '${_fmt(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} ' + '(har $availableStr)' + : 'Saknas helt', + IngredientStatus.unitMismatch => + 'Annan enhet i lager – kontrollera manuellt', + }; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, color: color, size: 18), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${ingredient.productName}' + '${ingredient.note != null ? ' (${ingredient.note})' : ''}' + ' – $requiredStr', + style: theme.textTheme.bodyMedium, + ), + Text( + subtitle, + style: theme.textTheme.bodySmall + ?.copyWith(color: color), + ), + ], + ), + ), + ], + ), + ); + } +}