feat: implement two-step category and product picker for enhanced selection

This commit is contained in:
Nils-Johan Gynther
2026-05-01 02:19:13 +02:00
parent 62667fd76d
commit 1fd910b561
2 changed files with 397 additions and 41 deletions
@@ -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,27 +93,15 @@ 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: 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;
});
} 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(
// 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,
value: _productId,
label: 'Produkt',
initialQuery: item.rawName,
categoryFilter: catId != null ? {catId} : null,
).then((id) {
currentProductId: _productId,
preselectedCategoryId: preselectedCategoryId,
);
if (id != null && mounted) {
setState(() {
_productId = id;
@@ -118,7 +111,20 @@ class _EditDialogState extends State<_EditDialog> {
?.name;
});
}
}
// 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;
});
} else {
// Öppna kategori → produkt med AI-föreslagen kategori förvald
openCategoryPicker(preselectedCategoryId: item.categorySuggestionId);
}
}
@@ -141,7 +147,7 @@ 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,
),
],
@@ -167,7 +173,11 @@ class _EditDialogState extends State<_EditDialog> {
style: const ButtonStyle(visualDensity: VisualDensity.compact),
),
const SizedBox(height: 12),
ProductPickerField(
// Produktval: sök direkt eller välj via kategoriträd
Row(
children: [
Expanded(
child: ProductPickerField(
products: widget.products,
value: _productId,
label: 'Produkt',
@@ -183,6 +193,21 @@ class _EditDialogState extends State<_EditDialog> {
});
},
),
),
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) ...[
Row(children: [
@@ -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(() {