feat: implement two-step category and product picker for enhanced selection
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'product_picker_field.dart';
|
||||
import '../../features/admin/domain/admin_category_node.dart';
|
||||
|
||||
/// Tvåstegs-picker: välj kategori i trädet → välj produkt i den kategorin.
|
||||
///
|
||||
/// Returnerar det valda produkt-id:t, eller null om användaren avbryter.
|
||||
///
|
||||
/// Anropas via [CategoryThenProductPicker.show].
|
||||
class CategoryThenProductPicker {
|
||||
CategoryThenProductPicker._();
|
||||
|
||||
/// Samlar alla ID:n för [node] och alla dess ättlingar rekursivt.
|
||||
static Set<int> _collectIds(AdminCategoryNode node) {
|
||||
final ids = <int>{node.id};
|
||||
for (final child in node.children) {
|
||||
ids.addAll(_collectIds(child));
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/// Hittar en nod med givet id djupet i trädet.
|
||||
static AdminCategoryNode? _findNode(List<AdminCategoryNode> nodes, int id) {
|
||||
for (final node in nodes) {
|
||||
if (node.id == id) return node;
|
||||
final found = _findNode(node.children, id);
|
||||
if (found != null) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Öppnar ett bottenark med kategoriträdet. Klick på en lövnod (inga barn)
|
||||
/// öppnar direkt produktpickern för den kategorin.
|
||||
/// Klick på en mellannodens namn expanderar/kollapsar dess barn.
|
||||
///
|
||||
/// [preselectedCategoryId] — om satt scrollas trädet till den noden och den
|
||||
/// markeras visuellt. Användaren kan fortfarande välja en annan kategori.
|
||||
static Future<int?> show(
|
||||
BuildContext context, {
|
||||
required List<AdminCategoryNode> categoryTree,
|
||||
required List<ProductOption> products,
|
||||
int? currentProductId,
|
||||
int? preselectedCategoryId,
|
||||
}) async {
|
||||
// Steg 1 — välj kategori
|
||||
final selectedCategory = await showModalBottomSheet<AdminCategoryNode>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
builder: (ctx) => _CategoryPickerSheet(
|
||||
tree: categoryTree,
|
||||
preselectedId: preselectedCategoryId,
|
||||
onSelected: (node) => Navigator.pop(ctx, node),
|
||||
),
|
||||
);
|
||||
if (selectedCategory == null || !context.mounted) return null;
|
||||
|
||||
// Samla alla kategori-IDs i den valda grenen (inkl. ättlingar)
|
||||
final categoryIds = _collectIds(selectedCategory);
|
||||
|
||||
// Filtrera produkter på dessa kategorier
|
||||
final filtered = products
|
||||
.where((p) => p.categoryId != null && categoryIds.contains(p.categoryId))
|
||||
.toList();
|
||||
final useList = filtered.isNotEmpty ? filtered : products;
|
||||
|
||||
// Steg 2 — välj produkt
|
||||
if (!context.mounted) return null;
|
||||
return ProductPickerField.showSheet(
|
||||
context,
|
||||
products: useList,
|
||||
value: currentProductId,
|
||||
label: 'Produkt i "${selectedCategory.name}"',
|
||||
categoryFilter: null, // redan förfiltrerat
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Kategoriträdets bottenark ────────────────────────────────────────────────
|
||||
|
||||
class _CategoryPickerSheet extends StatefulWidget {
|
||||
final List<AdminCategoryNode> tree;
|
||||
final int? preselectedId;
|
||||
final void Function(AdminCategoryNode node) onSelected;
|
||||
|
||||
const _CategoryPickerSheet({
|
||||
required this.tree,
|
||||
required this.onSelected,
|
||||
this.preselectedId,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_CategoryPickerSheet> createState() => _CategoryPickerSheetState();
|
||||
}
|
||||
|
||||
class _CategoryPickerSheetState extends State<_CategoryPickerSheet> {
|
||||
String _query = '';
|
||||
late final TextEditingController _ctrl;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ctrl = TextEditingController();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ctrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Returnerar alla lövnoder (inga barn) som matchar sökordet.
|
||||
List<_FlatCategory> _flatLeaves(
|
||||
List<AdminCategoryNode> nodes,
|
||||
List<String> parentNames,
|
||||
) {
|
||||
final result = <_FlatCategory>[];
|
||||
for (final node in nodes) {
|
||||
final path = [...parentNames, node.name];
|
||||
if (node.children.isEmpty) {
|
||||
result.add(_FlatCategory(node: node, path: path));
|
||||
} else {
|
||||
result.addAll(_flatLeaves(node.children, path));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final q = _query.trim().toLowerCase();
|
||||
|
||||
return SizedBox(
|
||||
height: MediaQuery.of(context).size.height * 0.88,
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text('Välj kategori', style: theme.textTheme.titleMedium),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
child: TextField(
|
||||
controller: _ctrl,
|
||||
autofocus: false,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Sök kategori...',
|
||||
prefixIcon: Icon(Icons.search),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onChanged: (v) => setState(() => _query = v),
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: q.isEmpty
|
||||
? ListView(
|
||||
children: widget.tree
|
||||
.map((n) => _CategoryTile(
|
||||
node: n,
|
||||
depth: 0,
|
||||
preselectedId: widget.preselectedId,
|
||||
onLeafTap: widget.onSelected,
|
||||
))
|
||||
.toList(),
|
||||
)
|
||||
: _buildSearchResults(q, theme),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSearchResults(String q, ThemeData theme) {
|
||||
final leaves = _flatLeaves(widget.tree, [])
|
||||
.where((fc) => fc.path.any((p) => p.toLowerCase().contains(q)))
|
||||
.toList()
|
||||
..sort((a, b) => a.path.last.toLowerCase().compareTo(b.path.last.toLowerCase()));
|
||||
|
||||
if (leaves.isEmpty) {
|
||||
return const Center(child: Text('Inga kategorier matchar sökningen.'));
|
||||
}
|
||||
return ListView.separated(
|
||||
itemCount: leaves.length,
|
||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||
itemBuilder: (ctx, i) {
|
||||
final fc = leaves[i];
|
||||
return ListTile(
|
||||
title: Text(fc.node.name),
|
||||
subtitle: Text(
|
||||
fc.path.take(fc.path.length - 1).join(' › '),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
selected: fc.node.id == widget.preselectedId,
|
||||
onTap: () => widget.onSelected(fc.node),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FlatCategory {
|
||||
final AdminCategoryNode node;
|
||||
final List<String> path;
|
||||
const _FlatCategory({required this.node, required this.path});
|
||||
}
|
||||
|
||||
// ── Rekursiv kategoritile med expand/kollaps ─────────────────────────────────
|
||||
|
||||
class _CategoryTile extends StatefulWidget {
|
||||
final AdminCategoryNode node;
|
||||
final int depth;
|
||||
final int? preselectedId;
|
||||
final void Function(AdminCategoryNode) onLeafTap;
|
||||
|
||||
const _CategoryTile({
|
||||
required this.node,
|
||||
required this.depth,
|
||||
required this.onLeafTap,
|
||||
this.preselectedId,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_CategoryTile> createState() => _CategoryTileState();
|
||||
}
|
||||
|
||||
class _CategoryTileState extends State<_CategoryTile> {
|
||||
late bool _expanded;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Expandera automatiskt om den förevalda noden finns i denna gren
|
||||
_expanded = widget.preselectedId != null &&
|
||||
_containsId(widget.node, widget.preselectedId!);
|
||||
}
|
||||
|
||||
bool _containsId(AdminCategoryNode node, int id) {
|
||||
if (node.id == id) return true;
|
||||
return node.children.any((c) => _containsId(c, id));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final node = widget.node;
|
||||
final isLeaf = node.children.isEmpty;
|
||||
final isPreselected = node.id == widget.preselectedId;
|
||||
final indent = widget.depth * 16.0;
|
||||
|
||||
if (isLeaf) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(left: indent),
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
selected: isPreselected,
|
||||
selectedColor: theme.colorScheme.primary,
|
||||
selectedTileColor: theme.colorScheme.primaryContainer.withOpacity(0.3),
|
||||
leading: Icon(
|
||||
Icons.label_outline,
|
||||
size: 16,
|
||||
color: isPreselected ? theme.colorScheme.primary : theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
title: Text(
|
||||
node.name,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: isPreselected ? FontWeight.bold : null,
|
||||
),
|
||||
),
|
||||
onTap: () => widget.onLeafTap(node),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final sortedChildren = [...node.children]
|
||||
..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(left: indent),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ListTile(
|
||||
dense: widget.depth > 0,
|
||||
leading: Icon(
|
||||
_expanded ? Icons.expand_less : Icons.chevron_right,
|
||||
size: 18,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
title: Text(
|
||||
node.name,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
onTap: () => setState(() => _expanded = !_expanded),
|
||||
),
|
||||
if (_expanded)
|
||||
...sortedChildren.map((child) => _CategoryTile(
|
||||
node: child,
|
||||
depth: widget.depth + 1,
|
||||
preselectedId: widget.preselectedId,
|
||||
onLeafTap: widget.onLeafTap,
|
||||
)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(() {
|
||||
|
||||
Reference in New Issue
Block a user