feat: Refactor inventory screens with category selection and product handling improvements
Test Suite / test (24.15.0) (push) Has been cancelled

This commit is contained in:
Nils-Johan Gynther
2026-05-11 21:09:40 +02:00
parent 2281df3716
commit 3e0af925d5
7 changed files with 319 additions and 145 deletions
@@ -9,13 +9,15 @@ import '../../../core/api/api_providers.dart';
import '../../../core/forms/form_options.dart';
import '../../../core/l10n/l10n.dart';
import '../../../core/ui/category_then_product_picker.dart';
import '../../../core/ui/product_picker_field.dart';
import '../../../core/ui/searchable_category_field.dart';
import '../../../core/ui/product_picker_field.dart' show ProductOption;
import '../../../core/ui/searchable_category_field.dart' show CategorySelectOption;
import '../../auth/data/auth_providers.dart';
import '../../admin/domain/admin_category_node.dart';
import '../../pantry/data/pantry_providers.dart';
import '../data/inventory_providers.dart';
import '../../import/data/receipt_import_session.dart' show ImportDestination;
import 'inventory_category_helpers.dart';
import 'inventory_category_product_section.dart';
class CreateInventoryScreen extends ConsumerStatefulWidget {
final String? initialDestination;
@@ -41,6 +43,8 @@ class _CreateInventoryScreenState
List<Map<String, dynamic>> _products = [];
List<AdminCategoryNode> _categoryTree = [];
List<CategorySelectOption> _categoryOptions = [];
InventoryCategoryBranchIndex? _categoryBranchIndex;
final _sortedProductsCache = InventorySortedProductsCache();
bool _loadingProducts = false;
DateTime? _purchaseDate;
DateTime? _bestBeforeDate;
@@ -95,6 +99,7 @@ class _CreateInventoryScreenState
.map((e) => AdminCategoryNode.fromJson(Map<String, dynamic>.from(e as Map)))
.toList();
_categoryOptions = _flattenCategoryOptions(_categoryTree);
_categoryBranchIndex = InventoryCategoryBranchIndex.fromTree(_categoryTree);
_loadingProducts = false;
});
}
@@ -132,11 +137,23 @@ class _CreateInventoryScreenState
return null;
}
Set<int>? _selectedCategoryBranchIds() {
final selectedId = _selectedCategoryId;
if (selectedId == null) return null;
return _categoryBranchIndex?.branchIdsFor(selectedId) ?? {selectedId};
}
List<ProductOption> _productOptions() {
final selectedCategoryIds = _selectedCategoryBranchIds();
final source = _selectedCategoryId == null
? _products
: _products
.where((p) => (p['categoryId'] as num?)?.toInt() == _selectedCategoryId)
.where(
(p) => productInSelectedBranch(
productCategoryId: (p['categoryId'] as num?)?.toInt(),
selectedCategoryIds: selectedCategoryIds,
),
)
.toList();
final selected = _selectedProduct();
@@ -146,13 +163,8 @@ class _CreateInventoryScreenState
withSelected.add(selected);
}
withSelected.sort((a, b) {
final aName = (a['canonicalName'] ?? a['name'] ?? '').toString().toLowerCase();
final bName = (b['canonicalName'] ?? b['name'] ?? '').toString().toLowerCase();
return aName.compareTo(bName);
});
return withSelected
final sorted = _sortedProductsCache.sort(withSelected);
return sorted
.map(
(p) => (
id: (p['id'] as num).toInt(),
@@ -172,9 +184,13 @@ class _CreateInventoryScreenState
if (selected == null || !mounted) return;
setState(() {
_selectedCategoryId = selected.id;
final selectedCategoryIds = _selectedCategoryBranchIds();
final current = _selectedProduct();
final currentCategoryId = (current?['categoryId'] as num?)?.toInt();
if (currentCategoryId != _selectedCategoryId) {
if (!productInSelectedBranch(
productCategoryId: currentCategoryId,
selectedCategoryIds: selectedCategoryIds,
)) {
_selectedProductId = null;
}
});
@@ -302,54 +318,41 @@ class _CreateInventoryScreenState
: (selected) => setState(() => _destination = selected.first),
),
const SizedBox(height: 16),
SearchableCategoryField(
options: _categoryOptions,
value: _selectedCategoryId?.toString(),
label: 'Kategori (sökbar)',
onChanged: (value) {
InventoryCategoryProductSection(
categoryOptions: _categoryOptions,
selectedCategoryId: _selectedCategoryId,
categorySearchLabel: 'Kategori (sökbar)',
onCategoryChanged: (value) {
if (value == null) return;
setState(() {
_selectedCategoryId = int.tryParse(value);
final selectedCategoryIds = _selectedCategoryBranchIds();
final current = _selectedProduct();
final currentCategoryId = (current?['categoryId'] as num?)?.toInt();
if (currentCategoryId != _selectedCategoryId) {
if (!productInSelectedBranch(
productCategoryId: currentCategoryId,
selectedCategoryIds: selectedCategoryIds,
)) {
_selectedProductId = null;
}
});
},
),
const SizedBox(height: 12),
Row(
children: [
OutlinedButton.icon(
onPressed: _loadingProducts || _saving || _categoryTree.isEmpty
? null
: _pickCategory,
icon: const Icon(Icons.category_outlined),
label: const Text('Välj kategori'),
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: _saving
? null
: () {
setState(() {
_selectedCategoryId = null;
});
},
icon: const Icon(Icons.clear),
label: const Text('Rensa kategori'),
),
],
),
const SizedBox(height: 12),
ProductPickerField(
products: productOptions,
value: _selectedProductId,
isLoading: _loadingProducts,
enabled: !_saving,
label: context.l10n.inventoryProductLabel,
onChanged: (value) => setState(() => _selectedProductId = value),
onPickCategory: _pickCategory,
pickCategoryLabel: 'Välj kategori',
onClearCategory: () {
setState(() {
_selectedCategoryId = null;
});
},
clearCategoryLabel: 'Rensa kategori',
canPickCategory: !_loadingProducts && !_saving && _categoryTree.isNotEmpty,
canClearCategory: !_saving,
productOptions: productOptions,
selectedProductId: _selectedProductId,
onProductChanged: (value) => setState(() => _selectedProductId = value),
isLoadingProducts: _loadingProducts,
productEnabled: !_saving,
productLabel: context.l10n.inventoryProductLabel,
),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
@@ -0,0 +1,78 @@
import '../../admin/domain/admin_category_node.dart';
class InventoryCategoryBranchIndex {
final Map<int, Set<int>> _descendantsById;
InventoryCategoryBranchIndex._(this._descendantsById);
factory InventoryCategoryBranchIndex.fromTree(List<AdminCategoryNode> roots) {
final descendantsById = <int, Set<int>>{};
Set<int> collect(AdminCategoryNode node) {
final ids = <int>{node.id};
for (final child in node.children) {
ids.addAll(collect(child));
}
descendantsById[node.id] = ids;
return ids;
}
for (final root in roots) {
collect(root);
}
return InventoryCategoryBranchIndex._(descendantsById);
}
Set<int>? branchIdsFor(int? categoryId) {
if (categoryId == null) return null;
return _descendantsById[categoryId] ?? {categoryId};
}
}
bool productInSelectedBranch({
required int? productCategoryId,
required Set<int>? selectedCategoryIds,
}) {
if (selectedCategoryIds == null) return true;
if (productCategoryId == null) return false;
return selectedCategoryIds.contains(productCategoryId);
}
class InventorySortedProductsCache {
String? _lastKey;
List<Map<String, dynamic>> _lastSorted = const [];
List<Map<String, dynamic>> sort(List<Map<String, dynamic>> products) {
final key = _buildKey(products);
if (_lastKey == key) return _lastSorted;
final sorted = [...products]
..sort((a, b) {
final aName = (a['canonicalName'] ?? a['name'] ?? '').toString().toLowerCase();
final bName = (b['canonicalName'] ?? b['name'] ?? '').toString().toLowerCase();
return aName.compareTo(bName);
});
_lastKey = key;
_lastSorted = sorted;
return _lastSorted;
}
String _buildKey(List<Map<String, dynamic>> products) {
final b = StringBuffer('${products.length}|');
for (final p in products) {
final id = (p['id'] as num?)?.toInt() ?? -1;
final categoryId = (p['categoryId'] as num?)?.toInt() ?? -1;
final name = (p['canonicalName'] ?? p['name'] ?? '').toString();
b
..write(id)
..write(':')
..write(categoryId)
..write(':')
..write(name)
..write('|');
}
return b.toString();
}
}
@@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import '../../../core/ui/product_picker_field.dart' as product_picker;
import '../../../core/ui/searchable_category_field.dart';
class InventoryCategoryProductSection extends StatelessWidget {
final List<CategorySelectOption> categoryOptions;
final int? selectedCategoryId;
final ValueChanged<String?> onCategoryChanged;
final String categorySearchLabel;
final VoidCallback onPickCategory;
final String pickCategoryLabel;
final VoidCallback onClearCategory;
final String clearCategoryLabel;
final bool canPickCategory;
final bool canClearCategory;
final List<product_picker.ProductOption> productOptions;
final int? selectedProductId;
final ValueChanged<int?> onProductChanged;
final bool isLoadingProducts;
final bool productEnabled;
final String productLabel;
const InventoryCategoryProductSection({
super.key,
required this.categoryOptions,
required this.selectedCategoryId,
required this.onCategoryChanged,
required this.categorySearchLabel,
required this.onPickCategory,
required this.pickCategoryLabel,
required this.onClearCategory,
required this.clearCategoryLabel,
required this.canPickCategory,
required this.canClearCategory,
required this.productOptions,
required this.selectedProductId,
required this.onProductChanged,
required this.isLoadingProducts,
required this.productEnabled,
required this.productLabel,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
SearchableCategoryField(
options: categoryOptions,
value: selectedCategoryId?.toString(),
label: categorySearchLabel,
onChanged: onCategoryChanged,
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
OutlinedButton.icon(
onPressed: canPickCategory ? onPickCategory : null,
icon: const Icon(Icons.category_outlined),
label: Text(pickCategoryLabel),
),
OutlinedButton.icon(
onPressed: canClearCategory ? onClearCategory : null,
icon: const Icon(Icons.clear),
label: Text(clearCategoryLabel),
),
],
),
const SizedBox(height: 12),
product_picker.ProductPickerField(
products: productOptions,
value: selectedProductId,
isLoading: isLoadingProducts,
enabled: productEnabled,
label: productLabel,
onChanged: onProductChanged,
),
],
);
}
}
@@ -10,12 +10,14 @@ import '../../../core/forms/form_options.dart';
import '../../../core/l10n/l10n.dart';
import '../../../core/ui/async_state_views.dart';
import '../../../core/ui/category_then_product_picker.dart';
import '../../../core/ui/product_picker_field.dart';
import '../../../core/ui/searchable_category_field.dart';
import '../../../core/ui/product_picker_field.dart' show ProductOption;
import '../../../core/ui/searchable_category_field.dart' show CategorySelectOption;
import '../../auth/data/auth_providers.dart';
import '../../admin/domain/admin_category_node.dart';
import '../data/inventory_providers.dart';
import '../domain/inventory_item.dart';
import 'inventory_category_helpers.dart';
import 'inventory_category_product_section.dart';
class InventoryEditScreen extends ConsumerStatefulWidget {
final int itemId;
@@ -46,6 +48,8 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
List<Map<String, dynamic>> _products = [];
List<AdminCategoryNode> _categoryTree = [];
List<CategorySelectOption> _categoryOptions = [];
InventoryCategoryBranchIndex? _categoryBranchIndex;
final _sortedProductsCache = InventorySortedProductsCache();
@override
void dispose() {
@@ -113,6 +117,7 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
_products = products;
_categoryTree = categoryTree;
_categoryOptions = categoryOptions;
_categoryBranchIndex = InventoryCategoryBranchIndex.fromTree(categoryTree);
final selected = _selectedProduct();
if (selected != null) {
@@ -154,10 +159,24 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
return null;
}
Set<int>? _selectedCategoryBranchIds() {
final selectedId = _selectedCategoryId;
if (selectedId == null) return null;
return _categoryBranchIndex?.branchIdsFor(selectedId) ?? {selectedId};
}
List<ProductOption> _productOptions() {
final selectedCategoryIds = _selectedCategoryBranchIds();
final source = _selectedCategoryId == null
? _products
: _products.where((p) => (p['categoryId'] as num?)?.toInt() == _selectedCategoryId).toList();
: _products
.where(
(p) => productInSelectedBranch(
productCategoryId: (p['categoryId'] as num?)?.toInt(),
selectedCategoryIds: selectedCategoryIds,
),
)
.toList();
final selected = _selectedProduct();
final withSelected = [...source];
@@ -165,13 +184,8 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
withSelected.add(selected);
}
withSelected.sort((a, b) {
final aName = (a['canonicalName'] ?? a['name'] ?? '').toString().toLowerCase();
final bName = (b['canonicalName'] ?? b['name'] ?? '').toString().toLowerCase();
return aName.compareTo(bName);
});
return withSelected
final sorted = _sortedProductsCache.sort(withSelected);
return sorted
.map(
(p) => (
id: (p['id'] as num).toInt(),
@@ -191,9 +205,13 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
if (selected == null || !mounted) return;
setState(() {
_selectedCategoryId = selected.id;
final selectedCategoryIds = _selectedCategoryBranchIds();
final current = _selectedProduct();
final currentCategoryId = (current?['categoryId'] as num?)?.toInt();
if (currentCategoryId != _selectedCategoryId) {
if (!productInSelectedBranch(
productCategoryId: currentCategoryId,
selectedCategoryIds: selectedCategoryIds,
)) {
_selectedProductId = null;
}
});
@@ -292,54 +310,41 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
Text(item.categoryPath!, style: Theme.of(context).textTheme.bodySmall),
],
const SizedBox(height: 16),
SearchableCategoryField(
options: _categoryOptions,
value: _selectedCategoryId?.toString(),
label: 'Kategori (sökbar)',
onChanged: (value) {
InventoryCategoryProductSection(
categoryOptions: _categoryOptions,
selectedCategoryId: _selectedCategoryId,
categorySearchLabel: 'Kategori (sökbar)',
onCategoryChanged: (value) {
if (value == null) return;
setState(() {
_selectedCategoryId = int.tryParse(value);
final selectedCategoryIds = _selectedCategoryBranchIds();
final current = _selectedProduct();
final currentCategoryId = (current?['categoryId'] as num?)?.toInt();
if (currentCategoryId != _selectedCategoryId) {
if (!productInSelectedBranch(
productCategoryId: currentCategoryId,
selectedCategoryIds: selectedCategoryIds,
)) {
_selectedProductId = null;
}
});
},
),
const SizedBox(height: 12),
Row(
children: [
OutlinedButton.icon(
onPressed: _loadingProducts || _saving || _categoryTree.isEmpty
? null
: _pickCategory,
icon: const Icon(Icons.category_outlined),
label: const Text('Välj kategori'),
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: _saving
? null
: () {
setState(() {
_selectedCategoryId = null;
});
},
icon: const Icon(Icons.clear),
label: const Text('Rensa kategori'),
),
],
),
const SizedBox(height: 12),
ProductPickerField(
products: _productOptions(),
value: _selectedProductId,
isLoading: _loadingProducts,
enabled: !_saving,
label: context.l10n.inventoryProductLabel,
onChanged: (value) => setState(() => _selectedProductId = value),
onPickCategory: _pickCategory,
pickCategoryLabel: 'Välj kategori',
onClearCategory: () {
setState(() {
_selectedCategoryId = null;
});
},
clearCategoryLabel: 'Rensa kategori',
canPickCategory: !_loadingProducts && !_saving && _categoryTree.isNotEmpty,
canClearCategory: !_saving,
productOptions: _productOptions(),
selectedProductId: _selectedProductId,
onProductChanged: (value) => setState(() => _selectedProductId = value),
isLoadingProducts: _loadingProducts,
productEnabled: !_saving,
productLabel: context.l10n.inventoryProductLabel,
),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/api/api_error_mapper.dart';
import '../../../core/utils/display_labels.dart';
import '../../../core/utils/formatters.dart';
import '../../auth/data/auth_providers.dart';
import '../data/inventory_providers.dart';
@@ -286,12 +287,11 @@ class _ForegroundTile extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final location = item.location?.trim();
final hasLocation = location != null && location.isNotEmpty;
final location = normalizedOptionalText(item.location);
final subtitleText = [
'${_fmtQty(item.quantity)} ${item.unit}',
if (hasLocation) location,
if (location != null) location,
if (item.bestBeforeDate != null)
'Bäst före: ${_formatDate(item.bestBeforeDate!)}',
].join(' · ');
@@ -331,26 +331,23 @@ class _ForegroundTile extends ConsumerWidget {
],
),
const SizedBox(height: 6),
Row(
children: [
Flexible(
child: Chip(
label: Text(
'L1: ${item.l1Category}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
side: BorderSide(color: theme.colorScheme.outlineVariant),
backgroundColor: theme.colorScheme.surface,
labelStyle: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
Align(
alignment: Alignment.centerLeft,
child: Chip(
label: Text(
l1CategoryChipLabel('L1: ', item.l1Category),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
side: BorderSide(color: theme.colorScheme.outlineVariant),
backgroundColor: theme.colorScheme.surface,
labelStyle: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
),