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_error_mapper.dart';
|
||||||
import '../../../core/api/api_paths.dart';
|
import '../../../core/api/api_paths.dart';
|
||||||
import '../../../core/api/api_providers.dart';
|
import '../../../core/api/api_providers.dart';
|
||||||
|
import '../../../core/ui/category_then_product_picker.dart';
|
||||||
import '../../../core/ui/product_picker_field.dart';
|
import '../../../core/ui/product_picker_field.dart';
|
||||||
import '../../../core/utils/global_error_handler.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 '../../auth/data/auth_providers.dart';
|
||||||
import '../../inventory/data/inventory_providers.dart';
|
import '../../inventory/data/inventory_providers.dart';
|
||||||
import '../../inventory/domain/inventory_item.dart';
|
import '../../inventory/domain/inventory_item.dart';
|
||||||
@@ -40,11 +43,13 @@ class _EditDialog extends StatefulWidget {
|
|||||||
final ParsedReceiptItem item;
|
final ParsedReceiptItem item;
|
||||||
final _ItemEdit current;
|
final _ItemEdit current;
|
||||||
final List<ProductOption> products;
|
final List<ProductOption> products;
|
||||||
|
final List<AdminCategoryNode> categoryTree;
|
||||||
|
|
||||||
const _EditDialog({
|
const _EditDialog({
|
||||||
required this.item,
|
required this.item,
|
||||||
required this.current,
|
required this.current,
|
||||||
required this.products,
|
required this.products,
|
||||||
|
required this.categoryTree,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -88,6 +93,26 @@ class _EditDialogState extends State<_EditDialog> {
|
|||||||
// Visa hela sökvägen om det finns, annars bara kategorinamnet
|
// Visa hela sökvägen om det finns, annars bara kategorinamnet
|
||||||
final aiLabel = aiPath != null && aiPath.isNotEmpty ? aiPath : aiCategory;
|
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
|
// Hjälpfunktion: acceptera AI-förslaget
|
||||||
void acceptAiSuggestion() {
|
void acceptAiSuggestion() {
|
||||||
final sugId = item.suggestedProductId;
|
final sugId = item.suggestedProductId;
|
||||||
@@ -97,28 +122,9 @@ class _EditDialogState extends State<_EditDialog> {
|
|||||||
_productId = sugId;
|
_productId = sugId;
|
||||||
_productName = item.suggestedProductName;
|
_productName = item.suggestedProductName;
|
||||||
});
|
});
|
||||||
} else if (aiCategory != null) {
|
} else {
|
||||||
// Öppna pickern filtrerad på AI-föreslagen kategori (categoryId).
|
// Öppna kategori → produkt med AI-föreslagen kategori förvald
|
||||||
// Visar bara produkter i den kategorin (eller rawName-sökning om kategorin är tom).
|
openCategoryPicker(preselectedCategoryId: item.categorySuggestionId);
|
||||||
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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,14 +147,14 @@ class _EditDialogState extends State<_EditDialog> {
|
|||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
tooltip: item.suggestedProductId != null
|
tooltip: item.suggestedProductId != null
|
||||||
? 'Välj "${item.suggestedProductName}" automatiskt'
|
? 'Välj "${item.suggestedProductName}" automatiskt'
|
||||||
: 'Sök produkter i kategorin "$aiCategory"',
|
: 'Bläddra produkter i kategorin "$aiCategory"',
|
||||||
onPressed: acceptAiSuggestion,
|
onPressed: acceptAiSuggestion,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
],
|
],
|
||||||
// Destination
|
// Destination
|
||||||
SegmentedButton<_Destination>(
|
SegmentedButton<_Destination>(
|
||||||
segments: const [
|
segments: const [
|
||||||
ButtonSegment(
|
ButtonSegment(
|
||||||
@@ -167,21 +173,40 @@ class _EditDialogState extends State<_EditDialog> {
|
|||||||
style: const ButtonStyle(visualDensity: VisualDensity.compact),
|
style: const ButtonStyle(visualDensity: VisualDensity.compact),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
ProductPickerField(
|
// Produktval: sök direkt eller välj via kategoriträd
|
||||||
products: widget.products,
|
Row(
|
||||||
value: _productId,
|
children: [
|
||||||
label: 'Produkt',
|
Expanded(
|
||||||
onChanged: (id) {
|
child: ProductPickerField(
|
||||||
setState(() {
|
products: widget.products,
|
||||||
_productId = id;
|
value: _productId,
|
||||||
_productName = id == null
|
label: 'Produkt',
|
||||||
? null
|
onChanged: (id) {
|
||||||
: widget.products
|
setState(() {
|
||||||
.cast<ProductOption?>()
|
_productId = id;
|
||||||
.firstWhere((p) => p?.id == id, orElse: () => null)
|
_productName = id == null
|
||||||
?.name;
|
? 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),
|
const SizedBox(height: 12),
|
||||||
if (_destination == _Destination.inventory) ...[
|
if (_destination == _Destination.inventory) ...[
|
||||||
@@ -255,6 +280,9 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
List<ProductOption> _products = [];
|
List<ProductOption> _products = [];
|
||||||
bool _loadingProducts = false;
|
bool _loadingProducts = false;
|
||||||
|
|
||||||
|
// Kategoriträdet för tvåstegs-picker
|
||||||
|
List<AdminCategoryNode> _categoryTree = [];
|
||||||
|
|
||||||
// Befintligt inventarie: productId → InventoryItem (för sammanslagning)
|
// Befintligt inventarie: productId → InventoryItem (för sammanslagning)
|
||||||
Map<int, InventoryItem> _inventoryByProduct = {};
|
Map<int, InventoryItem> _inventoryByProduct = {};
|
||||||
|
|
||||||
@@ -272,7 +300,12 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
try {
|
try {
|
||||||
final token = await ref.read(authStateProvider.future);
|
final token = await ref.read(authStateProvider.future);
|
||||||
final api = ref.read(apiClientProvider);
|
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? ?? []);
|
final list = data is List ? data : ((data as Map<String, dynamic>?)?['items'] as List? ?? []);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -280,6 +313,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
.cast<Map<String, dynamic>>()
|
.cast<Map<String, dynamic>>()
|
||||||
.map((e) => (id: e['id'] as int, name: e['name'] as String, categoryId: (e['categoryId'] as num?)?.toInt()))
|
.map((e) => (id: e['id'] as int, name: e['name'] as String, categoryId: (e['categoryId'] as num?)?.toInt()))
|
||||||
.toList();
|
.toList();
|
||||||
|
_categoryTree = results[1] as List<AdminCategoryNode>;
|
||||||
_loadingProducts = false;
|
_loadingProducts = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -379,7 +413,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
|
|
||||||
final result = await showDialog<_ItemEdit>(
|
final result = await showDialog<_ItemEdit>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (_) => _EditDialog(item: item, current: current, products: _products),
|
builder: (_) => _EditDialog(item: item, current: current, products: _products, categoryTree: _categoryTree),
|
||||||
);
|
);
|
||||||
if (result != null && mounted) {
|
if (result != null && mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|||||||
Reference in New Issue
Block a user