diff --git a/flutter/lib/core/ui/category_then_product_picker.dart b/flutter/lib/core/ui/category_then_product_picker.dart new file mode 100644 index 00000000..8cdf5d8a --- /dev/null +++ b/flutter/lib/core/ui/category_then_product_picker.dart @@ -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 _collectIds(AdminCategoryNode node) { + final ids = {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 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 show( + BuildContext context, { + required List categoryTree, + required List products, + int? currentProductId, + int? preselectedCategoryId, + }) async { + // Steg 1 — välj kategori + final selectedCategory = await showModalBottomSheet( + 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 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 nodes, + List 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 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, + )), + ], + ), + ); + } +} diff --git a/flutter/lib/features/import/presentation/receipt_import_tab.dart b/flutter/lib/features/import/presentation/receipt_import_tab.dart index 919b71fd..04ffd996 100644 --- a/flutter/lib/features/import/presentation/receipt_import_tab.dart +++ b/flutter/lib/features/import/presentation/receipt_import_tab.dart @@ -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 products; + final List 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 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() + .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() - .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() - .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() + .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 { List _products = []; bool _loadingProducts = false; + // Kategoriträdet för tvåstegs-picker + List _categoryTree = []; + // Befintligt inventarie: productId → InventoryItem (för sammanslagning) Map _inventoryByProduct = {}; @@ -272,7 +300,12 @@ class _ReceiptImportTabState extends ConsumerState { 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?)?['items'] as List? ?? []); if (mounted) { setState(() { @@ -280,6 +313,7 @@ class _ReceiptImportTabState extends ConsumerState { .cast>() .map((e) => (id: e['id'] as int, name: e['name'] as String, categoryId: (e['categoryId'] as num?)?.toInt())) .toList(); + _categoryTree = results[1] as List; _loadingProducts = false; }); } @@ -379,7 +413,7 @@ class _ReceiptImportTabState extends ConsumerState { 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(() {