feat(inventory): add inventory preview functionality and related models

This commit is contained in:
Nils-Johan Gynther
2026-04-22 19:41:45 +02:00
parent b31af6181c
commit b8627d0b7f
5 changed files with 327 additions and 0 deletions
+1
View File
@@ -11,6 +11,7 @@ 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 inventoryPreview(int id) => '/recipes/$id/inventory-preview';
static const parseMarkdown = '/recipes/parse-markdown'; static const parseMarkdown = '/recipes/parse-markdown';
} }
@@ -4,6 +4,7 @@ import '../../../core/api/api_providers.dart';
import '../../../core/api/guarded_api_call.dart'; import '../../../core/api/guarded_api_call.dart';
import '../../../features/auth/data/auth_providers.dart'; import '../../../features/auth/data/auth_providers.dart';
import '../domain/recipe.dart'; import '../domain/recipe.dart';
import '../domain/inventory_preview.dart';
import 'recipe_repository.dart'; import 'recipe_repository.dart';
final recipeRepositoryProvider = Provider<RecipeRepository>((ref) { final recipeRepositoryProvider = Provider<RecipeRepository>((ref) {
@@ -26,3 +27,14 @@ final recipeDetailProvider =
() => ref.read(recipeRepositoryProvider).fetchRecipeDetail(id, token: token), () => ref.read(recipeRepositoryProvider).fetchRecipeDetail(id, token: token),
); );
}); });
final inventoryPreviewProvider =
FutureProvider.family<InventoryPreview, int>((ref, id) async {
final token = await ref.watch(authStateProvider.future);
return guardedApiCall(
ref,
() => ref
.read(recipeRepositoryProvider)
.fetchInventoryPreview(id, token: token),
);
});
@@ -3,6 +3,7 @@ import '../../../core/api/api_exception.dart';
import '../../../core/api/api_paths.dart'; import '../../../core/api/api_paths.dart';
import '../domain/parsed_recipe.dart'; import '../domain/parsed_recipe.dart';
import '../domain/recipe.dart'; import '../domain/recipe.dart';
import '../domain/inventory_preview.dart';
class RecipeRepository { class RecipeRepository {
final ApiClient _api; final ApiClient _api;
@@ -93,6 +94,27 @@ class RecipeRepository {
} }
} }
Future<InventoryPreview> fetchInventoryPreview(int id,
{String? token}) async {
try {
final data = await _api.getJson(
RecipeApiPaths.inventoryPreview(id),
token: token,
);
if (data is! Map<String, dynamic>) {
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<ParsedRecipe> parseMarkdown(String markdown, Future<ParsedRecipe> parseMarkdown(String markdown,
{String? token}) async { {String? token}) async {
try { try {
@@ -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<String, dynamic> 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<String, dynamic> 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<IngredientPreview> ingredients;
final PreviewSummary summary;
const InventoryPreview({
required this.ingredients,
required this.summary,
});
factory InventoryPreview.fromJson(Map<String, dynamic> json) {
final rawIngredients = json['ingredients'] as List<dynamic>? ?? [];
return InventoryPreview(
ingredients: rawIngredients
.map((e) => IngredientPreview.fromJson(e as Map<String, dynamic>))
.toList(),
summary: PreviewSummary.fromJson(
json['summary'] as Map<String, dynamic>? ?? {}),
);
}
}
@@ -8,6 +8,7 @@ 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';
class RecipeDetailScreen extends ConsumerWidget { class RecipeDetailScreen extends ConsumerWidget {
final int recipeId; final int recipeId;
@@ -182,9 +183,208 @@ class _RecipeBody extends StatelessWidget {
style: theme.textTheme.bodyMedium style: theme.textTheme.bodyMedium
?.copyWith(height: 1.6)), ?.copyWith(height: 1.6)),
], ],
_InventoryPreviewSection(recipeId: recipe.id),
const SizedBox(height: 40), 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),
),
],
),
),
],
),
);
}
}