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();
|
||||
}
|
||||
|
||||
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,35 +370,32 @@ class _EditDialogState extends State<_EditDialog> {
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
if (item.categorySuggestionPath != null &&
|
||||
item.categorySuggestionPath!.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
children: [
|
||||
ActionChip(
|
||||
avatar: Icon(
|
||||
Icons.auto_awesome,
|
||||
size: 14,
|
||||
color: Colors.green.shade700,
|
||||
if (aiLabel != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
children: [
|
||||
ActionChip(
|
||||
avatar: Icon(
|
||||
Icons.auto_awesome,
|
||||
size: 14,
|
||||
color: Colors.green.shade700,
|
||||
),
|
||||
label: Text(
|
||||
'AI-forslag: $aiLabel',
|
||||
style: theme.textTheme.labelSmall,
|
||||
),
|
||||
backgroundColor: Colors.green.shade50,
|
||||
side: BorderSide(color: Colors.green.shade300),
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: item.categorySuggestionId == null
|
||||
? null
|
||||
: () => _openCreateCategoryPicker(
|
||||
preselectedCategoryId: item.categorySuggestionId,
|
||||
),
|
||||
),
|
||||
label: Text(
|
||||
'AI-förslag: ${item.categorySuggestionPath}',
|
||||
style: theme.textTheme.labelSmall,
|
||||
),
|
||||
backgroundColor: Colors.green.shade50,
|
||||
side: BorderSide(color: Colors.green.shade300),
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: item.categorySuggestionId == null
|
||||
? null
|
||||
: () => setState(() {
|
||||
_newCategoryId = item.categorySuggestionId;
|
||||
_newCategoryPath = item.categorySuggestionPath;
|
||||
_newCategorySource = CategorySelectionSource.ai;
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
@@ -538,19 +499,32 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
? mineData
|
||||
: ((mineData as Map<String, dynamic>?)?['items'] as List? ?? []);
|
||||
if (mounted) {
|
||||
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())),
|
||||
...mineList
|
||||
.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 = [
|
||||
...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())),
|
||||
...mineList
|
||||
.cast<Map<String, dynamic>>()
|
||||
.map((e) => (id: e['id'] as int, name: (e['canonicalName'] ?? e['name']) as String, categoryId: (e['categoryId'] as num?)?.toInt())),
|
||||
];
|
||||
_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),
|
||||
|
||||
Reference in New Issue
Block a user