feat: Refactor inventory screens with category selection and product handling improvements
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:
@@ -0,0 +1,9 @@
|
|||||||
|
String? normalizedOptionalText(String? value) {
|
||||||
|
final normalized = value?.trim();
|
||||||
|
if (normalized == null || normalized.isEmpty) return null;
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
String l1CategoryChipLabel(String prefix, String l1Category) => '$prefix$l1Category';
|
||||||
|
|
||||||
|
String locationLabel(String prefix, String location) => '$prefix$location';
|
||||||
@@ -9,13 +9,15 @@ import '../../../core/api/api_providers.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/category_then_product_picker.dart';
|
import '../../../core/ui/category_then_product_picker.dart';
|
||||||
import '../../../core/ui/product_picker_field.dart';
|
import '../../../core/ui/product_picker_field.dart' show ProductOption;
|
||||||
import '../../../core/ui/searchable_category_field.dart';
|
import '../../../core/ui/searchable_category_field.dart' show CategorySelectOption;
|
||||||
import '../../auth/data/auth_providers.dart';
|
import '../../auth/data/auth_providers.dart';
|
||||||
import '../../admin/domain/admin_category_node.dart';
|
import '../../admin/domain/admin_category_node.dart';
|
||||||
import '../../pantry/data/pantry_providers.dart';
|
import '../../pantry/data/pantry_providers.dart';
|
||||||
import '../data/inventory_providers.dart';
|
import '../data/inventory_providers.dart';
|
||||||
import '../../import/data/receipt_import_session.dart' show ImportDestination;
|
import '../../import/data/receipt_import_session.dart' show ImportDestination;
|
||||||
|
import 'inventory_category_helpers.dart';
|
||||||
|
import 'inventory_category_product_section.dart';
|
||||||
|
|
||||||
class CreateInventoryScreen extends ConsumerStatefulWidget {
|
class CreateInventoryScreen extends ConsumerStatefulWidget {
|
||||||
final String? initialDestination;
|
final String? initialDestination;
|
||||||
@@ -41,6 +43,8 @@ class _CreateInventoryScreenState
|
|||||||
List<Map<String, dynamic>> _products = [];
|
List<Map<String, dynamic>> _products = [];
|
||||||
List<AdminCategoryNode> _categoryTree = [];
|
List<AdminCategoryNode> _categoryTree = [];
|
||||||
List<CategorySelectOption> _categoryOptions = [];
|
List<CategorySelectOption> _categoryOptions = [];
|
||||||
|
InventoryCategoryBranchIndex? _categoryBranchIndex;
|
||||||
|
final _sortedProductsCache = InventorySortedProductsCache();
|
||||||
bool _loadingProducts = false;
|
bool _loadingProducts = false;
|
||||||
DateTime? _purchaseDate;
|
DateTime? _purchaseDate;
|
||||||
DateTime? _bestBeforeDate;
|
DateTime? _bestBeforeDate;
|
||||||
@@ -95,6 +99,7 @@ class _CreateInventoryScreenState
|
|||||||
.map((e) => AdminCategoryNode.fromJson(Map<String, dynamic>.from(e as Map)))
|
.map((e) => AdminCategoryNode.fromJson(Map<String, dynamic>.from(e as Map)))
|
||||||
.toList();
|
.toList();
|
||||||
_categoryOptions = _flattenCategoryOptions(_categoryTree);
|
_categoryOptions = _flattenCategoryOptions(_categoryTree);
|
||||||
|
_categoryBranchIndex = InventoryCategoryBranchIndex.fromTree(_categoryTree);
|
||||||
_loadingProducts = false;
|
_loadingProducts = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -132,11 +137,23 @@ class _CreateInventoryScreenState
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Set<int>? _selectedCategoryBranchIds() {
|
||||||
|
final selectedId = _selectedCategoryId;
|
||||||
|
if (selectedId == null) return null;
|
||||||
|
return _categoryBranchIndex?.branchIdsFor(selectedId) ?? {selectedId};
|
||||||
|
}
|
||||||
|
|
||||||
List<ProductOption> _productOptions() {
|
List<ProductOption> _productOptions() {
|
||||||
|
final selectedCategoryIds = _selectedCategoryBranchIds();
|
||||||
final source = _selectedCategoryId == null
|
final source = _selectedCategoryId == null
|
||||||
? _products
|
? _products
|
||||||
: _products
|
: _products
|
||||||
.where((p) => (p['categoryId'] as num?)?.toInt() == _selectedCategoryId)
|
.where(
|
||||||
|
(p) => productInSelectedBranch(
|
||||||
|
productCategoryId: (p['categoryId'] as num?)?.toInt(),
|
||||||
|
selectedCategoryIds: selectedCategoryIds,
|
||||||
|
),
|
||||||
|
)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
final selected = _selectedProduct();
|
final selected = _selectedProduct();
|
||||||
@@ -146,13 +163,8 @@ class _CreateInventoryScreenState
|
|||||||
withSelected.add(selected);
|
withSelected.add(selected);
|
||||||
}
|
}
|
||||||
|
|
||||||
withSelected.sort((a, b) {
|
final sorted = _sortedProductsCache.sort(withSelected);
|
||||||
final aName = (a['canonicalName'] ?? a['name'] ?? '').toString().toLowerCase();
|
return sorted
|
||||||
final bName = (b['canonicalName'] ?? b['name'] ?? '').toString().toLowerCase();
|
|
||||||
return aName.compareTo(bName);
|
|
||||||
});
|
|
||||||
|
|
||||||
return withSelected
|
|
||||||
.map(
|
.map(
|
||||||
(p) => (
|
(p) => (
|
||||||
id: (p['id'] as num).toInt(),
|
id: (p['id'] as num).toInt(),
|
||||||
@@ -172,9 +184,13 @@ class _CreateInventoryScreenState
|
|||||||
if (selected == null || !mounted) return;
|
if (selected == null || !mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedCategoryId = selected.id;
|
_selectedCategoryId = selected.id;
|
||||||
|
final selectedCategoryIds = _selectedCategoryBranchIds();
|
||||||
final current = _selectedProduct();
|
final current = _selectedProduct();
|
||||||
final currentCategoryId = (current?['categoryId'] as num?)?.toInt();
|
final currentCategoryId = (current?['categoryId'] as num?)?.toInt();
|
||||||
if (currentCategoryId != _selectedCategoryId) {
|
if (!productInSelectedBranch(
|
||||||
|
productCategoryId: currentCategoryId,
|
||||||
|
selectedCategoryIds: selectedCategoryIds,
|
||||||
|
)) {
|
||||||
_selectedProductId = null;
|
_selectedProductId = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -302,54 +318,41 @@ class _CreateInventoryScreenState
|
|||||||
: (selected) => setState(() => _destination = selected.first),
|
: (selected) => setState(() => _destination = selected.first),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
SearchableCategoryField(
|
InventoryCategoryProductSection(
|
||||||
options: _categoryOptions,
|
categoryOptions: _categoryOptions,
|
||||||
value: _selectedCategoryId?.toString(),
|
selectedCategoryId: _selectedCategoryId,
|
||||||
label: 'Kategori (sökbar)',
|
categorySearchLabel: 'Kategori (sökbar)',
|
||||||
onChanged: (value) {
|
onCategoryChanged: (value) {
|
||||||
if (value == null) return;
|
if (value == null) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedCategoryId = int.tryParse(value);
|
_selectedCategoryId = int.tryParse(value);
|
||||||
|
final selectedCategoryIds = _selectedCategoryBranchIds();
|
||||||
final current = _selectedProduct();
|
final current = _selectedProduct();
|
||||||
final currentCategoryId = (current?['categoryId'] as num?)?.toInt();
|
final currentCategoryId = (current?['categoryId'] as num?)?.toInt();
|
||||||
if (currentCategoryId != _selectedCategoryId) {
|
if (!productInSelectedBranch(
|
||||||
|
productCategoryId: currentCategoryId,
|
||||||
|
selectedCategoryIds: selectedCategoryIds,
|
||||||
|
)) {
|
||||||
_selectedProductId = null;
|
_selectedProductId = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
),
|
onPickCategory: _pickCategory,
|
||||||
const SizedBox(height: 12),
|
pickCategoryLabel: 'Välj kategori',
|
||||||
Row(
|
onClearCategory: () {
|
||||||
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(() {
|
setState(() {
|
||||||
_selectedCategoryId = null;
|
_selectedCategoryId = null;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.clear),
|
clearCategoryLabel: 'Rensa kategori',
|
||||||
label: const Text('Rensa kategori'),
|
canPickCategory: !_loadingProducts && !_saving && _categoryTree.isNotEmpty,
|
||||||
),
|
canClearCategory: !_saving,
|
||||||
],
|
productOptions: productOptions,
|
||||||
),
|
selectedProductId: _selectedProductId,
|
||||||
const SizedBox(height: 12),
|
onProductChanged: (value) => setState(() => _selectedProductId = value),
|
||||||
ProductPickerField(
|
isLoadingProducts: _loadingProducts,
|
||||||
products: productOptions,
|
productEnabled: !_saving,
|
||||||
value: _selectedProductId,
|
productLabel: context.l10n.inventoryProductLabel,
|
||||||
isLoading: _loadingProducts,
|
|
||||||
enabled: !_saving,
|
|
||||||
label: context.l10n.inventoryProductLabel,
|
|
||||||
onChanged: (value) => setState(() => _selectedProductId = value),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
DropdownButtonFormField<String>(
|
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/l10n/l10n.dart';
|
||||||
import '../../../core/ui/async_state_views.dart';
|
import '../../../core/ui/async_state_views.dart';
|
||||||
import '../../../core/ui/category_then_product_picker.dart';
|
import '../../../core/ui/category_then_product_picker.dart';
|
||||||
import '../../../core/ui/product_picker_field.dart';
|
import '../../../core/ui/product_picker_field.dart' show ProductOption;
|
||||||
import '../../../core/ui/searchable_category_field.dart';
|
import '../../../core/ui/searchable_category_field.dart' show CategorySelectOption;
|
||||||
import '../../auth/data/auth_providers.dart';
|
import '../../auth/data/auth_providers.dart';
|
||||||
import '../../admin/domain/admin_category_node.dart';
|
import '../../admin/domain/admin_category_node.dart';
|
||||||
import '../data/inventory_providers.dart';
|
import '../data/inventory_providers.dart';
|
||||||
import '../domain/inventory_item.dart';
|
import '../domain/inventory_item.dart';
|
||||||
|
import 'inventory_category_helpers.dart';
|
||||||
|
import 'inventory_category_product_section.dart';
|
||||||
|
|
||||||
class InventoryEditScreen extends ConsumerStatefulWidget {
|
class InventoryEditScreen extends ConsumerStatefulWidget {
|
||||||
final int itemId;
|
final int itemId;
|
||||||
@@ -46,6 +48,8 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
|
|||||||
List<Map<String, dynamic>> _products = [];
|
List<Map<String, dynamic>> _products = [];
|
||||||
List<AdminCategoryNode> _categoryTree = [];
|
List<AdminCategoryNode> _categoryTree = [];
|
||||||
List<CategorySelectOption> _categoryOptions = [];
|
List<CategorySelectOption> _categoryOptions = [];
|
||||||
|
InventoryCategoryBranchIndex? _categoryBranchIndex;
|
||||||
|
final _sortedProductsCache = InventorySortedProductsCache();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
@@ -113,6 +117,7 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
|
|||||||
_products = products;
|
_products = products;
|
||||||
_categoryTree = categoryTree;
|
_categoryTree = categoryTree;
|
||||||
_categoryOptions = categoryOptions;
|
_categoryOptions = categoryOptions;
|
||||||
|
_categoryBranchIndex = InventoryCategoryBranchIndex.fromTree(categoryTree);
|
||||||
|
|
||||||
final selected = _selectedProduct();
|
final selected = _selectedProduct();
|
||||||
if (selected != null) {
|
if (selected != null) {
|
||||||
@@ -154,10 +159,24 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Set<int>? _selectedCategoryBranchIds() {
|
||||||
|
final selectedId = _selectedCategoryId;
|
||||||
|
if (selectedId == null) return null;
|
||||||
|
return _categoryBranchIndex?.branchIdsFor(selectedId) ?? {selectedId};
|
||||||
|
}
|
||||||
|
|
||||||
List<ProductOption> _productOptions() {
|
List<ProductOption> _productOptions() {
|
||||||
|
final selectedCategoryIds = _selectedCategoryBranchIds();
|
||||||
final source = _selectedCategoryId == null
|
final source = _selectedCategoryId == null
|
||||||
? _products
|
? _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 selected = _selectedProduct();
|
||||||
final withSelected = [...source];
|
final withSelected = [...source];
|
||||||
@@ -165,13 +184,8 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
|
|||||||
withSelected.add(selected);
|
withSelected.add(selected);
|
||||||
}
|
}
|
||||||
|
|
||||||
withSelected.sort((a, b) {
|
final sorted = _sortedProductsCache.sort(withSelected);
|
||||||
final aName = (a['canonicalName'] ?? a['name'] ?? '').toString().toLowerCase();
|
return sorted
|
||||||
final bName = (b['canonicalName'] ?? b['name'] ?? '').toString().toLowerCase();
|
|
||||||
return aName.compareTo(bName);
|
|
||||||
});
|
|
||||||
|
|
||||||
return withSelected
|
|
||||||
.map(
|
.map(
|
||||||
(p) => (
|
(p) => (
|
||||||
id: (p['id'] as num).toInt(),
|
id: (p['id'] as num).toInt(),
|
||||||
@@ -191,9 +205,13 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
|
|||||||
if (selected == null || !mounted) return;
|
if (selected == null || !mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedCategoryId = selected.id;
|
_selectedCategoryId = selected.id;
|
||||||
|
final selectedCategoryIds = _selectedCategoryBranchIds();
|
||||||
final current = _selectedProduct();
|
final current = _selectedProduct();
|
||||||
final currentCategoryId = (current?['categoryId'] as num?)?.toInt();
|
final currentCategoryId = (current?['categoryId'] as num?)?.toInt();
|
||||||
if (currentCategoryId != _selectedCategoryId) {
|
if (!productInSelectedBranch(
|
||||||
|
productCategoryId: currentCategoryId,
|
||||||
|
selectedCategoryIds: selectedCategoryIds,
|
||||||
|
)) {
|
||||||
_selectedProductId = null;
|
_selectedProductId = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -292,54 +310,41 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
|
|||||||
Text(item.categoryPath!, style: Theme.of(context).textTheme.bodySmall),
|
Text(item.categoryPath!, style: Theme.of(context).textTheme.bodySmall),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
SearchableCategoryField(
|
InventoryCategoryProductSection(
|
||||||
options: _categoryOptions,
|
categoryOptions: _categoryOptions,
|
||||||
value: _selectedCategoryId?.toString(),
|
selectedCategoryId: _selectedCategoryId,
|
||||||
label: 'Kategori (sökbar)',
|
categorySearchLabel: 'Kategori (sökbar)',
|
||||||
onChanged: (value) {
|
onCategoryChanged: (value) {
|
||||||
if (value == null) return;
|
if (value == null) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedCategoryId = int.tryParse(value);
|
_selectedCategoryId = int.tryParse(value);
|
||||||
|
final selectedCategoryIds = _selectedCategoryBranchIds();
|
||||||
final current = _selectedProduct();
|
final current = _selectedProduct();
|
||||||
final currentCategoryId = (current?['categoryId'] as num?)?.toInt();
|
final currentCategoryId = (current?['categoryId'] as num?)?.toInt();
|
||||||
if (currentCategoryId != _selectedCategoryId) {
|
if (!productInSelectedBranch(
|
||||||
|
productCategoryId: currentCategoryId,
|
||||||
|
selectedCategoryIds: selectedCategoryIds,
|
||||||
|
)) {
|
||||||
_selectedProductId = null;
|
_selectedProductId = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
),
|
onPickCategory: _pickCategory,
|
||||||
const SizedBox(height: 12),
|
pickCategoryLabel: 'Välj kategori',
|
||||||
Row(
|
onClearCategory: () {
|
||||||
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(() {
|
setState(() {
|
||||||
_selectedCategoryId = null;
|
_selectedCategoryId = null;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.clear),
|
clearCategoryLabel: 'Rensa kategori',
|
||||||
label: const Text('Rensa kategori'),
|
canPickCategory: !_loadingProducts && !_saving && _categoryTree.isNotEmpty,
|
||||||
),
|
canClearCategory: !_saving,
|
||||||
],
|
productOptions: _productOptions(),
|
||||||
),
|
selectedProductId: _selectedProductId,
|
||||||
const SizedBox(height: 12),
|
onProductChanged: (value) => setState(() => _selectedProductId = value),
|
||||||
ProductPickerField(
|
isLoadingProducts: _loadingProducts,
|
||||||
products: _productOptions(),
|
productEnabled: !_saving,
|
||||||
value: _selectedProductId,
|
productLabel: context.l10n.inventoryProductLabel,
|
||||||
isLoading: _loadingProducts,
|
|
||||||
enabled: !_saving,
|
|
||||||
label: context.l10n.inventoryProductLabel,
|
|
||||||
onChanged: (value) => setState(() => _selectedProductId = value),
|
|
||||||
),
|
),
|
||||||
Row(
|
Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../../../core/api/api_error_mapper.dart';
|
import '../../../core/api/api_error_mapper.dart';
|
||||||
|
import '../../../core/utils/display_labels.dart';
|
||||||
import '../../../core/utils/formatters.dart';
|
import '../../../core/utils/formatters.dart';
|
||||||
import '../../auth/data/auth_providers.dart';
|
import '../../auth/data/auth_providers.dart';
|
||||||
import '../data/inventory_providers.dart';
|
import '../data/inventory_providers.dart';
|
||||||
@@ -286,12 +287,11 @@ class _ForegroundTile extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final location = item.location?.trim();
|
final location = normalizedOptionalText(item.location);
|
||||||
final hasLocation = location != null && location.isNotEmpty;
|
|
||||||
|
|
||||||
final subtitleText = [
|
final subtitleText = [
|
||||||
'${_fmtQty(item.quantity)} ${item.unit}',
|
'${_fmtQty(item.quantity)} ${item.unit}',
|
||||||
if (hasLocation) location,
|
if (location != null) location,
|
||||||
if (item.bestBeforeDate != null)
|
if (item.bestBeforeDate != null)
|
||||||
'Bäst före: ${_formatDate(item.bestBeforeDate!)}',
|
'Bäst före: ${_formatDate(item.bestBeforeDate!)}',
|
||||||
].join(' · ');
|
].join(' · ');
|
||||||
@@ -331,12 +331,11 @@ class _ForegroundTile extends ConsumerWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Row(
|
Align(
|
||||||
children: [
|
alignment: Alignment.centerLeft,
|
||||||
Flexible(
|
|
||||||
child: Chip(
|
child: Chip(
|
||||||
label: Text(
|
label: Text(
|
||||||
'L1: ${item.l1Category}',
|
l1CategoryChipLabel('L1: ', item.l1Category),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
@@ -352,8 +351,6 @@ class _ForegroundTile extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
trailing: acting
|
trailing: acting
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
width: 24,
|
width: 24,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ 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/utils/display_labels.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';
|
||||||
@@ -373,8 +374,7 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
|
|||||||
if (index == 1) return headerSection;
|
if (index == 1) return headerSection;
|
||||||
final item = filteredItems[index - 2];
|
final item = filteredItems[index - 2];
|
||||||
final l1Category = _resolveL1Category(item);
|
final l1Category = _resolveL1Category(item);
|
||||||
final location = item.location?.trim();
|
final location = normalizedOptionalText(item.location);
|
||||||
final hasLocation = location != null && location.isNotEmpty;
|
|
||||||
|
|
||||||
return ListTile(
|
return ListTile(
|
||||||
title: Text(item.displayName),
|
title: Text(item.displayName),
|
||||||
@@ -382,18 +382,17 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
if (hasLocation)
|
if (location != null)
|
||||||
Text(
|
Text(
|
||||||
'Plats: $location',
|
locationLabel('${context.l10n.locationLabel}: ', location),
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Row(
|
Align(
|
||||||
children: [
|
alignment: Alignment.centerLeft,
|
||||||
Flexible(
|
|
||||||
child: Chip(
|
child: Chip(
|
||||||
label: Text(
|
label: Text(
|
||||||
'L1: $l1Category',
|
l1CategoryChipLabel('L1: ', l1Category),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
@@ -409,8 +408,6 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
trailing: Row(
|
trailing: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
Reference in New Issue
Block a user