feat: enhance category picker functionality with preselection support and new existing category picker

This commit is contained in:
Nils-Johan Gynther
2026-05-01 23:05:01 +02:00
parent 4cbd658fa0
commit 32e83caa35
@@ -125,11 +125,12 @@ class _EditDialogState extends State<_EditDialog> {
super.dispose(); super.dispose();
} }
Future<void> _openCreateCategoryPicker() async { Future<void> _openCreateCategoryPicker({int? preselectedCategoryId}) async {
final selected = await CategoryThenProductPicker.showCategorySheet( final selected = await CategoryThenProductPicker.showCategorySheet(
context, context,
categoryTree: widget.categoryTree, categoryTree: widget.categoryTree,
preselectedCategoryId: _newCategoryId ?? widget.item.categorySuggestionId, preselectedCategoryId:
preselectedCategoryId ?? _newCategoryId ?? widget.item.categorySuggestionId,
); );
if (selected == null || !mounted) return; if (selected == null || !mounted) return;
setState(() { setState(() {
@@ -139,6 +140,41 @@ class _EditDialogState extends State<_EditDialog> {
}); });
} }
Future<void> _openExistingCategoryPicker({int? preselectedCategoryId}) async {
Future<ProductOption?> Function(String, int)? onCreateWrapped;
if (widget.onCreate != null) {
onCreateWrapped = (name, categoryId) async {
final newProduct = await widget.onCreate!(name, categoryId);
if (newProduct != null && mounted) {
setState(() => _localProducts = [..._localProducts, newProduct]);
}
return newProduct;
};
}
final id = await CategoryThenProductPicker.show(
context,
categoryTree: widget.categoryTree,
products: _localProducts,
currentProductId: _productId,
preselectedCategoryId: preselectedCategoryId,
initialQuery: widget.item.rawName,
onCreate: onCreateWrapped,
);
if (id != null && mounted) {
setState(() {
_productId = id;
_productName = _localProducts
.cast<ProductOption?>()
.firstWhere((p) => p?.id == id, orElse: () => null)
?.name;
_productCategoryId = _categoryIdForProduct(id);
_productCategoryPath = _categoryPathForCategoryId(_productCategoryId);
_productCategorySource = CategorySelectionSource.manual;
});
}
}
bool get _canConfirm { bool get _canConfirm {
if (_entryMode == _ProductEntryMode.create) { if (_entryMode == _ProductEntryMode.create) {
return !_isCreatingProduct && return !_isCreatingProduct &&
@@ -193,66 +229,9 @@ class _EditDialogState extends State<_EditDialog> {
final item = widget.item; final item = widget.item;
final aiCategory = item.categorySuggestionName; final aiCategory = item.categorySuggestionName;
final aiPath = item.categorySuggestionPath; final aiPath = item.categorySuggestionPath;
// Visa hela sökvägen om det finns, annars bara kategorinamnet final aiLabel = (aiPath != null && aiPath.isNotEmpty)
final aiLabel = aiPath != null && aiPath.isNotEmpty ? aiPath : aiCategory; ? aiPath
: ((aiCategory != null && aiCategory.isNotEmpty) ? aiCategory : null);
// Hjälpfunktion: välj produkt via tvåstegs-picker (kategori → produkt)
Future<void> openCategoryPicker({int? preselectedCategoryId}) async {
// onCreate-wrapper: lägg även till den nya produkten i _localProducts
Future<ProductOption?> Function(String, int)? onCreateWrapped;
if (widget.onCreate != null) {
onCreateWrapped = (name, categoryId) async {
final newProduct = await widget.onCreate!(name, categoryId);
if (newProduct != null && mounted) {
setState(() => _localProducts = [..._localProducts, newProduct]);
}
return newProduct;
};
}
final id = await CategoryThenProductPicker.show(
context,
categoryTree: widget.categoryTree,
products: _localProducts,
currentProductId: _productId,
preselectedCategoryId: preselectedCategoryId,
initialQuery: item.rawName,
onCreate: onCreateWrapped,
);
if (id != null && mounted) {
setState(() {
_productId = id;
_productName = _localProducts
.cast<ProductOption?>()
.firstWhere((p) => p?.id == id, orElse: () => null)
?.name;
_productCategoryId = _categoryIdForProduct(id);
_productCategoryPath = _categoryPathForCategoryId(_productCategoryId);
_productCategorySource = CategorySelectionSource.manual;
});
}
}
// Hjälpfunktion: acceptera AI-förslaget
void acceptAiSuggestion() {
final sugId = item.suggestedProductId;
if (sugId != null) {
// Välj den föreslagna produkten direkt
setState(() {
_productId = sugId;
_productName = item.suggestedProductName;
_productCategoryId = _categoryIdForProduct(sugId) ?? item.categorySuggestionId;
_productCategoryPath =
_categoryPathForCategoryId(_productCategoryId) ?? item.categorySuggestionPath;
_productCategorySource = item.categorySuggestionPath != null
? CategorySelectionSource.ai
: null;
});
} else {
// Öppna kategori → produkt med AI-föreslagen kategori förvald
openCategoryPicker(preselectedCategoryId: item.categorySuggestionId);
}
}
return AlertDialog( return AlertDialog(
title: Text(item.rawName, maxLines: 2, overflow: TextOverflow.ellipsis), title: Text(item.rawName, maxLines: 2, overflow: TextOverflow.ellipsis),
@@ -261,25 +240,6 @@ class _EditDialogState extends State<_EditDialog> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// AI-kategorisuggestion — klickbar
if (aiLabel != null) ...[
Wrap(
children: [
ActionChip(
avatar: Icon(Icons.auto_awesome, size: 14, color: Colors.green.shade700),
label: Text('AI: $aiLabel', style: theme.textTheme.labelSmall),
backgroundColor: Colors.green.shade50,
side: BorderSide(color: Colors.green.shade300),
visualDensity: VisualDensity.compact,
tooltip: item.suggestedProductId != null
? 'Välj "${item.suggestedProductName}" automatiskt'
: 'Bläddra produkter i kategorin "$aiCategory"',
onPressed: acceptAiSuggestion,
),
],
),
const SizedBox(height: 8),
],
// Destination // Destination
SegmentedButton<_Destination>( SegmentedButton<_Destination>(
segments: const [ segments: const [
@@ -336,6 +296,10 @@ class _EditDialogState extends State<_EditDialog> {
.cast<ProductOption?>() .cast<ProductOption?>()
.firstWhere((p) => p?.id == id, orElse: () => null) .firstWhere((p) => p?.id == id, orElse: () => null)
?.name; ?.name;
_productCategoryId = _categoryIdForProduct(id);
_productCategoryPath = _categoryPathForCategoryId(_productCategoryId);
_productCategorySource =
id == null ? null : CategorySelectionSource.manual;
}); });
}, },
), ),
@@ -348,7 +312,7 @@ class _EditDialogState extends State<_EditDialog> {
minimumSize: const Size(44, 56), minimumSize: const Size(44, 56),
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
), ),
onPressed: () => openCategoryPicker(), onPressed: () => _openExistingCategoryPicker(),
child: const Icon(Icons.account_tree_outlined, size: 20), child: const Icon(Icons.account_tree_outlined, size: 20),
), ),
), ),
@@ -406,9 +370,7 @@ class _EditDialogState extends State<_EditDialog> {
], ],
), ),
), ),
], if (aiLabel != null) ...[
if (item.categorySuggestionPath != null &&
item.categorySuggestionPath!.isNotEmpty) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
Wrap( Wrap(
children: [ children: [
@@ -419,7 +381,7 @@ class _EditDialogState extends State<_EditDialog> {
color: Colors.green.shade700, color: Colors.green.shade700,
), ),
label: Text( label: Text(
'AI-förslag: ${item.categorySuggestionPath}', 'AI-forslag: $aiLabel',
style: theme.textTheme.labelSmall, style: theme.textTheme.labelSmall,
), ),
backgroundColor: Colors.green.shade50, backgroundColor: Colors.green.shade50,
@@ -427,16 +389,15 @@ class _EditDialogState extends State<_EditDialog> {
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
onPressed: item.categorySuggestionId == null onPressed: item.categorySuggestionId == null
? null ? null
: () => setState(() { : () => _openCreateCategoryPicker(
_newCategoryId = item.categorySuggestionId; preselectedCategoryId: item.categorySuggestionId,
_newCategoryPath = item.categorySuggestionPath; ),
_newCategorySource = CategorySelectionSource.ai;
}),
), ),
], ],
), ),
], ],
], ],
],
const SizedBox(height: 12), const SizedBox(height: 12),
if (_destination == _Destination.inventory) ...[ if (_destination == _Destination.inventory) ...[
Row(children: [ Row(children: [
@@ -538,8 +499,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
? mineData ? mineData
: ((mineData as Map<String, dynamic>?)?['items'] as List? ?? []); : ((mineData as Map<String, dynamic>?)?['items'] as List? ?? []);
if (mounted) { if (mounted) {
setState(() { final mergedProducts = [
_products = [
...globalList ...globalList
.cast<Map<String, dynamic>>() .cast<Map<String, dynamic>>()
.map((e) => (id: e['id'] as int, name: (e['canonicalName'] ?? e['name']) as String, categoryId: (e['categoryId'] as num?)?.toInt())), .map((e) => (id: e['id'] as int, name: (e['canonicalName'] ?? e['name']) as String, categoryId: (e['categoryId'] as num?)?.toInt())),
@@ -547,10 +507,24 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
.cast<Map<String, dynamic>>() .cast<Map<String, dynamic>>()
.map((e) => (id: e['id'] as int, name: (e['canonicalName'] ?? e['name']) as String, categoryId: (e['categoryId'] as num?)?.toInt())), .map((e) => (id: e['id'] as int, name: (e['canonicalName'] ?? e['name']) as String, categoryId: (e['categoryId'] as num?)?.toInt())),
]; ];
final dedupedById = <int, ProductOption>{
for (final product in mergedProducts) product.id: product,
};
setState(() {
_products = dedupedById.values.toList();
_categoryTree = results[2] as List<AdminCategoryNode>; _categoryTree = results[2] as List<AdminCategoryNode>;
}); });
} }
} catch (_) {} } catch (e, st) {
debugPrint('ReceiptImportTab._loadProducts failed: $e');
debugPrintStack(stackTrace: st);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Kunde inte ladda produktlistan. Försök igen.')),
);
}
}
} }
Future<void> _loadInventory() async { Future<void> _loadInventory() async {
@@ -621,6 +595,9 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
productName: name, productName: name,
categoryId: it.categorySuggestionId, categoryId: it.categorySuggestionId,
categoryPath: it.categorySuggestionPath, categoryPath: it.categorySuggestionPath,
categorySource: it.categorySuggestionId != null
? CategorySelectionSource.ai
: null,
quantity: it.quantity, quantity: it.quantity,
unit: it.unit, unit: it.unit,
)); ));
@@ -646,6 +623,9 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
productName: item.matchedProductName ?? item.suggestedProductName, productName: item.matchedProductName ?? item.suggestedProductName,
categoryId: item.categorySuggestionId, categoryId: item.categorySuggestionId,
categoryPath: item.categorySuggestionPath, categoryPath: item.categorySuggestionPath,
categorySource: item.categorySuggestionId != null
? CategorySelectionSource.ai
: null,
quantity: item.quantity, quantity: item.quantity,
unit: item.unit, unit: item.unit,
); );
@@ -866,7 +846,8 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
), ),
], ],
// ── Förhandsvisning av kvitto ──────────────────────────────────────── // ── Förhandsvisning av kvitto ────────────────────────────────────────
if (session?.fileBytes != null) ...[ const SizedBox(height: 12), if (session?.fileBytes != null) ...[
const SizedBox(height: 12),
_buildReceiptPreview(theme, session!), _buildReceiptPreview(theme, session!),
], ],
const SizedBox(height: 24), const SizedBox(height: 24),