feat: implement recipe analysis service and data models
Test Suite / test (24.15.0) (push) Has been cancelled

- Added RecipeAnalysisService to handle recipe ingredient analysis, including methods for checking ingredient availability and calculating quantities.
- Introduced new TypeScript definitions for recipe analysis results, including ingredient status and summary.
- Created corresponding Dart models for recipe analysis, including RecipeIngredientAnalysis, RecipeAnalysisSummary, and RecipeShoppingCandidate.
- Updated Flutter UI to reflect changes in ingredient availability status.
- Fixed color opacity issue in recipe image card.
This commit is contained in:
Nils-Johan Gynther
2026-05-06 07:54:03 +02:00
parent 969dafdbc6
commit 9fe85a719c
23 changed files with 1271 additions and 693 deletions
@@ -11,7 +11,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';
import '../domain/recipe_analysis.dart';
String _fmtQty(double v) => formatQuantity(v);
@@ -126,7 +126,7 @@ class RecipeDetailScreen extends ConsumerWidget {
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.45),
color: Colors.black.withValues(alpha: 0.45),
borderRadius: BorderRadius.circular(14),
),
child: Text(
@@ -296,7 +296,7 @@ class _ImagePlaceholder extends StatelessWidget {
child: Icon(
Icons.restaurant,
size: 64,
color: Theme.of(context).colorScheme.onSurfaceVariant.withOpacity(0.4),
color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
),
),
);
@@ -543,7 +543,7 @@ class _InventoryPreviewSectionState
tooltip: 'Uppdatera',
icon: const Icon(Icons.refresh),
onPressed: () {
ref.invalidate(inventoryPreviewProvider(widget.recipeId));
ref.invalidate(recipeAnalysisProvider(widget.recipeId));
},
),
],
@@ -561,7 +561,7 @@ class _InventoryPreviewResults extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final previewAsync = ref.watch(inventoryPreviewProvider(recipeId));
final previewAsync = ref.watch(recipeAnalysisProvider(recipeId));
final theme = Theme.of(context);
return previewAsync.when(
@@ -587,6 +587,33 @@ class _InventoryPreviewResults extends ConsumerWidget {
...preview.ingredients.map(
(ing) => _IngredientPreviewRow(ingredient: ing),
),
if (preview.shoppingListCandidates.isNotEmpty) ...[
const SizedBox(height: 16),
Text('Shoppinglista', style: theme.textTheme.titleSmall),
const SizedBox(height: 8),
...preview.shoppingListCandidates.map((item) {
final qty = _fmtQty(item.missingQuantity > 0 ? item.missingQuantity : item.quantity);
final measure = '$qty ${item.unit}'.trim();
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.shopping_cart_outlined, size: 16),
const SizedBox(width: 8),
Expanded(
child: Text(
measure.isEmpty
? item.rawName
: '$measure ${item.rawName}',
style: theme.textTheme.bodySmall,
),
),
],
),
);
}),
],
],
);
},
@@ -595,7 +622,7 @@ class _InventoryPreviewResults extends ConsumerWidget {
}
class _SummaryChips extends StatelessWidget {
final PreviewSummary summary;
final RecipeAnalysisSummary summary;
const _SummaryChips({required this.summary});
@@ -607,7 +634,7 @@ class _SummaryChips extends StatelessWidget {
spacing: 8,
runSpacing: 4,
children: [
if (summary.canCookExactly)
if (summary.missingCount == 0)
Chip(
avatar: Icon(Icons.check_circle,
color: cs.onPrimary, size: 16),
@@ -624,21 +651,29 @@ class _SummaryChips extends StatelessWidget {
backgroundColor: cs.errorContainer,
labelStyle: TextStyle(color: cs.onErrorContainer),
),
if (summary.unitMismatchCount > 0)
if (summary.substituteCount > 0)
Chip(
avatar: Icon(Icons.swap_horiz,
color: cs.onTertiaryContainer, size: 16),
label: Text('${summary.unitMismatchCount} enhetsmismatch'),
label: Text('${summary.substituteCount} ersättningsbar'),
backgroundColor: cs.tertiaryContainer,
labelStyle: TextStyle(color: cs.onTertiaryContainer),
),
if (summary.pantryCount > 0)
Chip(
avatar: Icon(Icons.kitchen_outlined,
color: cs.onSecondaryContainer, size: 16),
label: Text('${summary.pantryCount} i skafferiet'),
backgroundColor: cs.secondaryContainer,
labelStyle: TextStyle(color: cs.onSecondaryContainer),
),
],
);
}
}
class _IngredientPreviewRow extends StatelessWidget {
final IngredientPreview ingredient;
final RecipeIngredientAnalysis ingredient;
const _IngredientPreviewRow({required this.ingredient});
@@ -646,37 +681,35 @@ class _IngredientPreviewRow extends StatelessWidget {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final cs = theme.colorScheme;
final label = ingredient.productName.trim().isEmpty
? 'Okänd ingrediens'
: ingredient.productName;
final matchedName = ingredient.matchedProductName?.trim() ?? '';
final label = matchedName.isEmpty
? (ingredient.rawName.trim().isEmpty ? 'Okänd ingrediens' : ingredient.rawName)
: matchedName;
final (icon, color) = ingredient.fromPantry
? (Icons.kitchen_outlined, cs.secondary)
: 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),
};
final (icon, color) = switch (ingredient.status) {
RecipeIngredientAvailabilityStatus.coveredByPantry => (Icons.kitchen_outlined, cs.secondary),
RecipeIngredientAvailabilityStatus.exactMatch => (Icons.check_circle_outline, cs.primary),
RecipeIngredientAvailabilityStatus.substitutable => (Icons.swap_horiz, cs.tertiary),
RecipeIngredientAvailabilityStatus.missing => (Icons.cancel_outlined, cs.error),
};
final effectiveUnit = ingredient.unit;
final requiredStr =
'${_fmtQty(ingredient.requiredQuantity)} ${ingredient.requiredUnit}'.trim();
'${_fmtQty(ingredient.quantity)} $effectiveUnit'.trim();
final availableStr =
'${_fmtQty(ingredient.availableQuantity)} ${ingredient.requiredUnit}'.trim();
'${_fmtQty(ingredient.availableQuantity)} $effectiveUnit'.trim();
final subtitle = ingredient.fromPantry
? 'Finns i skafferiet'
: switch (ingredient.status) {
IngredientStatus.enough => 'Tillgängligt: $availableStr',
IngredientStatus.missing => ingredient.availableQuantity > 0
? 'Saknar ${_fmtQty(ingredient.missingQuantity)} ${ingredient.requiredUnit} '
'(har $availableStr)'
: 'Saknas helt',
IngredientStatus.unitMismatch =>
'Annan enhet i lager kontrollera manuellt',
};
final subtitle = switch (ingredient.status) {
RecipeIngredientAvailabilityStatus.coveredByPantry => 'Finns i skafferiet',
RecipeIngredientAvailabilityStatus.exactMatch => 'Tillgängligt: $availableStr',
RecipeIngredientAvailabilityStatus.substitutable =>
ingredient.matchedProductName == null || ingredient.matchedProductName!.trim().isEmpty
? 'Kan ersättas med annan vara'
: 'Kan ersättas med ${ingredient.matchedProductName}',
RecipeIngredientAvailabilityStatus.missing => ingredient.availableQuantity > 0
? 'Saknar ${_fmtQty(ingredient.missingQuantity)} $effectiveUnit (har $availableStr)'
: 'Saknas helt',
};
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),