Files
recipe-app/flutter/lib/features/inventory/presentation/inventory_screen.dart
T
Nils-Johan Gynther 04b1fc3024
Test Suite / test (24.15.0) (push) Has been cancelled
feat: add rematch functionality for recipe ingredients and enhance inventory management
- 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>
2026-05-06 09:20:31 +02:00

227 lines
8.2 KiB
Dart

import 'package:flutter/material.dart';
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';
class InventoryScreen extends ConsumerWidget {
const InventoryScreen({super.key});
static const _locationOptions = <String>['', 'Kyl', 'Frys', 'Skafferi'];
List<({String value, String label})> _sortOptions(BuildContext context) => [
(value: '', label: context.l10n.inventorySortLatest),
(value: 'nameAsc', label: context.l10n.inventorySortNameAsc),
(value: 'bestBeforeAsc', label: context.l10n.inventorySortBestBeforeAsc),
(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);
final sort = ref.watch(inventorySortFilterProvider);
final inventoryAsync = ref.watch(inventoryProvider);
return inventoryAsync.when(
loading: () => LoadingStateView(label: context.l10n.inventoryLoading),
error: (e, _) => ErrorStateView(
message: mapErrorToUserMessage(e, context),
onRetry: () => ref.invalidate(inventoryProvider),
),
data: (items) {
final filterSection = Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.inventoryFilterAndSort,
style: const TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: _locationOptions
.map(
(option) => ChoiceChip(
label: Text(option.isEmpty ? context.l10n.inventoryAllFilter : option),
selected: location == option,
onSelected: (_) => ref
.read(inventoryLocationFilterProvider.notifier)
.setValue(option),
),
)
.toList(),
),
const SizedBox(height: 8),
DropdownButtonFormField<String>(
initialValue: sort,
isExpanded: true,
decoration: InputDecoration(
labelText: context.l10n.inventorySortLabel,
border: const OutlineInputBorder(),
),
items: _sortOptions(context)
.map(
(option) => DropdownMenuItem<String>(
value: option.value,
child: Text(option.label),
),
)
.toList(),
onChanged: (value) {
ref
.read(inventorySortFilterProvider.notifier)
.setValue(value ?? '');
},
),
],
),
);
if (items.isEmpty) {
return Stack(
children: [
ListView(
padding: const EdgeInsets.only(bottom: 88),
children: [
filterSection,
EmptyStateView(title: context.l10n.inventoryEmpty),
],
),
Positioned(
right: 16,
bottom: 16,
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'),
),
],
),
),
],
);
}
return Stack(
children: [
ListView.separated(
padding: const EdgeInsets.only(bottom: 88),
itemCount: items.length + 1,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) {
if (index == 0) return filterSection;
final item = items[index - 1];
return SwipeableInventoryTile(item: item);
},
),
Positioned(
right: 16,
bottom: 16,
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'),
),
const SizedBox(height: 8),
FloatingActionButton.extended(
onPressed: () => context.go('/recipes'),
icon: const Icon(Icons.restaurant_menu),
label: Text(context.l10n.inventoryRecipesAction),
),
],
),
),
],
);
},
);
}
}