feat: implement AI recipe suggestions; add endpoint and UI for generating suggestions based on inventory
Test Suite / test (24.15.0) (push) Has been cancelled

This commit is contained in:
Nils-Johan Gynther
2026-05-05 14:15:28 +02:00
parent 3ea5a4778f
commit ce20b1dd07
9 changed files with 471 additions and 19 deletions
@@ -10,6 +10,7 @@ class IngredientPreview {
final double availableQuantity;
final IngredientStatus status;
final double missingQuantity;
final bool fromPantry;
const IngredientPreview({
required this.ingredientId,
@@ -21,6 +22,7 @@ class IngredientPreview {
required this.availableQuantity,
required this.status,
required this.missingQuantity,
this.fromPantry = false,
});
factory IngredientPreview.fromJson(Map<String, dynamic> json) {
@@ -40,6 +42,7 @@ class IngredientPreview {
availableQuantity: (json['availableQuantity'] as num? ?? 0).toDouble(),
status: status,
missingQuantity: (json['missingQuantity'] as num? ?? 0).toDouble(),
fromPantry: json['fromPantry'] as bool? ?? false,
);
}
}
@@ -50,6 +53,7 @@ class PreviewSummary {
final int missingCount;
final int unitMismatchCount;
final bool canCookExactly;
final int pantryCount;
const PreviewSummary({
required this.totalIngredients,
@@ -57,6 +61,7 @@ class PreviewSummary {
required this.missingCount,
required this.unitMismatchCount,
required this.canCookExactly,
this.pantryCount = 0,
});
factory PreviewSummary.fromJson(Map<String, dynamic> json) {
@@ -66,6 +71,7 @@ class PreviewSummary {
missingCount: json['missingCount'] as int,
unitMismatchCount: json['unitMismatchCount'] as int,
canCookExactly: json['canCookExactly'] as bool? ?? false,
pantryCount: json['pantryCount'] as int? ?? 0,
);
}
}
@@ -0,0 +1,267 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/api/api_paths.dart';
import '../../../core/api/api_providers.dart';
import '../../auth/data/auth_providers.dart';
class _AiSuggestion {
final String name;
final String description;
final List<String> mainIngredients;
final List<String> missingIngredients;
final String estimatedTime;
const _AiSuggestion({
required this.name,
required this.description,
required this.mainIngredients,
required this.missingIngredients,
required this.estimatedTime,
});
factory _AiSuggestion.fromJson(Map<String, dynamic> json) {
return _AiSuggestion(
name: json['name'] as String? ?? '',
description: json['description'] as String? ?? '',
mainIngredients: (json['mainIngredients'] as List<dynamic>?)
?.map((e) => e.toString())
.toList() ??
[],
missingIngredients: (json['missingIngredients'] as List<dynamic>?)
?.map((e) => e.toString())
.toList() ??
[],
estimatedTime: json['estimatedTime'] as String? ?? '',
);
}
}
class AiRecipeSuggestionsScreen extends ConsumerStatefulWidget {
const AiRecipeSuggestionsScreen({super.key});
@override
ConsumerState<AiRecipeSuggestionsScreen> createState() =>
_AiRecipeSuggestionsScreenState();
}
class _AiRecipeSuggestionsScreenState
extends ConsumerState<AiRecipeSuggestionsScreen> {
bool _isLoading = false;
String? _error;
List<_AiSuggestion> _suggestions = [];
bool _hasFetched = false;
Future<void> _generateSuggestions() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final token = await ref.read(authStateProvider.future);
final api = ref.read(apiClientProvider);
final data = await api.getJson(RecipeApiPaths.aiSuggestions, token: token);
if (!mounted) return;
final raw = data as Map<String, dynamic>;
final list = (raw['suggestions'] as List<dynamic>?) ?? [];
setState(() {
_suggestions =
list.map((e) => _AiSuggestion.fromJson(e as Map<String, dynamic>)).toList();
_hasFetched = true;
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = 'Kunde inte hämta förslag. Försök igen.';
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final cs = theme.colorScheme;
return Scaffold(
appBar: AppBar(
title: const Text('AI-receptförslag'),
leading: BackButton(onPressed: () => context.pop()),
),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Vad kan jag laga?',
style: theme.textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
'Baserat på vad du har i ditt lager och skafferi föreslår AI vad du kan laga.',
style: theme.textTheme.bodyMedium
?.copyWith(color: cs.onSurfaceVariant),
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: _isLoading ? null : _generateSuggestions,
icon: _isLoading
? SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: cs.onPrimary,
),
)
: const Icon(Icons.auto_awesome),
label: Text(_isLoading ? 'Genererar förslag...' : 'Generera förslag'),
),
if (_error != null) ...[
const SizedBox(height: 12),
Text(
_error!,
style: theme.textTheme.bodySmall?.copyWith(color: cs.error),
),
],
const SizedBox(height: 16),
if (_hasFetched && _suggestions.isEmpty && !_isLoading)
Center(
child: Text(
'Inga förslag hittades. Lägg till fler varor i ditt lager.',
style: theme.textTheme.bodyMedium
?.copyWith(color: cs.onSurfaceVariant),
textAlign: TextAlign.center,
),
)
else
Expanded(
child: ListView.separated(
itemCount: _suggestions.length,
separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (context, index) {
final s = _suggestions[index];
return _SuggestionCard(
suggestion: s,
onCreateRecipe: () => context.push(
'/recipes/create',
extra: {'markdown': '# ${s.name}\n\n${s.description}'},
),
);
},
),
),
],
),
),
);
}
}
class _SuggestionCard extends StatelessWidget {
final _AiSuggestion suggestion;
final VoidCallback onCreateRecipe;
const _SuggestionCard({
required this.suggestion,
required this.onCreateRecipe,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final cs = theme.colorScheme;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
suggestion.name,
style: theme.textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
),
),
if (suggestion.estimatedTime.isNotEmpty)
Chip(
label: Text(suggestion.estimatedTime),
avatar: const Icon(Icons.timer_outlined, size: 16),
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
),
],
),
const SizedBox(height: 8),
Text(
suggestion.description,
style: theme.textTheme.bodyMedium,
),
if (suggestion.mainIngredients.isNotEmpty) ...[
const SizedBox(height: 12),
Text(
'Ingredienser du har:',
style: theme.textTheme.labelMedium
?.copyWith(color: cs.primary),
),
const SizedBox(height: 4),
Wrap(
spacing: 6,
runSpacing: 4,
children: suggestion.mainIngredients
.map((ing) => Chip(
label: Text(ing),
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
backgroundColor: cs.primaryContainer,
labelStyle: TextStyle(color: cs.onPrimaryContainer),
))
.toList(),
),
],
if (suggestion.missingIngredients.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
'Kan behövas:',
style: theme.textTheme.labelMedium
?.copyWith(color: cs.error),
),
const SizedBox(height: 4),
Wrap(
spacing: 6,
runSpacing: 4,
children: suggestion.missingIngredients
.map((ing) => Chip(
label: Text(ing),
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
backgroundColor: cs.errorContainer,
labelStyle: TextStyle(color: cs.onErrorContainer),
))
.toList(),
),
],
const SizedBox(height: 12),
Align(
alignment: Alignment.centerRight,
child: OutlinedButton.icon(
onPressed: onCreateRecipe,
icon: const Icon(Icons.add, size: 18),
label: const Text('Skapa recept'),
),
),
],
),
),
);
}
}
@@ -643,29 +643,33 @@ class _IngredientPreviewRow extends StatelessWidget {
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),
};
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 requiredStr =
'${_fmtQty(ingredient.requiredQuantity)} ${ingredient.requiredUnit}'.trim();
final availableStr =
'${_fmtQty(ingredient.availableQuantity)} ${ingredient.requiredUnit}'.trim();
final subtitle = 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 = 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',
};
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
@@ -92,11 +92,22 @@ class RecipesScreen extends ConsumerWidget {
}
},
),
Positioned(
right: 16,
bottom: 80,
child: FloatingActionButton.small(
tooltip: 'AI-receptförslag',
heroTag: 'ai_suggestions',
onPressed: () => context.push('/recipes/ai-suggestions'),
child: const Icon(Icons.auto_awesome),
),
),
Positioned(
right: 16,
bottom: 16,
child: FloatingActionButton(
tooltip: context.l10n.recipesNewTooltip,
heroTag: 'new_recipe',
onPressed: () => context.push('/recipes/create'),
child: const Icon(Icons.add),
),