feat: add rematch functionality for recipe ingredients and enhance inventory management
Test Suite / test (24.15.0) (push) Has been cancelled
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:
@@ -46,6 +46,7 @@ class RecipeApiPaths {
|
||||
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 String rematch(int id) => '/recipes/$id/rematch';
|
||||
static const parseMarkdown = '/recipes/parse-markdown';
|
||||
static const aiSuggestions = '/recipes/ai-suggestions';
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user