feat: implement recipe analysis service and data models
Test Suite / test (24.15.0) (push) Has been cancelled
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:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user