Files
recipe-app/flutter/lib/features/inventory/presentation/inventory_screen.dart
T
Nils-Johan Gynther a1a2c33427
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 5m8s
Test Suite / flutter-quality (push) Failing after 1m41s
feat(shopping-list): add shopping list feature with flyer integration
This commit introduces a comprehensive shopping list feature with the following key changes:

Backend:
- Added ShoppingListItem model with relations to User, Product, and Category
- Added new fields to FlyerSession for source file metadata
- Added categoryId field to FlyerItem model
- Implemented session source file retrieval endpoint
- Added endpoint for updating flyer session items with category assignment
- Added endpoint for planning flyer selections to shopping list
- Implemented backfillCategoriesMine for AI-assisted category assignment
- Added ShoppingListModule and integrated with FlyerSelectionModule

Frontend:
- Added ShoppingListScreen and navigation route
- Implemented API paths and client methods for shopping list operations
- Added category tree loading for shopping list item creation
- Integrated shopping list functionality in flyer import tab
- Added category backfill trigger in inventory screen
- Updated FlyerImportItem model with categoryId support
- Added methods for updating flyer session items and retrieving source files

Database:
- Added new Prisma migration for flyer source metadata and shopping list items
- Updated schema with new relations and indexes

The shopping list feature allows users to:
1. Plan flyer selections directly to their shopping list
2. View and manage their shopping list items
3. Update flyer session items with proper categorization
4. Retrieve original flyer source files
5. Automatically backfill categories for uncategorized products
2026-05-20 09:07:30 +02:00

