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();
}
Future<void> _openCreateCategoryPicker() async {
Future<void> _openCreateCategoryPicker({int? preselectedCategoryId}) async {
final selected = await CategoryThenProductPicker.showCategorySheet(
context,
categoryTree: widget.categoryTree,
preselectedCategoryId: _newCategoryId ?? widget.item.categorySuggestionId,
preselectedCategoryId:
preselectedCategoryId ?? _newCategoryId ?? widget.item.categorySuggestionId,
);
if (selected == null || !mounted) return;
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 {
if (_entryMode == _ProductEntryMode.create) {
return !_isCreatingProduct &&
@@ -193,66 +229,9 @@ class _EditDialogState extends State<_EditDialog> {
final item = widget.item;
final aiCategory = item.categorySuggestionName;
final aiPath = item.categorySuggestionPath;
// 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 {
// 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);
}
}
final aiLabel = (aiPath != null && aiPath.isNotEmpty)
? aiPath
: ((aiCategory != null && aiCategory.isNotEmpty) ? aiCategory : null);
return AlertDialog(
title: Text(item.rawName, maxLines: 2, overflow: TextOverflow.ellipsis),
@@ -261,25 +240,6 @@ class _EditDialogState extends State<_EditDialog> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
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
SegmentedButton<_Destination>(
segments: const [
@@ -336,6 +296,10 @@ class _EditDialogState extends State<_EditDialog> {
.cast<ProductOption?>()
.firstWhere((p) => p?.id == id, orElse: () => null)
?.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),
padding: EdgeInsets.zero,
),
onPressed: () => openCategoryPicker(),
onPressed: () => _openExistingCategoryPicker(),
child: const Icon(Icons.account_tree_outlined, size: 20),
),
),
@@ -406,9 +370,7 @@ class _EditDialogState extends State<_EditDialog> {
],
),
),
],
if (item.categorySuggestionPath != null &&
item.categorySuggestionPath!.isNotEmpty) ...[
if (aiLabel != null) ...[
const SizedBox(height: 8),
Wrap(
children: [
@@ -419,7 +381,7 @@ class _EditDialogState extends State<_EditDialog> {
color: Colors.green.shade700,
),
label: Text(
'AI-förslag: ${item.categorySuggestionPath}',
'AI-forslag: $aiLabel',
style: theme.textTheme.labelSmall,
),
backgroundColor: Colors.green.shade50,
@@ -427,16 +389,15 @@ class _EditDialogState extends State<_EditDialog> {
visualDensity: VisualDensity.compact,
onPressed: item.categorySuggestionId == null
? null
: () => setState(() {
_newCategoryId = item.categorySuggestionId;
_newCategoryPath = item.categorySuggestionPath;
_newCategorySource = CategorySelectionSource.ai;
}),
: () => _openCreateCategoryPicker(
preselectedCategoryId: item.categorySuggestionId,
),
),
],
),
],
],
],
const SizedBox(height: 12),
if (_destination == _Destination.inventory) ...[
Row(children: [
@@ -538,8 +499,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
? mineData
: ((mineData as Map<String, dynamic>?)?['items'] as List? ?? []);
if (mounted) {
setState(() {
_products = [
final mergedProducts = [
...globalList
.cast<Map<String, dynamic>>()
.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>>()
.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>;
});
}
} 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 {
@@ -621,6 +595,9 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
productName: name,
categoryId: it.categorySuggestionId,
categoryPath: it.categorySuggestionPath,
categorySource: it.categorySuggestionId != null
? CategorySelectionSource.ai
: null,
quantity: it.quantity,
unit: it.unit,
));
@@ -646,6 +623,9 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
productName: item.matchedProductName ?? item.suggestedProductName,
categoryId: item.categorySuggestionId,
categoryPath: item.categorySuggestionPath,
categorySource: item.categorySuggestionId != null
? CategorySelectionSource.ai
: null,
quantity: item.quantity,
unit: item.unit,
);
@@ -866,7 +846,8 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
),
],
// ── Förhandsvisning av kvitto ────────────────────────────────────────
if (session?.fileBytes != null) ...[ const SizedBox(height: 12),
if (session?.fileBytes != null) ...[
const SizedBox(height: 12),
_buildReceiptPreview(theme, session!),
],
const SizedBox(height: 24),