diff --git a/flutter/lib/core/api/api_paths.dart b/flutter/lib/core/api/api_paths.dart index a8e48698..dc2c0825 100644 --- a/flutter/lib/core/api/api_paths.dart +++ b/flutter/lib/core/api/api_paths.dart @@ -14,6 +14,7 @@ class ProductApiPaths { '/products/merge-preview?sourceProductId=$sourceProductId&targetProductId=$targetProductId'; static String setStatus(int id) => '/products/$id/status'; static String update(int id) => '/products/$id'; + static String canonicalName(int id) => '/products/$id/canonical-name'; static String remove(int id) => '/products/$id'; static String restore(int id) => '/products/$id/restore'; static const bulkUpdate = '/products/bulk-update'; diff --git a/flutter/lib/features/admin/data/admin_repository.dart b/flutter/lib/features/admin/data/admin_repository.dart index aa4ef06e..c9f17f45 100644 --- a/flutter/lib/features/admin/data/admin_repository.dart +++ b/flutter/lib/features/admin/data/admin_repository.dart @@ -191,6 +191,12 @@ class AdminRepository { Future restoreProduct(int productId) => _postVoid(ProductApiPaths.restore(productId)); + Future updateCanonicalName(int productId, String canonicalName) => + _patchVoid( + ProductApiPaths.canonicalName(productId), + {'canonicalName': canonicalName.trim()}, + ); + /// Skapar en ny aktiv produkt (kräver admin). Returnerar `{id, name, categoryId?}`. Future> createProduct(String name, {int? categoryId}) => _post>( diff --git a/flutter/lib/features/admin/presentation/admin_products_panel.dart b/flutter/lib/features/admin/presentation/admin_products_panel.dart index 92128088..fac7e44e 100644 --- a/flutter/lib/features/admin/presentation/admin_products_panel.dart +++ b/flutter/lib/features/admin/presentation/admin_products_panel.dart @@ -42,6 +42,7 @@ class _AdminProductsPanelState extends ConsumerState { final Set _selectedIds = {}; final Map _rowCategoryValue = {}; final Set _rowCategorySaving = {}; + List<({String value, String label})> _cachedCategoryOptions = []; @override void initState() { @@ -65,6 +66,7 @@ class _AdminProductsPanelState extends ConsumerState { setState(() { _products = results[0] as List; _categories = results[1] as List; + _cachedCategoryOptions = _flattenCategories(_categories); _rowCategoryValue.clear(); _rowCategorySaving.clear(); }); @@ -76,22 +78,14 @@ class _AdminProductsPanelState extends ConsumerState { } } - String _sortLabel(_ProductSort sort) { - switch (sort) { - case _ProductSort.newest: - return context.l10n.adminSortNewest; - case _ProductSort.oldest: - return context.l10n.adminSortOldest; - case _ProductSort.nameAsc: - return context.l10n.adminSortNameAsc; - case _ProductSort.nameDesc: - return context.l10n.adminSortNameDesc; - case _ProductSort.categoryAsc: - return context.l10n.adminSortCategoryAsc; - case _ProductSort.categoryDesc: - return context.l10n.adminSortCategoryDesc; - } - } + String _sortLabel(_ProductSort sort) => switch (sort) { + _ProductSort.newest => context.l10n.adminSortNewest, + _ProductSort.oldest => context.l10n.adminSortOldest, + _ProductSort.nameAsc => context.l10n.adminSortNameAsc, + _ProductSort.nameDesc => context.l10n.adminSortNameDesc, + _ProductSort.categoryAsc => context.l10n.adminSortCategoryAsc, + _ProductSort.categoryDesc => context.l10n.adminSortCategoryDesc, + }; List<({String value, String label})> _flattenCategories( List nodes, [ @@ -132,7 +126,7 @@ class _AdminProductsPanelState extends ConsumerState { } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( - buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)), + _showError(e); ); } finally { if (mounted) setState(() => _isApplying = false); @@ -181,7 +175,7 @@ class _AdminProductsPanelState extends ConsumerState { } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( - buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)), + _showError(e); ); } finally { if (mounted) setState(() => _isAiRunning = false); @@ -321,7 +315,70 @@ class _AdminProductsPanelState extends ConsumerState { } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( - buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)), + _showError(e); + ); + } + } + + Future _renameProduct(AdminProduct product) async { + final controller = TextEditingController( + text: product.canonicalName?.isNotEmpty == true + ? product.canonicalName + : product.name, + ); + final newName = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Byt namn på produkt'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Produkt-ID: ${product.id}'), + const SizedBox(height: 12), + TextField( + controller: controller, + autofocus: true, + decoration: const InputDecoration( + labelText: 'Kanoniskt namn', + border: OutlineInputBorder(), + ), + onSubmitted: (value) { + if (value.trim().isNotEmpty) Navigator.pop(dialogContext, value.trim()); + }, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(context.l10n.cancelAction), + ), + FilledButton( + onPressed: () { + final value = controller.text.trim(); + if (value.isNotEmpty) Navigator.pop(dialogContext, value); + }, + child: const Text('Spara'), + ), + ], + ), + ); + controller.dispose(); + if (newName == null || !mounted) return; + + try { + await ref.read(adminRepositoryProvider).updateCanonicalName(product.id, newName); + if (!mounted) return; + await _load(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Namn uppdaterat till "$newName"')), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + _showError(e); ); } } @@ -358,7 +415,7 @@ class _AdminProductsPanelState extends ConsumerState { } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( - buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)), + _showError(e); ); } } @@ -367,9 +424,8 @@ class _AdminProductsPanelState extends ConsumerState { if (_selectedIds.isEmpty || !_showDeletedOnly || _isApplying) return; setState(() => _isApplying = true); try { - for (final id in _selectedIds.toList()) { - await ref.read(adminRepositoryProvider).restoreProduct(id); - } + final repo = ref.read(adminRepositoryProvider); + await Future.wait(_selectedIds.map(repo.restoreProduct)); if (!mounted) return; setState(() => _selectedIds.clear()); await _load(); @@ -380,7 +436,7 @@ class _AdminProductsPanelState extends ConsumerState { } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( - buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)), + _showError(e); ); } finally { if (mounted) setState(() => _isApplying = false); @@ -399,7 +455,7 @@ class _AdminProductsPanelState extends ConsumerState { } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( - buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)), + _showError(e); ); } } @@ -435,22 +491,30 @@ class _AdminProductsPanelState extends ConsumerState { } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( - buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)), + _showError(e); ); setState(() => _rowCategorySaving.remove(product.id)); } } String _nameForId(int id) { - final match = _products.where((p) => p.id == id).toList(); - if (match.isEmpty) return '#$id'; - return match.first.displayName; + for (final p in _products) { + if (p.id == id) return p.displayName; + } + return '#$id'; + } + + void _showError(Object e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + _showError(e); + ); } @override Widget build(BuildContext context) { final theme = Theme.of(context); - final categoryOptions = _flattenCategories(_categories); + final categoryOptions = _cachedCategoryOptions; final filtered = _products.where((product) { if (!_showDeletedOnly && _showUncategorizedOnly && product.categoryId != null) { return false; @@ -709,6 +773,13 @@ class _AdminProductsPanelState extends ConsumerState { else const SizedBox.shrink(), const SizedBox(width: 8), + if (!_showDeletedOnly) + TextButton.icon( + onPressed: () => _renameProduct(product), + icon: const Icon(Icons.drive_file_rename_outline), + label: const Text('Byt namn'), + ), + const SizedBox(width: 4), if (_showDeletedOnly) TextButton.icon( onPressed: () => _restoreProduct(product), diff --git a/flutter/lib/features/import/presentation/receipt_import_tab.dart b/flutter/lib/features/import/presentation/receipt_import_tab.dart index 24064c96..78733c93 100644 --- a/flutter/lib/features/import/presentation/receipt_import_tab.dart +++ b/flutter/lib/features/import/presentation/receipt_import_tab.dart @@ -677,9 +677,7 @@ class _ReceiptImportTabState extends ConsumerState { final selectedFileName = _pickedFile?.name ?? session?.fileName; final selectedFileSizeBytes = _pickedFile?.size ?? session?.fileBytes?.length; - final resultListHeight = items == null - ? 0.0 - : (items.length * 128.0).clamp(220.0, 620.0).toDouble(); + return SingleChildScrollView( padding: const EdgeInsets.all(16), @@ -755,12 +753,12 @@ class _ReceiptImportTabState extends ConsumerState { ], ), const SizedBox(height: 4), - SizedBox( - height: resultListHeight, - child: ListView.builder( - key: const PageStorageKey('receipt-import-result-list'), - itemCount: items.length, - itemBuilder: (context, i) { + ListView.builder( + key: const PageStorageKey('receipt-import-result-list'), + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: items.length, + itemBuilder: (context, i) { return _ReceiptImportResultRow( index: i, item: items[i], @@ -782,7 +780,6 @@ class _ReceiptImportTabState extends ConsumerState { matchedViaBadgeBuilder: _buildMatchedViaBadge, ); }, - ), ), const SizedBox(height: 16), SizedBox(