feat: Enhance inventory screens with category selection and product loading 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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user