feat: add rematch functionality for recipe ingredients and enhance inventory management
Test Suite / test (24.15.0) (push) Has been cancelled

- Added a new API path for rematching recipe ingredients in `api_paths.dart`.
- Implemented a manual product creation dialog in `inventory_screen.dart` to allow users to create new products directly.
- Integrated the rematch functionality in `recipe_repository.dart` to handle rematching of recipe ingredients.
- Updated the recipe detail screen to include a button for triggering the rematch process.
- Introduced a new `RecipeMatchingService` in the backend to handle ingredient matching logic.
- Added database migration to include `aiEngineEnabled` column in the User table.

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Nils-Johan Gynther
2026-05-06 09:20:31 +02:00
parent 9fe85a719c
commit 04b1fc3024
53 changed files with 1420 additions and 652 deletions
@@ -3,8 +3,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/api/api_error_mapper.dart';
import '../../../core/api/api_paths.dart';
import '../../../core/api/api_providers.dart';
import '../../../core/l10n/l10n.dart';
import '../../../core/ui/async_state_views.dart';
import '../../auth/data/auth_providers.dart';
import '../data/inventory_providers.dart';
import 'swipeable_inventory_tile.dart';
@@ -19,6 +22,65 @@ class InventoryScreen extends ConsumerWidget {
(value: 'bestBeforeDesc', label: context.l10n.inventorySortBestBeforeDesc),
];
Future<void> _createManualProduct(BuildContext context, WidgetRef ref) async {
final nameCtrl = TextEditingController();
final created = await showDialog<String>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Ny produkt'),
content: TextField(
controller: nameCtrl,
autofocus: true,
decoration: const InputDecoration(
labelText: 'Produktnamn',
border: OutlineInputBorder(),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: Text(context.l10n.cancelAction),
),
FilledButton(
onPressed: () {
final value = nameCtrl.text.trim();
if (value.isEmpty) return;
Navigator.pop(dialogContext, value);
},
child: const Text('Skapa'),
),
],
),
);
nameCtrl.dispose();
if (created == null || created.trim().isEmpty || !context.mounted) return;
try {
final token = ref.read(authStateProvider).maybeWhen(
data: (t) => t,
orElse: () => null,
) ??
await ref.read(authStateProvider.future);
final api = ref.read(apiClientProvider);
await api.postJson(
ProductApiPaths.createPrivate,
body: {'name': created.trim()},
token: token,
);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Produkt skapad. Lägg nu till den i inventariet.')),
);
context.push('/inventory/create');
} catch (e) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)),
);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final location = ref.watch(inventoryLocationFilterProvider);
@@ -96,10 +158,21 @@ class InventoryScreen extends ConsumerWidget {
Positioned(
right: 16,
bottom: 16,
child: FloatingActionButton.extended(
onPressed: () => context.push('/inventory/create'),
icon: const Icon(Icons.add),
label: Text(context.l10n.addAction),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
FloatingActionButton.extended(
onPressed: () => context.push('/inventory/create'),
icon: const Icon(Icons.add),
label: Text(context.l10n.addAction),
),
const SizedBox(height: 8),
FloatingActionButton.extended(
onPressed: () => _createManualProduct(context, ref),
icon: const Icon(Icons.add_box_outlined),
label: const Text('Ny produkt'),
),
],
),
),
],
@@ -129,6 +202,12 @@ class InventoryScreen extends ConsumerWidget {
label: Text(context.l10n.addAction),
),
const SizedBox(height: 8),
FloatingActionButton.extended(
onPressed: () => _createManualProduct(context, ref),
icon: const Icon(Icons.add_box_outlined),
label: const Text('Ny produkt'),
),
const SizedBox(height: 8),
FloatingActionButton.extended(
onPressed: () => context.go('/recipes'),
icon: const Icon(Icons.restaurant_menu),
@@ -196,6 +196,28 @@ class RecipeRepository {
}
}
Future<RecipeAnalysis> rematchRecipeIngredients(int id,
{String? token}) async {
try {
final data = await _api.postJson(
RecipeApiPaths.rematch(id),
body: const {},
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 köra om matchning.');
}
}
Future<ParsedRecipe> parseMarkdown(String markdown,
{String? token}) async {
try {
@@ -521,6 +521,29 @@ class _InventoryPreviewSectionState
extends ConsumerState<_InventoryPreviewSection> {
bool _loaded = false;
Future<void> _runRematch() async {
try {
final token = ref.read(authStateProvider).maybeWhen(
data: (t) => t,
orElse: () => null,
) ??
await ref.read(authStateProvider.future);
await ref
.read(recipeRepositoryProvider)
.rematchRecipeIngredients(widget.recipeId, token: token);
ref.invalidate(recipeAnalysisProvider(widget.recipeId));
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Matchning uppdaterad')),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)),
);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
@@ -538,6 +561,12 @@ class _InventoryPreviewSectionState
icon: const Icon(Icons.search, size: 16),
label: const Text('Kontrollera inventarie'),
),
if (_loaded)
IconButton(
tooltip: 'Kör om matchning',
icon: const Icon(Icons.auto_fix_high),
onPressed: _runRematch,
),
if (_loaded)
IconButton(
tooltip: 'Uppdatera',