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
+1
View File
@@ -45,6 +45,7 @@ class RecipeApiPaths {
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 String analysis(int id) => '/recipes/$id/analysis';
static const parseMarkdown = '/recipes/parse-markdown';
static const aiSuggestions = '/recipes/ai-suggestions';
}
@@ -5,6 +5,7 @@ import '../../../core/api/guarded_api_call.dart';
import '../../../features/auth/data/auth_providers.dart';
import '../domain/recipe.dart';
import '../domain/inventory_preview.dart';
import '../domain/recipe_analysis.dart';
import 'recipe_repository.dart';
final recipeRepositoryProvider = Provider<RecipeRepository>((ref) {
@@ -38,3 +39,14 @@ final inventoryPreviewProvider =
.fetchInventoryPreview(id, token: token),
);
});
final recipeAnalysisProvider =
FutureProvider.family<RecipeAnalysis, int>((ref, id) async {
final token = await ref.watch(authStateProvider.future);
return guardedApiCall(
ref,
() => ref
.read(recipeRepositoryProvider)
.fetchRecipeAnalysis(id, token: token),
);
});
@@ -4,6 +4,7 @@ import '../../../core/api/api_paths.dart';
import '../domain/parsed_recipe.dart';
import '../domain/recipe.dart';
import '../domain/inventory_preview.dart';
import '../domain/recipe_analysis.dart';
class RecipeRepository {
final ApiClient _api;
@@ -174,6 +175,27 @@ class RecipeRepository {
}
}
Future<RecipeAnalysis> fetchRecipeAnalysis(int id,
{String? token}) async {
try {
final data = await _api.getJson(
RecipeApiPaths.analysis(id),
token: token,
);
if (data is! Map<String, dynamic>) {
throw const ApiException(
type: ApiErrorType.unknown, message: 'Ogiltigt svar från servern.');
}
return RecipeAnalysis.fromJson(data);
} on ApiException {
rethrow;
} catch (_) {
throw const ApiException(
type: ApiErrorType.network,
message: 'Kunde inte hämta receptanalys.');
}
}
Future<ParsedRecipe> parseMarkdown(String markdown,
{String? token}) async {
try {
@@ -0,0 +1,139 @@
enum RecipeIngredientAvailabilityStatus {
exactMatch,
coveredByPantry,
substitutable,
missing,
}
class RecipeIngredientAnalysis {
final int ingredientId;
final String rawName;
final double quantity;
final String unit;
final String? note;
final RecipeIngredientAvailabilityStatus status;
final int? matchedProductId;
final String? matchedProductName;
final String? source;
final double availableQuantity;
final double missingQuantity;
const RecipeIngredientAnalysis({
required this.ingredientId,
required this.rawName,
required this.quantity,
required this.unit,
this.note,
required this.status,
this.matchedProductId,
this.matchedProductName,
this.source,
required this.availableQuantity,
required this.missingQuantity,
});
factory RecipeIngredientAnalysis.fromJson(Map<String, dynamic> json) {
final rawStatus = json['status'] as String? ?? 'missing';
final status = switch (rawStatus) {
'exact_match' => RecipeIngredientAvailabilityStatus.exactMatch,
'covered_by_pantry' => RecipeIngredientAvailabilityStatus.coveredByPantry,
'substitutable' => RecipeIngredientAvailabilityStatus.substitutable,
_ => RecipeIngredientAvailabilityStatus.missing,
};
return RecipeIngredientAnalysis(
ingredientId: (json['ingredientId'] as num?)?.toInt() ?? 0,
rawName: (json['rawName'] as String? ?? '').trim(),
quantity: (json['quantity'] as num? ?? 0).toDouble(),
unit: json['unit'] as String? ?? '',
note: json['note'] as String?,
status: status,
matchedProductId: (json['matchedProductId'] as num?)?.toInt(),
matchedProductName: json['matchedProductName'] as String?,
source: json['source'] as String?,
availableQuantity: (json['availableQuantity'] as num? ?? 0).toDouble(),
missingQuantity: (json['missingQuantity'] as num? ?? 0).toDouble(),
);
}
}
class RecipeAnalysisSummary {
final int exactCount;
final int pantryCount;
final int substituteCount;
final int missingCount;
const RecipeAnalysisSummary({
required this.exactCount,
required this.pantryCount,
required this.substituteCount,
required this.missingCount,
});
factory RecipeAnalysisSummary.fromJson(Map<String, dynamic> json) {
return RecipeAnalysisSummary(
exactCount: (json['exactCount'] as num?)?.toInt() ?? 0,
pantryCount: (json['pantryCount'] as num?)?.toInt() ?? 0,
substituteCount: (json['substituteCount'] as num?)?.toInt() ?? 0,
missingCount: (json['missingCount'] as num?)?.toInt() ?? 0,
);
}
}
class RecipeShoppingCandidate {
final int ingredientId;
final String rawName;
final double quantity;
final String unit;
final double missingQuantity;
const RecipeShoppingCandidate({
required this.ingredientId,
required this.rawName,
required this.quantity,
required this.unit,
required this.missingQuantity,
});
factory RecipeShoppingCandidate.fromJson(Map<String, dynamic> json) {
return RecipeShoppingCandidate(
ingredientId: (json['ingredientId'] as num?)?.toInt() ?? 0,
rawName: (json['rawName'] as String? ?? '').trim(),
quantity: (json['quantity'] as num? ?? 0).toDouble(),
unit: json['unit'] as String? ?? '',
missingQuantity: (json['missingQuantity'] as num? ?? 0).toDouble(),
);
}
}
class RecipeAnalysis {
final int recipeId;
final List<RecipeIngredientAnalysis> ingredients;
final RecipeAnalysisSummary summary;
final List<RecipeShoppingCandidate> shoppingListCandidates;
const RecipeAnalysis({
required this.recipeId,
required this.ingredients,
required this.summary,
required this.shoppingListCandidates,
});
factory RecipeAnalysis.fromJson(Map<String, dynamic> json) {
final rawIngredients = json['ingredients'] as List<dynamic>? ?? const [];
final rawShopping = json['shoppingListCandidates'] as List<dynamic>? ?? const [];
return RecipeAnalysis(
recipeId: (json['recipeId'] as num?)?.toInt() ?? 0,
ingredients: rawIngredients
.map((e) => RecipeIngredientAnalysis.fromJson(e as Map<String, dynamic>))
.toList(),
summary: RecipeAnalysisSummary.fromJson(
json['summary'] as Map<String, dynamic>? ?? const {},
),
shoppingListCandidates: rawShopping
.map((e) => RecipeShoppingCandidate.fromJson(e as Map<String, dynamic>))
.toList(),
);
}
}
@@ -4,8 +4,6 @@ import 'package:go_router/go_router.dart';
import '../../../core/api/api_error_mapper.dart';
import '../../../core/api/api_exception.dart';
import '../../../core/api/api_paths.dart';
import '../../../core/api/api_providers.dart';
import '../../../core/utils/formatters.dart';
import '../../../core/l10n/l10n.dart';
import '../../auth/data/auth_providers.dart';
@@ -14,19 +12,6 @@ import '../domain/parsed_recipe.dart';
enum _Step { input, review }
class _ManualIngredient {
int? productId;
final TextEditingController qtyCtrl = TextEditingController();
final TextEditingController unitCtrl = TextEditingController(text: 'g');
final TextEditingController noteCtrl = TextEditingController();
void dispose() {
qtyCtrl.dispose();
unitCtrl.dispose();
noteCtrl.dispose();
}
}
class CreateRecipeScreen extends ConsumerStatefulWidget {
/// Optional markdown to pre-fill the input field, e.g. from import.
final String? initialMarkdown;
@@ -64,20 +49,12 @@ class _CreateRecipeScreenState extends ConsumerState<CreateRecipeScreen> {
late TextEditingController _nameCtrl;
late TextEditingController _servingsCtrl;
late List<bool> _included;
late Map<int, int?> _selectedProductIds;
late Map<int, String?> _selectedProductNames;
late Map<int, TextEditingController> _rawNameControllers;
late Map<int, TextEditingController> _qtyControllers;
late Map<int, TextEditingController> _unitControllers;
late Map<int, TextEditingController> _noteControllers;
// Produktlista för manuellt tillagda ingredienser
List<Map<String, dynamic>> _allProducts = [];
bool _isLoadingProducts = false;
// Manuellt tillagda ingredienser
final List<_ManualIngredient> _manualIngredients = [];
bool _isSaving = false;
String? _saveError;
@@ -87,64 +64,37 @@ class _CreateRecipeScreenState extends ConsumerState<CreateRecipeScreen> {
if (_step == _Step.review) {
_nameCtrl.dispose();
_servingsCtrl.dispose();
for (final c in _rawNameControllers.values) c.dispose();
for (final c in _qtyControllers.values) c.dispose();
for (final c in _unitControllers.values) c.dispose();
for (final c in _noteControllers.values) c.dispose();
for (final m in _manualIngredients) m.dispose();
}
super.dispose();
}
Future<void> _loadProducts() async {
setState(() => _isLoadingProducts = true);
try {
final token = await ref.read(authStateProvider.future);
final api = ref.read(apiClientProvider);
final data = await api.getJson(ProductApiPaths.list, token: token);
if (!mounted) return;
final products = (data as List<dynamic>)
.map((e) => e as Map<String, dynamic>)
.toList()
..sort((a, b) {
final aName = (a['canonicalName'] ?? a['name'] ?? '').toString();
final bName = (b['canonicalName'] ?? b['name'] ?? '').toString();
return aName.toLowerCase().compareTo(bName.toLowerCase());
});
setState(() {
_allProducts = products;
_isLoadingProducts = false;
});
} catch (_) {
if (!mounted) return;
setState(() => _isLoadingProducts = false);
}
}
void _initReviewState(ParsedRecipe parsed) {
_nameCtrl = TextEditingController(text: parsed.name);
_servingsCtrl = TextEditingController();
_included = List.generate(parsed.ingredients.length, (_) => true);
_selectedProductIds = {};
_selectedProductNames = {};
_rawNameControllers = {};
_qtyControllers = {};
_unitControllers = {};
_noteControllers = {};
for (var i = 0; i < parsed.ingredients.length; i++) {
final ing = parsed.ingredients[i];
_rawNameControllers[i] = TextEditingController(text: ing.rawName);
_qtyControllers[i] = TextEditingController(
text: ing.quantity > 0 ? formatQuantity(ing.quantity) : '',
);
_unitControllers[i] = TextEditingController(text: ing.unit);
_noteControllers[i] = TextEditingController(text: ing.note ?? '');
if (ing.suggestions.isNotEmpty) {
_selectedProductIds[i] = ing.suggestions.first.productId;
_selectedProductNames[i] = ing.suggestions.first.productName;
} else {
_selectedProductIds[i] = null;
_selectedProductNames[i] = null;
}
}
_loadProducts();
}
String _formatIngredientName(String value) {
final trimmed = value.trim();
if (trimmed.isEmpty) return trimmed;
return '${trimmed[0].toUpperCase()}${trimmed.substring(1)}';
}
Future<void> _parseMarkdown() async {
@@ -190,7 +140,10 @@ class _CreateRecipeScreenState extends ConsumerState<CreateRecipeScreen> {
final ingredients = <Map<String, dynamic>>[];
for (var i = 0; i < _parsed!.ingredients.length; i++) {
if (!_included[i]) continue;
final productId = _selectedProductIds[i];
final rawName = _formatIngredientName(_rawNameControllers[i]!.text);
if (rawName.isEmpty) {
continue;
}
final qty = double.tryParse(
_qtyControllers[i]!.text.trim().replaceAll(',', '.'),
) ??
@@ -198,40 +151,18 @@ class _CreateRecipeScreenState extends ConsumerState<CreateRecipeScreen> {
final unit = _unitControllers[i]!.text.trim();
final note = _noteControllers[i]!.text.trim();
final ing = _parsed!.ingredients[i];
// Alternativa produkter: alla suggestions vars productId matchar ett alternativ
final alternativeProductIds = ing.alternatives.length > 1
? ing.suggestions
.where((s) => s.productId != productId)
.map((s) => s.productId)
.toList()
: <int>[];
ingredients.add({
'rawName': ing.rawName,
'rawName': rawName,
if ((ing.rawLine ?? '').trim().isNotEmpty) 'rawLine': ing.rawLine,
if (productId != null) 'productId': productId,
if (qty > 0) 'quantity': qty,
if (unit.isNotEmpty) 'unit': unit,
if (note.isNotEmpty) 'note': note,
if (alternativeProductIds.isNotEmpty)
'alternativeProductIds': alternativeProductIds,
});
}
// Inkludera manuellt tillagda ingredienser
for (final manual in _manualIngredients) {
if (manual.productId == null) continue;
final qty = double.tryParse(
manual.qtyCtrl.text.trim().replaceAll(',', '.'),
);
if (qty == null) continue;
final unit = manual.unitCtrl.text.trim();
if (unit.isEmpty) continue;
final note = manual.noteCtrl.text.trim();
ingredients.add({
'productId': manual.productId,
'quantity': qty,
'unit': unit,
if (note.isNotEmpty) 'note': note,
});
if (ingredients.isEmpty) {
setState(() => _saveError = 'Lägg till minst en ingrediensrad.');
return;
}
setState(() {
@@ -370,27 +301,6 @@ class _CreateRecipeScreenState extends ConsumerState<CreateRecipeScreen> {
parsed.ingredients.length,
(i) => _buildIngredientRow(i, parsed.ingredients[i])),
],
// Manuellt tillagda ingredienser
if (_manualIngredients.isNotEmpty) ...[
const SizedBox(height: 8),
...List.generate(
_manualIngredients.length,
(i) => _buildManualIngredientCard(i),
),
],
const SizedBox(height: 12),
// Knapp för att lägga till ingrediens
if (_isLoadingProducts)
const Padding(
padding: EdgeInsets.symmetric(vertical: 4),
child: LinearProgressIndicator(),
)
else
OutlinedButton.icon(
onPressed: _addManualIngredient,
icon: const Icon(Icons.add),
label: const Text('Lägg till ingrediens'),
),
const SizedBox(height: 8),
],
),
@@ -423,124 +333,11 @@ class _CreateRecipeScreenState extends ConsumerState<CreateRecipeScreen> {
);
}
void _addManualIngredient() {
setState(() {
_manualIngredients.add(_ManualIngredient());
});
}
void _removeManualIngredient(int index) {
setState(() {
_manualIngredients[index].dispose();
_manualIngredients.removeAt(index);
});
}
Widget _buildManualIngredientCard(int index) {
final manual = _manualIngredients[index];
return Card(
margin: const EdgeInsets.symmetric(vertical: 4),
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
'Tillagd ingrediens',
style: Theme.of(context).textTheme.titleSmall,
),
),
IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: () => _removeManualIngredient(index),
tooltip: 'Ta bort',
),
],
),
DropdownButtonFormField<int>(
value: manual.productId,
isExpanded: true,
decoration: const InputDecoration(
labelText: 'Produkt *',
border: OutlineInputBorder(),
isDense: true,
),
hint: const Text('Välj produkt'),
items: _allProducts
.map((p) => DropdownMenuItem<int>(
value: p['id'] as int,
child: Text(
((p['canonicalName'] ?? p['name']) as Object).toString(),
overflow: TextOverflow.ellipsis,
),
))
.toList(),
onChanged: (value) {
if (value == null) return;
setState(() => manual.productId = value);
},
),
const SizedBox(height: 8),
Row(
children: [
SizedBox(
width: 72,
child: TextField(
controller: manual.qtyCtrl,
decoration: const InputDecoration(
labelText: 'Mängd',
isDense: true,
border: OutlineInputBorder(),
contentPadding:
EdgeInsets.symmetric(horizontal: 8, vertical: 8),
),
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
),
),
const SizedBox(width: 8),
SizedBox(
width: 72,
child: TextField(
controller: manual.unitCtrl,
decoration: const InputDecoration(
labelText: 'Enhet',
isDense: true,
border: OutlineInputBorder(),
contentPadding:
EdgeInsets.symmetric(horizontal: 8, vertical: 8),
),
),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: manual.noteCtrl,
decoration: const InputDecoration(
labelText: 'Not',
isDense: true,
border: OutlineInputBorder(),
contentPadding:
EdgeInsets.symmetric(horizontal: 8, vertical: 8),
),
),
),
],
),
],
),
),
);
}
Widget _buildIngredientRow(int index, ParsedIngredient ing) {
final isIncluded = _included[index];
final noProductFound = ing.suggestions.isEmpty;
// Problem #2: tydlig varning om rad är inkluderad men saknar produkt
final showMissingProductWarning = isIncluded && noProductFound;
final suggestionText = ing.suggestions.isEmpty
? null
: 'Förslag: ${ing.suggestions.take(3).map((s) => s.productName).join(', ')}';
return Card(
margin: const EdgeInsets.symmetric(vertical: 4),
@@ -562,86 +359,78 @@ class _CreateRecipeScreenState extends ConsumerState<CreateRecipeScreen> {
))
.toList(),
)
: Text(ing.rawName),
subtitle: noProductFound
? Text(
context.l10n.recipeCreateNoProductFound,
: Text(_formatIngredientName(ing.rawName)),
subtitle: suggestionText == null
? null
: Text(
suggestionText,
style: TextStyle(
color: showMissingProductWarning
? Theme.of(context).colorScheme.error
: Theme.of(context).colorScheme.onSurfaceVariant,
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 12,
),
)
: DropdownButton<int>(
value: _selectedProductIds[index],
isExpanded: true,
onChanged: isIncluded
? (id) {
if (id == null) return;
setState(() {
_selectedProductIds[index] = id;
_selectedProductNames[index] = ing.suggestions
.firstWhere((s) => s.productId == id)
.productName;
});
}
: null,
items: ing.suggestions
.map((s) => DropdownMenuItem(
value: s.productId,
child: Text(s.productName),
))
.toList(),
),
),
// Problem #1: editerbara qty/unit/note-fält per ingrediens
if (isIncluded)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
child: Column(
children: [
SizedBox(
width: 72,
child: TextField(
controller: _qtyControllers[index],
decoration: const InputDecoration(
labelText: 'Mängd',
isDense: true,
border: OutlineInputBorder(),
contentPadding:
EdgeInsets.symmetric(horizontal: 8, vertical: 8),
),
keyboardType: const TextInputType.numberWithOptions(
decimal: true),
TextField(
controller: _rawNameControllers[index],
decoration: const InputDecoration(
labelText: 'Ingrediens',
isDense: true,
border: OutlineInputBorder(),
contentPadding:
EdgeInsets.symmetric(horizontal: 8, vertical: 8),
),
),
const SizedBox(width: 8),
SizedBox(
width: 72,
child: TextField(
controller: _unitControllers[index],
decoration: const InputDecoration(
labelText: 'Enhet',
isDense: true,
border: OutlineInputBorder(),
contentPadding:
EdgeInsets.symmetric(horizontal: 8, vertical: 8),
const SizedBox(height: 8),
Row(
children: [
SizedBox(
width: 72,
child: TextField(
controller: _qtyControllers[index],
decoration: const InputDecoration(
labelText: 'Mängd',
isDense: true,
border: OutlineInputBorder(),
contentPadding:
EdgeInsets.symmetric(horizontal: 8, vertical: 8),
),
keyboardType: const TextInputType.numberWithOptions(
decimal: true),
),
),
),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _noteControllers[index],
decoration: const InputDecoration(
labelText: 'Not',
isDense: true,
border: OutlineInputBorder(),
contentPadding:
EdgeInsets.symmetric(horizontal: 8, vertical: 8),
const SizedBox(width: 8),
SizedBox(
width: 72,
child: TextField(
controller: _unitControllers[index],
decoration: const InputDecoration(
labelText: 'Enhet',
isDense: true,
border: OutlineInputBorder(),
contentPadding:
EdgeInsets.symmetric(horizontal: 8, vertical: 8),
),
),
),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _noteControllers[index],
decoration: const InputDecoration(
labelText: 'Not',
isDense: true,
border: OutlineInputBorder(),
contentPadding:
EdgeInsets.symmetric(horizontal: 8, vertical: 8),
),
),
),
],
),
],
),
@@ -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),
@@ -152,7 +152,7 @@ class _RecipeImageCard extends StatelessWidget {
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.45),
color: Colors.black.withValues(alpha: 0.45),
borderRadius: BorderRadius.circular(10),
),
child: Text(