feat: implement two-step category and product picker for enhanced selection

This commit is contained in:
Nils-Johan Gynther
2026-05-01 02:19:13 +02:00
parent 62667fd76d
commit 1fd910b561
2 changed files with 397 additions and 41 deletions
@@ -4,8 +4,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/api/api_error_mapper.dart';
import '../../../core/api/api_paths.dart';
import '../../../core/api/api_providers.dart';
import '../../../core/ui/category_then_product_picker.dart';
import '../../../core/ui/product_picker_field.dart';
import '../../../core/utils/global_error_handler.dart';
import '../../admin/data/admin_repository.dart';
import '../../admin/domain/admin_category_node.dart';
import '../../auth/data/auth_providers.dart';
import '../../inventory/data/inventory_providers.dart';
import '../../inventory/domain/inventory_item.dart';
@@ -40,11 +43,13 @@ class _EditDialog extends StatefulWidget {
final ParsedReceiptItem item;
final _ItemEdit current;
final List<ProductOption> products;
final List<AdminCategoryNode> categoryTree;
const _EditDialog({
required this.item,
required this.current,
required this.products,
required this.categoryTree,
});
@override
@@ -88,6 +93,26 @@ class _EditDialogState extends State<_EditDialog> {
// Visa hela sökvägen om det finns, annars bara kategorinamnet
final aiLabel = aiPath != null && aiPath.isNotEmpty ? aiPath : aiCategory;
// Hjälpfunktion: välj produkt via tvåstegs-picker (kategori → produkt)
Future<void> openCategoryPicker({int? preselectedCategoryId}) async {
final id = await CategoryThenProductPicker.show(
context,
categoryTree: widget.categoryTree,
products: widget.products,
currentProductId: _productId,
preselectedCategoryId: preselectedCategoryId,
);
if (id != null && mounted) {
setState(() {
_productId = id;
_productName = widget.products
.cast<ProductOption?>()
.firstWhere((p) => p?.id == id, orElse: () => null)
?.name;
});
}
}
// Hjälpfunktion: acceptera AI-förslaget
void acceptAiSuggestion() {
final sugId = item.suggestedProductId;
@@ -97,28 +122,9 @@ class _EditDialogState extends State<_EditDialog> {
_productId = sugId;
_productName = item.suggestedProductName;
});
} else if (aiCategory != null) {
// Öppna pickern filtrerad på AI-föreslagen kategori (categoryId).
// Visar bara produkter i den kategorin (eller rawName-sökning om kategorin är tom).
final catId = item.categorySuggestionId;
ProductPickerField.showSheet(
context,
products: widget.products,
value: _productId,
label: 'Produkt',
initialQuery: item.rawName,
categoryFilter: catId != null ? {catId} : null,
).then((id) {
if (id != null && mounted) {
setState(() {
_productId = id;
_productName = widget.products
.cast<ProductOption?>()
.firstWhere((p) => p?.id == id, orElse: () => null)
?.name;
});
}
});
} else {
// Öppna kategori → produkt med AI-föreslagen kategori förvald
openCategoryPicker(preselectedCategoryId: item.categorySuggestionId);
}
}
@@ -141,14 +147,14 @@ class _EditDialogState extends State<_EditDialog> {
visualDensity: VisualDensity.compact,
tooltip: item.suggestedProductId != null
? 'Välj "${item.suggestedProductName}" automatiskt'
: 'Sök produkter i kategorin "$aiCategory"',
: 'Bläddra produkter i kategorin "$aiCategory"',
onPressed: acceptAiSuggestion,
),
],
),
const SizedBox(height: 8),
],
// Destination
// Destination
SegmentedButton<_Destination>(
segments: const [
ButtonSegment(
@@ -167,21 +173,40 @@ class _EditDialogState extends State<_EditDialog> {
style: const ButtonStyle(visualDensity: VisualDensity.compact),
),
const SizedBox(height: 12),
ProductPickerField(
products: widget.products,
value: _productId,
label: 'Produkt',
onChanged: (id) {
setState(() {
_productId = id;
_productName = id == null
? null
: widget.products
.cast<ProductOption?>()
.firstWhere((p) => p?.id == id, orElse: () => null)
?.name;
});
},
// Produktval: sök direkt eller välj via kategoriträd
Row(
children: [
Expanded(
child: ProductPickerField(
products: widget.products,
value: _productId,
label: 'Produkt',
onChanged: (id) {
setState(() {
_productId = id;
_productName = id == null
? null
: widget.products
.cast<ProductOption?>()
.firstWhere((p) => p?.id == id, orElse: () => null)
?.name;
});
},
),
),
const SizedBox(width: 8),
Tooltip(
message: 'Välj via kategori',
child: OutlinedButton(
style: OutlinedButton.styleFrom(
minimumSize: const Size(44, 56),
padding: EdgeInsets.zero,
),
onPressed: () => openCategoryPicker(),
child: const Icon(Icons.account_tree_outlined, size: 20),
),
),
],
),
const SizedBox(height: 12),
if (_destination == _Destination.inventory) ...[
@@ -255,6 +280,9 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
List<ProductOption> _products = [];
bool _loadingProducts = false;
// Kategoriträdet för tvåstegs-picker
List<AdminCategoryNode> _categoryTree = [];
// Befintligt inventarie: productId → InventoryItem (för sammanslagning)
Map<int, InventoryItem> _inventoryByProduct = {};
@@ -272,7 +300,12 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
try {
final token = await ref.read(authStateProvider.future);
final api = ref.read(apiClientProvider);
final data = await api.getJson(ProductApiPaths.list, token: token);
final adminRepo = ref.read(adminRepositoryProvider);
final results = await Future.wait([
api.getJson(ProductApiPaths.list, token: token),
adminRepo.listCategoryTree(),
]);
final data = results[0];
final list = data is List ? data : ((data as Map<String, dynamic>?)?['items'] as List? ?? []);
if (mounted) {
setState(() {
@@ -280,6 +313,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
.cast<Map<String, dynamic>>()
.map((e) => (id: e['id'] as int, name: e['name'] as String, categoryId: (e['categoryId'] as num?)?.toInt()))
.toList();
_categoryTree = results[1] as List<AdminCategoryNode>;
_loadingProducts = false;
});
}
@@ -379,7 +413,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
final result = await showDialog<_ItemEdit>(
context: context,
builder: (_) => _EditDialog(item: item, current: current, products: _products),
builder: (_) => _EditDialog(item: item, current: current, products: _products, categoryTree: _categoryTree),
);
if (result != null && mounted) {
setState(() {