feat: enhance category picker functionality with preselection support and new existing category picker
This commit is contained in:
@@ -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),
|
||||||
|
|||||||
Reference in New Issue
Block a user