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/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/product_picker_field.dart'; import '../../../core/ui/product_picker_field.dart';
import '../../../core/ui/searchable_category_field.dart';
import '../../auth/data/auth_providers.dart'; import '../../auth/data/auth_providers.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;
@@ -34,7 +37,10 @@ class _CreateInventoryScreenState
final _commentController = TextEditingController(); final _commentController = TextEditingController();
int? _selectedProductId; int? _selectedProductId;
int? _selectedCategoryId;
List<Map<String, dynamic>> _products = []; List<Map<String, dynamic>> _products = [];
List<AdminCategoryNode> _categoryTree = [];
List<CategorySelectOption> _categoryOptions = [];
bool _loadingProducts = false; bool _loadingProducts = false;
DateTime? _purchaseDate; DateTime? _purchaseDate;
DateTime? _bestBeforeDate; DateTime? _bestBeforeDate;
@@ -48,7 +54,7 @@ class _CreateInventoryScreenState
if (widget.initialDestination == 'pantry') { if (widget.initialDestination == 'pantry') {
_destination = ImportDestination.pantry; _destination = ImportDestination.pantry;
} }
_loadProducts(); _loadProductsAndCategories();
} }
@override @override
@@ -61,22 +67,34 @@ class _CreateInventoryScreenState
super.dispose(); super.dispose();
} }
Future<void> _loadProducts() async { Future<void> _loadProductsAndCategories() async {
setState(() => _loadingProducts = true); setState(() => _loadingProducts = true);
try { try {
final token = await ref.read(authStateProvider.future); final token = await ref.read(authStateProvider.future);
final api = ref.read(apiClientProvider); final api = ref.read(apiClientProvider);
final data = await api.getJson(ProductApiPaths.mine, token: token); final results = await Future.wait<dynamic>([
final list = data is List<dynamic> api.getJson(ProductApiPaths.mine, token: token),
? data api.getJson(CategoryApiPaths.tree, token: token),
: (data is Map<String, dynamic> && data['items'] is List<dynamic>) ]);
? data['items'] as List<dynamic> 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>[]; : const <dynamic>[];
if (mounted) { if (mounted) {
setState(() { setState(() {
_products = list _products = list.map((e) => e as Map<String, dynamic>).toList();
.map((e) => e as Map<String, dynamic>) _categoryTree = categoryList
.map((e) => AdminCategoryNode.fromJson(Map<String, dynamic>.from(e as Map)))
.toList(); .toList();
_categoryOptions = _flattenCategoryOptions(_categoryTree);
_loadingProducts = false; _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 { Future<void> _pickDate(bool isBestBefore) async {
final picked = await showDatePicker( final picked = await showDatePicker(
context: context, context: context,
@@ -182,21 +272,7 @@ class _CreateInventoryScreenState
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final sortedProducts = [..._products] final productOptions = _productOptions();
..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 isInventory = _destination == ImportDestination.inventory; final isInventory = _destination == ImportDestination.inventory;
@@ -226,6 +302,47 @@ class _CreateInventoryScreenState
: (selected) => setState(() => _destination = selected.first), : (selected) => setState(() => _destination = selected.first),
), ),
const SizedBox(height: 16), 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( ProductPickerField(
products: productOptions, products: productOptions,
value: _selectedProductId, value: _selectedProductId,
@@ -3,11 +3,17 @@ 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/api/api_paths.dart';
import '../../../core/api/api_providers.dart';
import '../../../core/utils/formatters.dart'; import '../../../core/utils/formatters.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/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 '../../auth/data/auth_providers.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';
@@ -30,10 +36,16 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
final _commentController = TextEditingController(); final _commentController = TextEditingController();
bool _initialized = false; bool _initialized = false;
bool _loadingProducts = false;
bool _opened = false; bool _opened = false;
DateTime? _purchaseDate; DateTime? _purchaseDate;
DateTime? _bestBeforeDate; DateTime? _bestBeforeDate;
bool _saving = false; bool _saving = false;
int? _selectedProductId;
int? _selectedCategoryId;
List<Map<String, dynamic>> _products = [];
List<AdminCategoryNode> _categoryTree = [];
List<CategorySelectOption> _categoryOptions = [];
@override @override
void dispose() { void dispose() {
@@ -47,6 +59,7 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
void _initControllers(InventoryItem item) { void _initControllers(InventoryItem item) {
_initialized = true; _initialized = true;
_selectedProductId = item.productId;
_quantityController.text = item.quantity.toString(); _quantityController.text = item.quantity.toString();
_unitController.text = item.unit; _unitController.text = item.unit;
_locationController.text = item.location ?? ''; _locationController.text = item.location ?? '';
@@ -60,6 +73,132 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
: null; : 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 { Future<void> _pickDate(bool isBestBefore) async {
final picked = await showDatePicker( final picked = await showDatePicker(
context: context, context: context,
@@ -84,6 +223,7 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
try { try {
final token = await ref.read(authStateProvider.future); final token = await ref.read(authStateProvider.future);
final body = <String, dynamic>{ final body = <String, dynamic>{
'productId': _selectedProductId,
'quantity': 'quantity':
double.parse(_quantityController.text.trim().replaceAll(',', '.')), double.parse(_quantityController.text.trim().replaceAll(',', '.')),
'unit': _unitController.text.trim(), 'unit': _unitController.text.trim(),
@@ -139,11 +279,61 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
child: ListView( child: ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
Text( Text(item.displayName, style: Theme.of(context).textTheme.titleMedium),
item.productName, if (item.categoryPath != null && item.categoryPath!.trim().isNotEmpty) ...[
style: Theme.of(context).textTheme.titleMedium, const SizedBox(height: 4),
), Text(item.categoryPath!, style: Theme.of(context).textTheme.bodySmall),
],
const SizedBox(height: 16), 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( Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -154,7 +154,7 @@ class _InventoryScreenState extends ConsumerState<InventoryScreen> {
final units = <String>[]; final units = <String>[];
final seenUnits = <String>{}; final seenUnits = <String>{};
for (final item in selectedItems) { for (final item in selectedItems) {
final unit = (item.unit as String).trim(); final unit = item.unit.trim();
final key = unit.toLowerCase(); final key = unit.toLowerCase();
if (seenUnits.add(key)) { if (seenUnits.add(key)) {
units.add(unit); units.add(unit);