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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user