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

This commit is contained in:
Nils-Johan Gynther
2026-05-11 19:40:02 +02:00
parent d05b7da8bc
commit a635f1002a
3 changed files with 336 additions and 29 deletions
@@ -8,8 +8,11 @@ import '../../../core/api/api_paths.dart';
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 '../../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;
@@ -34,7 +37,10 @@ class _CreateInventoryScreenState
final _commentController = TextEditingController();
int? _selectedProductId;
int? _selectedCategoryId;
List<Map<String, dynamic>> _products = [];
List<AdminCategoryNode> _categoryTree = [];
List<CategorySelectOption> _categoryOptions = [];
bool _loadingProducts = false;
DateTime? _purchaseDate;
DateTime? _bestBeforeDate;
@@ -48,7 +54,7 @@ class _CreateInventoryScreenState
if (widget.initialDestination == 'pantry') {
_destination = ImportDestination.pantry;
}
_loadProducts();
_loadProductsAndCategories();
}
@override
@@ -61,22 +67,34 @@ class _CreateInventoryScreenState
super.dispose();
}
Future<void> _loadProducts() async {
Future<void> _loadProductsAndCategories() async {
setState(() => _loadingProducts = true);
try {
final token = await ref.read(authStateProvider.future);
final api = ref.read(apiClientProvider);
final data = await api.getJson(ProductApiPaths.mine, token: token);
final list = data is List<dynamic>
? data
: (data is Map<String, dynamic> && data['items'] is List<dynamic>)
? data['items'] as List<dynamic>
final results = await Future.wait<dynamic>([
api.getJson(ProductApiPaths.mine, token: token),
api.getJson(CategoryApiPaths.tree, token: token),
]);
final productData = results[0];
final categoryData = results[1];
final list = productData is List<dynamic>
? productData
: (productData is Map<String, dynamic> && productData['items'] is List<dynamic>)
? productData['items'] as List<dynamic>
: const <dynamic>[];
final categoryList = categoryData is List<dynamic>
? categoryData
: (categoryData is Map<String, dynamic> && categoryData['items'] is List<dynamic>)
? categoryData['items'] as List<dynamic>
: const <dynamic>[];
if (mounted) {
setState(() {
_products = list
.map((e) => e as Map<String, dynamic>)
_products = list.map((e) => e as Map<String, dynamic>).toList();
_categoryTree = categoryList
.map((e) => AdminCategoryNode.fromJson(Map<String, dynamic>.from(e as Map)))
.toList();
_categoryOptions = _flattenCategoryOptions(_categoryTree);
_loadingProducts = false;
});
}
@@ -90,6 +108,78 @@ class _CreateInventoryScreenState
}
}
List<CategorySelectOption> _flattenCategoryOptions(
List<AdminCategoryNode> nodes, [
List<String> parents = const [],
]) {
final result = <CategorySelectOption>[];
for (final node in nodes) {
final pathParts = [...parents, node.name];
final path = pathParts.join(' > ');
result.add((value: node.id.toString(), label: path));
result.addAll(_flattenCategoryOptions(node.children, pathParts));
}
return result;
}
Map<String, dynamic>? _selectedProduct() {
if (_selectedProductId == null) return null;
for (final product in _products) {
if ((product['id'] as num).toInt() == _selectedProductId) {
return product;
}
}
return null;
}
List<ProductOption> _productOptions() {
final source = _selectedCategoryId == null
? _products
: _products
.where((p) => (p['categoryId'] as num?)?.toInt() == _selectedCategoryId)
.toList();
final selected = _selectedProduct();
final withSelected = [...source];
if (selected != null &&
!withSelected.any((p) => (p['id'] as num).toInt() == _selectedProductId)) {
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
.map(
(p) => (
id: (p['id'] as num).toInt(),
name: (p['canonicalName'] ?? p['name'] ?? '').toString(),
categoryId: (p['categoryId'] as num?)?.toInt(),
),
)
.toList();
}
Future<void> _pickCategory() async {
final selected = await CategoryThenProductPicker.showCategorySheet(
context,
categoryTree: _categoryTree,
preselectedCategoryId: _selectedCategoryId,
);
if (selected == null || !mounted) return;
setState(() {
_selectedCategoryId = selected.id;
final current = _selectedProduct();
final currentCategoryId = (current?['categoryId'] as num?)?.toInt();
if (currentCategoryId != _selectedCategoryId) {
_selectedProductId = null;
}
});
}
Future<void> _pickDate(bool isBestBefore) async {
final picked = await showDatePicker(
context: context,
@@ -182,21 +272,7 @@ class _CreateInventoryScreenState
@override
Widget build(BuildContext context) {
final sortedProducts = [..._products]
..sort((a, b) {
final aName = (a['canonicalName'] ?? a['name'] ?? '').toString();
final bName = (b['canonicalName'] ?? b['name'] ?? '').toString();
return aName.toLowerCase().compareTo(bName.toLowerCase());
});
final productOptions = sortedProducts
.map(
(p) => (
id: (p['id'] as num).toInt(),
name: (p['canonicalName'] ?? p['name'] ?? '').toString(),
categoryId: (p['categoryId'] as num?)?.toInt(),
),
)
.toList();
final productOptions = _productOptions();
final isInventory = _destination == ImportDestination.inventory;
@@ -226,6 +302,47 @@ class _CreateInventoryScreenState
: (selected) => setState(() => _destination = selected.first),
),
const SizedBox(height: 16),
SearchableCategoryField(
options: _categoryOptions,
value: _selectedCategoryId?.toString(),
label: 'Kategori (sökbar)',
onChanged: (value) {
if (value == null) return;
setState(() {
_selectedCategoryId = int.tryParse(value);
final current = _selectedProduct();
final currentCategoryId = (current?['categoryId'] as num?)?.toInt();
if (currentCategoryId != _selectedCategoryId) {
_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,
@@ -3,11 +3,17 @@ 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/utils/formatters.dart';
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 '../../auth/data/auth_providers.dart';
import '../../admin/domain/admin_category_node.dart';
import '../data/inventory_providers.dart';
import '../domain/inventory_item.dart';
@@ -30,10 +36,16 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
final _commentController = TextEditingController();
bool _initialized = false;
bool _loadingProducts = false;
bool _opened = false;
DateTime? _purchaseDate;
DateTime? _bestBeforeDate;
bool _saving = false;
int? _selectedProductId;
int? _selectedCategoryId;
List<Map<String, dynamic>> _products = [];
List<AdminCategoryNode> _categoryTree = [];
List<CategorySelectOption> _categoryOptions = [];
@override
void dispose() {
@@ -47,6 +59,7 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
void _initControllers(InventoryItem item) {
_initialized = true;
_selectedProductId = item.productId;
_quantityController.text = item.quantity.toString();
_unitController.text = item.unit;
_locationController.text = item.location ?? '';
@@ -60,6 +73,132 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
: null;
}
@override
void initState() {
super.initState();
_loadProductsAndCategories();
}
Future<void> _loadProductsAndCategories() async {
setState(() => _loadingProducts = true);
try {
final token = await ref.read(authStateProvider.future);
final api = ref.read(apiClientProvider);
final results = await Future.wait<dynamic>([
api.getJson(ProductApiPaths.mine, token: token),
api.getJson(CategoryApiPaths.tree, token: token),
]);
final productData = results[0];
final categoryData = results[1];
final productList = productData is List<dynamic>
? productData
: (productData is Map<String, dynamic> && productData['items'] is List<dynamic>)
? productData['items'] as List<dynamic>
: const <dynamic>[];
final categoryList = categoryData is List<dynamic>
? categoryData
: (categoryData is Map<String, dynamic> && categoryData['items'] is List<dynamic>)
? categoryData['items'] as List<dynamic>
: const <dynamic>[];
final products = productList.map((e) => e as Map<String, dynamic>).toList();
final categoryTree = categoryList
.map((e) => AdminCategoryNode.fromJson(Map<String, dynamic>.from(e as Map)))
.toList();
final categoryOptions = _flattenCategoryOptions(categoryTree);
if (!mounted) return;
setState(() {
_products = products;
_categoryTree = categoryTree;
_categoryOptions = categoryOptions;
final selected = _selectedProduct();
if (selected != null) {
_selectedCategoryId = (selected['categoryId'] as num?)?.toInt();
}
});
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)),
);
}
} finally {
if (mounted) setState(() => _loadingProducts = false);
}
}
List<CategorySelectOption> _flattenCategoryOptions(
List<AdminCategoryNode> nodes, [
List<String> parents = const [],
]) {
final result = <CategorySelectOption>[];
for (final node in nodes) {
final pathParts = [...parents, node.name];
final path = pathParts.join(' > ');
result.add((value: node.id.toString(), label: path));
result.addAll(_flattenCategoryOptions(node.children, pathParts));
}
return result;
}
Map<String, dynamic>? _selectedProduct() {
if (_selectedProductId == null) return null;
for (final product in _products) {
if ((product['id'] as num).toInt() == _selectedProductId) {
return product;
}
}
return null;
}
List<ProductOption> _productOptions() {
final source = _selectedCategoryId == null
? _products
: _products.where((p) => (p['categoryId'] as num?)?.toInt() == _selectedCategoryId).toList();
final selected = _selectedProduct();
final withSelected = [...source];
if (selected != null && !withSelected.any((p) => (p['id'] as num).toInt() == _selectedProductId)) {
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
.map(
(p) => (
id: (p['id'] as num).toInt(),
name: (p['canonicalName'] ?? p['name'] ?? '').toString(),
categoryId: (p['categoryId'] as num?)?.toInt(),
),
)
.toList();
}
Future<void> _pickCategory() async {
final selected = await CategoryThenProductPicker.showCategorySheet(
context,
categoryTree: _categoryTree,
preselectedCategoryId: _selectedCategoryId,
);
if (selected == null || !mounted) return;
setState(() {
_selectedCategoryId = selected.id;
final current = _selectedProduct();
final currentCategoryId = (current?['categoryId'] as num?)?.toInt();
if (currentCategoryId != _selectedCategoryId) {
_selectedProductId = null;
}
});
}
Future<void> _pickDate(bool isBestBefore) async {
final picked = await showDatePicker(
context: context,
@@ -84,6 +223,7 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
try {
final token = await ref.read(authStateProvider.future);
final body = <String, dynamic>{
'productId': _selectedProductId,
'quantity':
double.parse(_quantityController.text.trim().replaceAll(',', '.')),
'unit': _unitController.text.trim(),
@@ -139,11 +279,61 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
child: ListView(
padding: const EdgeInsets.all(16),
children: [
Text(
item.productName,
style: Theme.of(context).textTheme.titleMedium,
),
Text(item.displayName, style: Theme.of(context).textTheme.titleMedium),
if (item.categoryPath != null && item.categoryPath!.trim().isNotEmpty) ...[
const SizedBox(height: 4),
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) {
if (value == null) return;
setState(() {
_selectedCategoryId = int.tryParse(value);
final current = _selectedProduct();
final currentCategoryId = (current?['categoryId'] as num?)?.toInt();
if (currentCategoryId != _selectedCategoryId) {
_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),
),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -154,7 +154,7 @@ class _InventoryScreenState extends ConsumerState<InventoryScreen> {
final units = <String>[];
final seenUnits = <String>{};
for (final item in selectedItems) {
final unit = (item.unit as String).trim();
final unit = item.unit.trim();
final key = unit.toLowerCase();
if (seenUnits.add(key)) {
units.add(unit);