555 lines
19 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 '../../../core/utils/display_labels.dart';
import '../../auth/data/auth_providers.dart';
import '../../pantry/data/pantry_providers.dart';
import '../domain/inventory_item.dart';
import '../data/inventory_providers.dart';
import 'swipeable_inventory_tile.dart';
class InventoryScreen extends ConsumerStatefulWidget {
const InventoryScreen({super.key});
@override
ConsumerState<InventoryScreen> createState() => _InventoryScreenState();
}
class _InventoryScreenState extends ConsumerState<InventoryScreen> {
final Set<int> _selectedIds = <int>{};
static const _sortByDisplayedCategory = 'l1CategoryAsc';
bool _backfillTriggered = false;
@override
void initState() {
super.initState();
_triggerCategoryBackfill();
}
Future<void> _triggerCategoryBackfill() async {
if (_backfillTriggered) return;
_backfillTriggered = true;
try {
final token = await ref.read(authStateProvider.future);
final api = ref.read(apiClientProvider);
await api.postJson(ProductApiPaths.backfillMineCategories, token: token);
ref.invalidate(inventoryProvider);
ref.invalidate(pantryProvider);
} catch (_) {
// Ignorera fel här för att inte blockera vyn.
}
}
static const _locationOptions = <String>['', 'Kyl', 'Frys', 'Skafferi'];
static const _weightOrVolumeUnits = <String>{
'g', 'gram', 'mg', 'milligram', 'hg', 'hektogram', 'kg', 'kilo', 'kilogram',
'ml', 'milliliter', 'cl', 'centiliter', 'dl', 'deciliter', 'l', 'liter',
};
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),
(value: _sortByDisplayedCategory, label: 'Kategori (A-O)'),
];
void _startSelection(int id) {
setState(() {
_selectedIds.add(id);
});
}
void _toggleSelection(int id) {
setState(() {
if (_selectedIds.contains(id)) {
_selectedIds.remove(id);
} else {
_selectedIds.add(id);
}
});
}
void _clearSelection() {
setState(() {
_selectedIds.clear();
});
}
void _selectAllVisible(List<InventoryItem> visibleItems) {
setState(() {
_selectedIds
..clear()
..addAll(visibleItems.map<int>((item) => item.id));
});
}
bool _isWeightOrVolumeUnit(String unit) {
return _weightOrVolumeUnits.contains(unit.trim().toLowerCase());
}
String _preferredUnit(List<String> units) {
for (final unit in units) {
if (_isWeightOrVolumeUnit(unit)) return unit;
}
return units.first;
}
Future<String?> _askMergeTargetUnit(BuildContext context, List<String> units) async {
if (units.isEmpty) return null;
if (units.length == 1) return units.first;
var selected = _preferredUnit(units);
return showDialog<String>(
context: context,
builder: (dialogContext) => StatefulBuilder(
builder: (dialogContext, setDialogState) => AlertDialog(
title: const Text('Välj enhet för merge'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Poster har olika enhet. Välj vilken enhet den sammanslagna posten ska använda.',
),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
initialValue: selected,
decoration: const InputDecoration(
labelText: 'Enhet',
border: OutlineInputBorder(),
),
items: units
.map(
(unit) => DropdownMenuItem<String>(
value: unit,
child: Text(unit),
),
)
.toList(),
onChanged: (value) {
if (value == null) return;
setDialogState(() => selected = value);
},
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: Text(context.l10n.cancelAction),
),
FilledButton(
onPressed: () => Navigator.pop(dialogContext, selected),
child: const Text('Merge'),
),
],
),
),
);
}
Future<void> _mergeSelected(BuildContext context, List<InventoryItem> allItems) async {
final selectedItems = allItems
.where((item) => _selectedIds.contains(item.id))
.toList();
if (selectedItems.length < 2) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Välj minst två poster för merge.')),
);
return;
}
final productIds = selectedItems.map((item) => item.productId).toSet();
if (productIds.length > 1) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Valda poster måste ha samma produkt för merge.')),
);
return;
}
final units = <String>[];
final seenUnits = <String>{};
for (final item in selectedItems) {
final unit = item.unit.trim();
final key = unit.toLowerCase();
if (seenUnits.add(key)) {
units.add(unit);
}
}
final targetUnit = await _askMergeTargetUnit(context, units);
if (!context.mounted || targetUnit == null) return;
try {
final token = await ref.read(authStateProvider.future);
final ids = selectedItems.map<int>((item) => item.id).toList();
await ref.read(inventoryRepositoryProvider).mergeInventoryItems(
ids,
targetUnit: units.length > 1 ? targetUnit : null,
token: token,
);
ref.invalidate(inventoryProvider);
if (!mounted) return;
setState(() => _selectedIds.clear());
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Poster sammanslagna.')),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)),
);
}
}
Future<void> _deleteSelected(BuildContext context, List<InventoryItem> allItems) async {
final ids = allItems
.where((item) => _selectedIds.contains(item.id))
.map<int>((item) => item.id)
.toList();
if (ids.isEmpty) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Ta bort markerade poster'),
content: Text('Vill du verkligen ta bort ${ids.length} markerade poster?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: Text(context.l10n.cancelAction),
),
FilledButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: Text(context.l10n.deleteAction),
),
],
),
);
if (confirmed != true || !context.mounted) return;
try {
final token = await ref.read(authStateProvider.future);
await ref.read(inventoryRepositoryProvider).bulkDeleteInventoryItems(ids, token: token);
ref.invalidate(inventoryProvider);
if (!mounted) return;
setState(() => _selectedIds.clear());
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${ids.length} poster borttagna.')),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)),
);
}
}
Future<void> _openBulkActions(BuildContext context, List<InventoryItem> allItems) async {
if (_selectedIds.isEmpty) return;
final action = await showDialog<String>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Hantera markerade poster'),
content: Text('Du har markerat ${_selectedIds.length} poster. Välj åtgärd.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: Text(context.l10n.cancelAction),
),
OutlinedButton(
onPressed: () => Navigator.pop(dialogContext, 'delete'),
child: Text(context.l10n.deleteAction),
),
FilledButton(
onPressed: () => Navigator.pop(dialogContext, 'merge'),
child: const Text('Merge'),
),
],
),
);
if (action == 'merge') {
await _mergeSelected(context, allItems);
} else if (action == 'delete') {
await _deleteSelected(context, allItems);
}
}
List<InventoryItem> _sortedVisibleItems(List<InventoryItem> items, String sort) {
final visibleItems = [...items];
if (sort == _sortByDisplayedCategory) {
final displayedCategoryById = {
for (final item in visibleItems)
item.id: categoryChipText(
categoryPath: item.categoryPath,
fallbackL1: item.l1Category,
).label.toLowerCase(),
};
visibleItems.sort((a, b) {
final aCategory = displayedCategoryById[a.id] ?? '';
final bCategory = displayedCategoryById[b.id] ?? '';
final byCategory = aCategory.compareTo(bCategory);
if (byCategory != 0) return byCategory;
return a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase());
});
}
return visibleItems;
}
Widget _buildFilterSection(BuildContext context, String location, String sort) {
return 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 ?? '');
},
),
],
),
);
}
Widget _buildHeaderSection(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 4),
child: Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(context.l10n.profileInventoryTab, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
Text(
'Din personliga inventarie. Här ser du sådant du faktiskt äger, kan sortera på plats och bäst före, och flytta vidare till recept eller baslager.',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 8),
const Wrap(
spacing: 8,
runSpacing: 8,
children: [
Chip(label: Text('User-scope')),
Chip(label: Text('Bäst före')),
Chip(label: Text('Swipa för +/-')),
],
),
],
),
),
),
);
}
Widget _buildSelectedSection(
BuildContext context,
List<InventoryItem> visibleItems,
List<InventoryItem> allItems,
) {
return Padding(
padding: const EdgeInsets.fromLTRB(12, 4, 12, 4),
child: Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${_selectedIds.length} markerade poster',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
FilledButton.icon(
onPressed: () => _openBulkActions(context, allItems),
icon: const Icon(Icons.playlist_add_check),
label: const Text('Hantera markerade'),
),
OutlinedButton.icon(
onPressed: () => _selectAllVisible(visibleItems),
icon: const Icon(Icons.select_all),
label: const Text('Markera alla synliga'),
),
OutlinedButton.icon(
onPressed: _clearSelection,
icon: const Icon(Icons.deselect),
label: const Text('Avmarkera'),
),
],
),
],
),
),
),
);
}
Widget _buildEmptyState(BuildContext context) {
return EmptyStateView(title: context.l10n.inventoryEmpty);
}
@override
Widget build(BuildContext context) {
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 itemIds = items.map((item) => item.id).toSet();
final staleIds = _selectedIds.where((id) => !itemIds.contains(id)).toList();
if (staleIds.isNotEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
setState(() {
_selectedIds.removeWhere((id) => !itemIds.contains(id));
});
});
}
final visibleItems = _sortedVisibleItems(items, sort);
if (visibleItems.isEmpty) {
return Stack(
children: [
ListView(
key: const PageStorageKey<String>('inventory-empty-list'),
padding: const EdgeInsets.only(bottom: 88),
children: [
_buildHeaderSection(context),
if (_selectedIds.isNotEmpty) _buildSelectedSection(context, visibleItems, items),
_buildFilterSection(context, location, sort),
_buildEmptyState(context),
],
),
Positioned(
right: 16,
bottom: 16,
child: FloatingActionButton.extended(
heroTag: 'inventory_add_empty',
onPressed: () => context.push('/inventory/create'),
icon: const Icon(Icons.add),
label: Text(context.l10n.addAction),
),
),
],
);
}
return Stack(
children: [
ListView.separated(
key: const PageStorageKey<String>('inventory-main-list'),
padding: const EdgeInsets.only(bottom: 88),
itemCount: visibleItems.length + (_selectedIds.isEmpty ? 2 : 3),
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) {
if (index == 0) return _buildFilterSection(context, location, sort);
if (index == 1) return _buildHeaderSection(context);
if (_selectedIds.isNotEmpty && index == 2) {
return _buildSelectedSection(context, visibleItems, items);
}
final item = visibleItems[index - (_selectedIds.isEmpty ? 2 : 3)];
return SwipeableInventoryTile(
item: item,
selectableMode: _selectedIds.isNotEmpty,
selected: _selectedIds.contains(item.id),
onToggleSelected: () => _toggleSelection(item.id),
onLongPress: () {
if (_selectedIds.isEmpty) {
_startSelection(item.id);
} else {
_toggleSelection(item.id);
}
},
);
},
),
Positioned(
right: 16,
bottom: 16,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
FloatingActionButton.extended(
heroTag: 'inventory_add',
onPressed: () => context.push('/inventory/create'),
icon: const Icon(Icons.add),
label: Text(context.l10n.addAction),
),
const SizedBox(height: 8),
FloatingActionButton.extended(
heroTag: 'inventory_go_recipes',
onPressed: () => context.go('/recipes'),
icon: const Icon(Icons.restaurant_menu),
label: Text(context.l10n.inventoryRecipesAction),
),
],
),
),
],
);
},
);
}
}