feat: enhance inventory management with category and location filters
Test Suite / test (24.15.0) (push) Has been cancelled
Test Suite / test (24.15.0) (push) Has been cancelled
This commit is contained in:
@@ -14,6 +14,20 @@ type InventoryQuery = {
|
|||||||
export class InventoryService {
|
export class InventoryService {
|
||||||
constructor(private prisma: PrismaService) {}
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
|
private readonly productWithCategoryInclude = {
|
||||||
|
include: {
|
||||||
|
categoryRef: {
|
||||||
|
include: {
|
||||||
|
parent: {
|
||||||
|
include: {
|
||||||
|
parent: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
private throwInventoryItemNotFound(id: number): never {
|
private throwInventoryItemNotFound(id: number): never {
|
||||||
throw new NotFoundException(`Inventory item with id ${id} not found`);
|
throw new NotFoundException(`Inventory item with id ${id} not found`);
|
||||||
}
|
}
|
||||||
@@ -59,7 +73,7 @@ export class InventoryService {
|
|||||||
return this.prisma.inventoryItem.findMany({
|
return this.prisma.inventoryItem.findMany({
|
||||||
where,
|
where,
|
||||||
include: {
|
include: {
|
||||||
product: true,
|
product: this.productWithCategoryInclude,
|
||||||
},
|
},
|
||||||
orderBy,
|
orderBy,
|
||||||
});
|
});
|
||||||
@@ -78,7 +92,7 @@ export class InventoryService {
|
|||||||
quantity: new Prisma.Decimal(newQuantity),
|
quantity: new Prisma.Decimal(newQuantity),
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
product: true,
|
product: this.productWithCategoryInclude,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -154,7 +168,7 @@ export class InventoryService {
|
|||||||
: undefined,
|
: undefined,
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
product: true,
|
product: this.productWithCategoryInclude,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -222,7 +236,7 @@ export class InventoryService {
|
|||||||
where: { id },
|
where: { id },
|
||||||
data: updateData,
|
data: updateData,
|
||||||
include: {
|
include: {
|
||||||
product: true,
|
product: this.productWithCategoryInclude,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,7 +121,10 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
|||||||
// /inventory/create must be listed before /inventory/:id.
|
// /inventory/create must be listed before /inventory/:id.
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/inventory/create',
|
path: '/inventory/create',
|
||||||
builder: (context, state) => const CreateInventoryScreen(),
|
builder: (context, state) {
|
||||||
|
final destination = state.uri.queryParameters['destination'];
|
||||||
|
return CreateInventoryScreen(initialDestination: destination);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/inventory/:id',
|
path: '/inventory/:id',
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ class InventoryItem {
|
|||||||
final int id;
|
final int id;
|
||||||
final int productId;
|
final int productId;
|
||||||
final String productName;
|
final String productName;
|
||||||
|
final String? productCanonicalName;
|
||||||
|
final String? categoryPath;
|
||||||
final double quantity;
|
final double quantity;
|
||||||
final String unit;
|
final String unit;
|
||||||
final String? location;
|
final String? location;
|
||||||
@@ -15,6 +17,8 @@ class InventoryItem {
|
|||||||
required this.id,
|
required this.id,
|
||||||
required this.productId,
|
required this.productId,
|
||||||
required this.productName,
|
required this.productName,
|
||||||
|
this.productCanonicalName,
|
||||||
|
this.categoryPath,
|
||||||
required this.quantity,
|
required this.quantity,
|
||||||
required this.unit,
|
required this.unit,
|
||||||
this.location,
|
this.location,
|
||||||
@@ -25,11 +29,27 @@ class InventoryItem {
|
|||||||
this.comment,
|
this.comment,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
String get displayName {
|
||||||
|
if (productCanonicalName != null && productCanonicalName!.trim().isNotEmpty) {
|
||||||
|
return productCanonicalName!;
|
||||||
|
}
|
||||||
|
return productName;
|
||||||
|
}
|
||||||
|
|
||||||
|
String get l1Category {
|
||||||
|
final path = categoryPath?.trim();
|
||||||
|
if (path == null || path.isEmpty) return 'Ovrigt';
|
||||||
|
return path.split('>').first.trim();
|
||||||
|
}
|
||||||
|
|
||||||
factory InventoryItem.fromJson(Map<String, dynamic> json) {
|
factory InventoryItem.fromJson(Map<String, dynamic> json) {
|
||||||
|
final product = (json['product'] as Map<String, dynamic>?) ?? const {};
|
||||||
return InventoryItem(
|
return InventoryItem(
|
||||||
id: json['id'] as int,
|
id: json['id'] as int,
|
||||||
productId: json['productId'] as int,
|
productId: json['productId'] as int,
|
||||||
productName: (json['product'] as Map<String, dynamic>?)?['name'] as String? ?? '',
|
productName: product['name'] as String? ?? '',
|
||||||
|
productCanonicalName: product['canonicalName'] as String?,
|
||||||
|
categoryPath: _buildCategoryPath(product['categoryRef']),
|
||||||
quantity: double.tryParse(json['quantity']?.toString() ?? '0') ?? 0,
|
quantity: double.tryParse(json['quantity']?.toString() ?? '0') ?? 0,
|
||||||
unit: json['unit'] as String? ?? '',
|
unit: json['unit'] as String? ?? '',
|
||||||
location: json['location'] as String?,
|
location: json['location'] as String?,
|
||||||
@@ -40,4 +60,21 @@ class InventoryItem {
|
|||||||
comment: json['comment'] as String?,
|
comment: json['comment'] as String?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static String? _buildCategoryPath(dynamic rawCategoryRef) {
|
||||||
|
if (rawCategoryRef is! Map<String, dynamic>) return null;
|
||||||
|
|
||||||
|
final names = <String>[];
|
||||||
|
dynamic current = rawCategoryRef;
|
||||||
|
while (current is Map<String, dynamic>) {
|
||||||
|
final name = current['name']?.toString().trim();
|
||||||
|
if (name != null && name.isNotEmpty) {
|
||||||
|
names.insert(0, name);
|
||||||
|
}
|
||||||
|
current = current['parent'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (names.isEmpty) return null;
|
||||||
|
return names.join(' > ');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ import '../data/inventory_providers.dart';
|
|||||||
import '../../import/data/receipt_import_session.dart' show ImportDestination;
|
import '../../import/data/receipt_import_session.dart' show ImportDestination;
|
||||||
|
|
||||||
class CreateInventoryScreen extends ConsumerStatefulWidget {
|
class CreateInventoryScreen extends ConsumerStatefulWidget {
|
||||||
const CreateInventoryScreen({super.key});
|
final String? initialDestination;
|
||||||
|
|
||||||
|
const CreateInventoryScreen({super.key, this.initialDestination});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<CreateInventoryScreen> createState() =>
|
ConsumerState<CreateInventoryScreen> createState() =>
|
||||||
@@ -43,6 +45,9 @@ class _CreateInventoryScreenState
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
if (widget.initialDestination == 'pantry') {
|
||||||
|
_destination = ImportDestination.pantry;
|
||||||
|
}
|
||||||
_loadProducts();
|
_loadProducts();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class InventoryScreen extends ConsumerWidget {
|
|||||||
(value: 'nameAsc', label: context.l10n.inventorySortNameAsc),
|
(value: 'nameAsc', label: context.l10n.inventorySortNameAsc),
|
||||||
(value: 'bestBeforeAsc', label: context.l10n.inventorySortBestBeforeAsc),
|
(value: 'bestBeforeAsc', label: context.l10n.inventorySortBestBeforeAsc),
|
||||||
(value: 'bestBeforeDesc', label: context.l10n.inventorySortBestBeforeDesc),
|
(value: 'bestBeforeDesc', label: context.l10n.inventorySortBestBeforeDesc),
|
||||||
|
(value: 'l1CategoryAsc', label: 'L1-kategori (A-O)'),
|
||||||
];
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -32,6 +33,17 @@ class InventoryScreen extends ConsumerWidget {
|
|||||||
onRetry: () => ref.invalidate(inventoryProvider),
|
onRetry: () => ref.invalidate(inventoryProvider),
|
||||||
),
|
),
|
||||||
data: (items) {
|
data: (items) {
|
||||||
|
final visibleItems = [...items];
|
||||||
|
if (sort == 'l1CategoryAsc') {
|
||||||
|
visibleItems.sort((a, b) {
|
||||||
|
final byCategory = a.l1Category.toLowerCase().compareTo(
|
||||||
|
b.l1Category.toLowerCase(),
|
||||||
|
);
|
||||||
|
if (byCategory != 0) return byCategory;
|
||||||
|
return a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
final filterSection = Padding(
|
final filterSection = Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 4),
|
padding: const EdgeInsets.fromLTRB(12, 12, 12, 4),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -83,7 +95,7 @@ class InventoryScreen extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (items.isEmpty) {
|
if (visibleItems.isEmpty) {
|
||||||
return Stack(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
ListView(
|
ListView(
|
||||||
@@ -109,11 +121,11 @@ class InventoryScreen extends ConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
ListView.separated(
|
ListView.separated(
|
||||||
padding: const EdgeInsets.only(bottom: 88),
|
padding: const EdgeInsets.only(bottom: 88),
|
||||||
itemCount: items.length + 1,
|
itemCount: visibleItems.length + 1,
|
||||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
if (index == 0) return filterSection;
|
if (index == 0) return filterSection;
|
||||||
final item = items[index - 1];
|
final item = visibleItems[index - 1];
|
||||||
return SwipeableInventoryTile(item: item);
|
return SwipeableInventoryTile(item: item);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import '../../../core/api/api_error_mapper.dart';
|
|||||||
import '../../../core/forms/form_options.dart';
|
import '../../../core/forms/form_options.dart';
|
||||||
import '../../../core/l10n/l10n.dart';
|
import '../../../core/l10n/l10n.dart';
|
||||||
import '../../../core/ui/async_state_views.dart';
|
import '../../../core/ui/async_state_views.dart';
|
||||||
import '../../../core/ui/product_picker_field.dart';
|
|
||||||
import '../../auth/data/auth_providers.dart';
|
import '../../auth/data/auth_providers.dart';
|
||||||
import '../../inventory/data/inventory_providers.dart';
|
import '../../inventory/data/inventory_providers.dart';
|
||||||
import '../data/pantry_providers.dart';
|
import '../data/pantry_providers.dart';
|
||||||
@@ -24,8 +23,16 @@ class PantryScreen extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _PantryScreenState extends ConsumerState<PantryScreen> {
|
class _PantryScreenState extends ConsumerState<PantryScreen> {
|
||||||
int? _selectedProductId;
|
static const _locationOptions = <String>['', 'Kyl', 'Frys', 'Skafferi'];
|
||||||
bool _isSubmitting = false;
|
String _locationFilter = '';
|
||||||
|
String _sort = 'nameAsc';
|
||||||
|
|
||||||
|
List<({String value, String label})> _sortOptions() => const [
|
||||||
|
(value: 'nameAsc', label: 'Namn (A-O)'),
|
||||||
|
(value: 'nameDesc', label: 'Namn (O-A)'),
|
||||||
|
(value: 'l1CategoryAsc', label: 'L1-kategori (A-O)'),
|
||||||
|
(value: 'locationAsc', label: 'Plats (A-O)'),
|
||||||
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -171,27 +178,6 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _addItem() async {
|
|
||||||
final selectedId = _selectedProductId;
|
|
||||||
if (selectedId == null || _isSubmitting) return;
|
|
||||||
|
|
||||||
setState(() => _isSubmitting = true);
|
|
||||||
try {
|
|
||||||
final token = await ref.read(authStateProvider.future);
|
|
||||||
await ref.read(pantryRepositoryProvider).createPantryItem(selectedId, token: token);
|
|
||||||
ref.invalidate(pantryProvider);
|
|
||||||
if (mounted) setState(() => _selectedProductId = null);
|
|
||||||
} catch (error) {
|
|
||||||
_logger.severe('Failed to add pantry item: $error');
|
|
||||||
if (!mounted) return;
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(error, context)),
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
if (mounted) setState(() => _isSubmitting = false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _removeItem(PantryItem item) async {
|
Future<void> _removeItem(PantryItem item) async {
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -226,13 +212,13 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _resolveCategory(PantryItem item, Map<int, PantryProduct> productById) {
|
String _resolveL1Category(PantryItem item, Map<int, PantryProduct> productById) {
|
||||||
final fromTree = productById[item.productId]?.categoryPath;
|
final path = productById[item.productId]?.categoryPath?.trim();
|
||||||
if (fromTree != null && fromTree.trim().isNotEmpty) {
|
if (path != null && path.isNotEmpty) {
|
||||||
return fromTree;
|
return path.split('>').first.trim();
|
||||||
}
|
}
|
||||||
if (item.category != null && item.category!.trim().isNotEmpty) {
|
if (item.category != null && item.category!.trim().isNotEmpty) {
|
||||||
return item.category!;
|
return item.category!.trim();
|
||||||
}
|
}
|
||||||
return context.l10n.pantryOtherCategory;
|
return context.l10n.pantryOtherCategory;
|
||||||
}
|
}
|
||||||
@@ -263,161 +249,154 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
|
|||||||
final products =
|
final products =
|
||||||
productsAsync.maybeWhen(data: (d) => d, orElse: () => null) ?? const [];
|
productsAsync.maybeWhen(data: (d) => d, orElse: () => null) ?? const [];
|
||||||
final productById = {for (final product in products) product.id: product};
|
final productById = {for (final product in products) product.id: product};
|
||||||
final pantryProductIds = pantryItems.map((e) => e.productId).toSet();
|
|
||||||
final availableProducts = products
|
|
||||||
.where((product) => !pantryProductIds.contains(product.id))
|
|
||||||
.toList()
|
|
||||||
..sort(
|
|
||||||
(a, b) => a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()),
|
|
||||||
);
|
|
||||||
final availableOptions = availableProducts
|
|
||||||
.map((p) => (id: p.id, name: p.displayName, categoryId: null as int?))
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
final grouped = <String, List<PantryItem>>{};
|
final filteredItems = pantryItems.where((item) {
|
||||||
for (final item in pantryItems) {
|
if (_locationFilter.isEmpty) return true;
|
||||||
final category = _resolveCategory(item, productById);
|
return (item.location ?? '').trim() == _locationFilter;
|
||||||
grouped.putIfAbsent(category, () => []).add(item);
|
}).toList();
|
||||||
}
|
|
||||||
final categories = grouped.keys.toList()
|
|
||||||
..sort((a, b) {
|
|
||||||
if (a == 'Övrigt') return 1;
|
|
||||||
if (b == 'Övrigt') return -1;
|
|
||||||
return a.toLowerCase().compareTo(b.toLowerCase());
|
|
||||||
});
|
|
||||||
|
|
||||||
return ListView(
|
filteredItems.sort((a, b) {
|
||||||
padding: const EdgeInsets.all(16),
|
if (_sort == 'nameDesc') {
|
||||||
children: [
|
return b.displayName.toLowerCase().compareTo(a.displayName.toLowerCase());
|
||||||
Row(
|
}
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
if (_sort == 'locationAsc') {
|
||||||
children: [
|
final byLocation = (a.location ?? '').toLowerCase().compareTo(
|
||||||
Text(
|
(b.location ?? '').toLowerCase(),
|
||||||
context.l10n.pantryDescription,
|
);
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
if (byLocation != 0) return byLocation;
|
||||||
),
|
return a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase());
|
||||||
IconButton(
|
}
|
||||||
tooltip: context.l10n.pantryGoToRecipesTooltip,
|
if (_sort == 'l1CategoryAsc') {
|
||||||
icon: const Icon(Icons.restaurant_menu),
|
final byL1 = _resolveL1Category(a, productById).toLowerCase().compareTo(
|
||||||
onPressed: () => context.go('/recipes'),
|
_resolveL1Category(b, productById).toLowerCase(),
|
||||||
),
|
);
|
||||||
],
|
if (byL1 != 0) return byL1;
|
||||||
),
|
return a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase());
|
||||||
const SizedBox(height: 12),
|
}
|
||||||
Row(
|
return a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase());
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
});
|
||||||
children: [
|
|
||||||
Expanded(
|
final filterSection = Padding(
|
||||||
child: ProductPickerField(
|
padding: const EdgeInsets.fromLTRB(12, 12, 12, 4),
|
||||||
products: availableOptions,
|
child: Column(
|
||||||
value: _selectedProductId,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
enabled: !_isSubmitting && availableProducts.isNotEmpty,
|
children: [
|
||||||
label: 'Produkt',
|
|
||||||
onChanged: (value) => setState(() => _selectedProductId = value),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
FilledButton(
|
|
||||||
onPressed:
|
|
||||||
(_selectedProductId == null || _isSubmitting || availableProducts.isEmpty)
|
|
||||||
? null
|
|
||||||
: _addItem,
|
|
||||||
child: _isSubmitting
|
|
||||||
? const SizedBox(
|
|
||||||
height: 18,
|
|
||||||
width: 18,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
|
||||||
)
|
|
||||||
: const Text('Lägg till'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
if (availableProducts.isEmpty) ...[
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Text(
|
Text(
|
||||||
'Inga produkter tillgängliga att lägga till.',
|
context.l10n.inventoryFilterAndSort,
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.grey),
|
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: _locationFilter == option,
|
||||||
|
onSelected: (_) => setState(() => _locationFilter = option),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
initialValue: _sort,
|
||||||
|
isExpanded: true,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: context.l10n.inventorySortLabel,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
items: _sortOptions()
|
||||||
|
.map(
|
||||||
|
(option) => DropdownMenuItem<String>(
|
||||||
|
value: option.value,
|
||||||
|
child: Text(option.label),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() => _sort = value ?? 'nameAsc');
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 20),
|
),
|
||||||
Text(
|
);
|
||||||
'${pantryItems.length} ${pantryItems.length == 1 ? 'produkt' : 'produkter'} i baslagret',
|
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
final content = filteredItems.isEmpty
|
||||||
),
|
? ListView(
|
||||||
const SizedBox(height: 12),
|
padding: const EdgeInsets.fromLTRB(12, 0, 12, 96),
|
||||||
if (pantryItems.isEmpty)
|
children: [
|
||||||
const EmptyStateView(
|
filterSection,
|
||||||
title: 'Baslagret är tomt',
|
const EmptyStateView(
|
||||||
description: 'Lägg till produkter ovan.',
|
title: 'Baslagret är tomt',
|
||||||
)
|
description: 'Lägg till produkter med plusknappen.',
|
||||||
else
|
|
||||||
...categories.map((category) {
|
|
||||||
final items = grouped[category]!
|
|
||||||
..sort(
|
|
||||||
(a, b) =>
|
|
||||||
a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()),
|
|
||||||
);
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 16),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
category,
|
|
||||||
style: Theme.of(context).textTheme.titleSmall,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Column(
|
|
||||||
children: items
|
|
||||||
.map(
|
|
||||||
(item) => Card(
|
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
|
||||||
child: ListTile(
|
|
||||||
title: Text(item.displayName),
|
|
||||||
subtitle: item.location == null || item.location!.trim().isEmpty
|
|
||||||
? null
|
|
||||||
: Text('Plats: ${item.location}'),
|
|
||||||
trailing: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
const Tooltip(
|
|
||||||
message: 'Konsumera (inte tillgängligt i baslager)',
|
|
||||||
child: IconButton(
|
|
||||||
onPressed: null,
|
|
||||||
icon: Icon(Icons.remove_circle_outline),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Tooltip(
|
|
||||||
message: 'Redigera (inte tillgängligt i baslager)',
|
|
||||||
child: IconButton(
|
|
||||||
onPressed: null,
|
|
||||||
icon: Icon(Icons.edit_outlined),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
tooltip: 'Lägg i inventarie',
|
|
||||||
icon: const Icon(Icons.inventory_2_outlined),
|
|
||||||
onPressed: () => _addToInventory(item),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
tooltip: 'Ta bort',
|
|
||||||
icon: const Icon(
|
|
||||||
Icons.delete_outline,
|
|
||||||
color: Colors.red,
|
|
||||||
),
|
|
||||||
onPressed: () => _removeItem(item),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toList(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
],
|
||||||
}).toList(),
|
)
|
||||||
|
: ListView.separated(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 0, 12, 96),
|
||||||
|
itemCount: filteredItems.length + 1,
|
||||||
|
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
if (index == 0) return filterSection;
|
||||||
|
final item = filteredItems[index - 1];
|
||||||
|
final l1Category = _resolveL1Category(item, productById);
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
title: Text(item.displayName),
|
||||||
|
subtitle: Text(
|
||||||
|
[
|
||||||
|
'L1: $l1Category',
|
||||||
|
if (item.location != null && item.location!.trim().isNotEmpty)
|
||||||
|
'Plats: ${item.location}',
|
||||||
|
].join(' • '),
|
||||||
|
),
|
||||||
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Lägg i inventarie',
|
||||||
|
icon: const Icon(Icons.inventory_2_outlined),
|
||||||
|
onPressed: () => _addToInventory(item),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Ta bort',
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.delete_outline,
|
||||||
|
color: Colors.red,
|
||||||
|
),
|
||||||
|
onPressed: () => _removeItem(item),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
content,
|
||||||
|
Positioned(
|
||||||
|
right: 16,
|
||||||
|
bottom: 16,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
FloatingActionButton.extended(
|
||||||
|
onPressed: () => context.push('/inventory/create?destination=pantry'),
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
label: Text(context.l10n.addAction),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
FloatingActionButton.extended(
|
||||||
|
onPressed: () => context.go('/recipes'),
|
||||||
|
icon: const Icon(Icons.restaurant_menu),
|
||||||
|
label: Text(context.l10n.inventoryRecipesAction),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user