feat: Implement caching for selectable products and enhance product filtering in admin panels
Test Suite / test (24.15.0) (push) Has been cancelled
Test Suite / test (24.15.0) (push) Has been cancelled
This commit is contained in:
@@ -21,9 +21,18 @@ final adminRepositoryProvider = Provider<AdminRepository>((ref) {
|
||||
class AdminRepository {
|
||||
final ApiClient _apiClient;
|
||||
final Ref _ref;
|
||||
List<AdminProduct>? _selectableProductsCache;
|
||||
DateTime? _selectableProductsCacheAt;
|
||||
|
||||
static const Duration _selectableProductsCacheTtl = Duration(seconds: 45);
|
||||
|
||||
AdminRepository(this._apiClient, this._ref);
|
||||
|
||||
void _invalidateSelectableProductsCache() {
|
||||
_selectableProductsCache = null;
|
||||
_selectableProductsCacheAt = null;
|
||||
}
|
||||
|
||||
// ── Interna helpers ────────────────────────────────────────────────────────
|
||||
|
||||
Future<String?> _token() => _ref.read(authStateProvider.future);
|
||||
@@ -202,6 +211,50 @@ class AdminRepository {
|
||||
Future<List<PendingProduct>> listPrivateProducts() =>
|
||||
_getList(ProductApiPaths.privateList, PendingProduct.fromJson);
|
||||
|
||||
Future<List<AdminProduct>> listSelectableProductsForAdmin({
|
||||
bool forceRefresh = false,
|
||||
}) async {
|
||||
final now = DateTime.now();
|
||||
final cached = _selectableProductsCache;
|
||||
final cacheAt = _selectableProductsCacheAt;
|
||||
if (!forceRefresh && cached != null && cacheAt != null) {
|
||||
if (now.difference(cacheAt) <= _selectableProductsCacheTtl) {
|
||||
return List<AdminProduct>.from(cached);
|
||||
}
|
||||
}
|
||||
|
||||
final results = await Future.wait<dynamic>([
|
||||
listProducts(),
|
||||
listPrivateProducts(),
|
||||
]);
|
||||
final globalProducts = results[0] as List<AdminProduct>;
|
||||
final privateProducts = results[1] as List<PendingProduct>;
|
||||
|
||||
final merged = <int, AdminProduct>{
|
||||
for (final product in globalProducts) product.id: product,
|
||||
};
|
||||
|
||||
for (final product in privateProducts) {
|
||||
merged[product.id] = AdminProduct(
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
canonicalName: product.canonicalName,
|
||||
ownerId: product.ownerId,
|
||||
categoryId: product.categoryId,
|
||||
categoryPath: product.categoryPath,
|
||||
status: 'private',
|
||||
);
|
||||
}
|
||||
|
||||
final list = merged.values.toList();
|
||||
list.sort(
|
||||
(a, b) => a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()),
|
||||
);
|
||||
_selectableProductsCache = List<AdminProduct>.from(list);
|
||||
_selectableProductsCacheAt = now;
|
||||
return list;
|
||||
}
|
||||
|
||||
Future<List<AdminProduct>> listDeletedProducts() =>
|
||||
_getList(ProductApiPaths.deleted, AdminProduct.fromJson);
|
||||
|
||||
@@ -209,36 +262,51 @@ class AdminRepository {
|
||||
_getList(ProductApiPaths.pending, PendingProduct.fromJson);
|
||||
|
||||
Future<void> setProductStatus(int productId, String status) =>
|
||||
_patchVoid(ProductApiPaths.setStatus(productId), {'status': status});
|
||||
_patchVoid(ProductApiPaths.setStatus(productId), {'status': status}).then((_) {
|
||||
_invalidateSelectableProductsCache();
|
||||
});
|
||||
|
||||
Future<AdminProduct> promotePrivateProduct(int productId) =>
|
||||
_post<AdminProduct>(
|
||||
ProductApiPaths.promotePrivate(productId),
|
||||
body: null,
|
||||
parse: (d) => AdminProduct.fromJson(Map<String, dynamic>.from(d as Map)),
|
||||
);
|
||||
).then((value) {
|
||||
_invalidateSelectableProductsCache();
|
||||
return value;
|
||||
});
|
||||
|
||||
Future<void> setProductCategory(int productId, {required int? categoryId}) =>
|
||||
_patchVoid(ProductApiPaths.update(productId), {'categoryId': categoryId});
|
||||
_patchVoid(ProductApiPaths.update(productId), {'categoryId': categoryId}).then((_) {
|
||||
_invalidateSelectableProductsCache();
|
||||
});
|
||||
|
||||
Future<void> removeProduct(int productId) =>
|
||||
_deleteVoid(ProductApiPaths.remove(productId));
|
||||
_deleteVoid(ProductApiPaths.remove(productId)).then((_) {
|
||||
_invalidateSelectableProductsCache();
|
||||
});
|
||||
|
||||
Future<void> restoreProduct(int productId) =>
|
||||
_postVoid(ProductApiPaths.restore(productId));
|
||||
_postVoid(ProductApiPaths.restore(productId)).then((_) {
|
||||
_invalidateSelectableProductsCache();
|
||||
});
|
||||
|
||||
// ── Product canonical name updates ────────────────────────────────────────
|
||||
Future<void> updateCanonicalName(int productId, String canonicalName) =>
|
||||
_patchVoid(
|
||||
ProductApiPaths.canonicalName(productId),
|
||||
{'canonicalName': canonicalName.trim()},
|
||||
);
|
||||
).then((_) {
|
||||
_invalidateSelectableProductsCache();
|
||||
});
|
||||
|
||||
Future<void> updateCanonicalNamePrivate(int productId, String canonicalName) =>
|
||||
_patchVoid(
|
||||
ProductApiPaths.canonicalNamePrivate(productId),
|
||||
{'canonicalName': canonicalName.trim()},
|
||||
);
|
||||
).then((_) {
|
||||
_invalidateSelectableProductsCache();
|
||||
});
|
||||
|
||||
// ── Product merging ────────────────────────────────────────────────────────
|
||||
Future<void> mergeProductsPrivate({
|
||||
@@ -248,6 +316,8 @@ class AdminRepository {
|
||||
_postVoid(ProductApiPaths.mergePrivate, {
|
||||
'sourceProductId': sourceProductId,
|
||||
'targetProductId': targetProductId,
|
||||
}).then((_) {
|
||||
_invalidateSelectableProductsCache();
|
||||
});
|
||||
|
||||
/// Skapar en ny aktiv produkt (kräver admin). Returnerar `{id, name, categoryId?}`.
|
||||
@@ -258,7 +328,10 @@ class AdminRepository {
|
||||
'name': name.trim(),
|
||||
if (categoryId != null) 'categoryId': categoryId,
|
||||
},
|
||||
);
|
||||
).then((value) {
|
||||
_invalidateSelectableProductsCache();
|
||||
return value;
|
||||
});
|
||||
|
||||
int _parseUpdatedCount(dynamic data) {
|
||||
if (data is! Map) {
|
||||
@@ -274,7 +347,10 @@ class AdminRepository {
|
||||
ProductApiPaths.bulkUpdate,
|
||||
body: {'ids': ids, 'categoryId': categoryId},
|
||||
parse: _parseUpdatedCount,
|
||||
);
|
||||
).then((value) {
|
||||
_invalidateSelectableProductsCache();
|
||||
return value;
|
||||
});
|
||||
|
||||
Future<void> mergeProducts({
|
||||
required int sourceProductId,
|
||||
@@ -283,6 +359,8 @@ class AdminRepository {
|
||||
_postVoid(ProductApiPaths.merge, {
|
||||
'sourceProductId': sourceProductId,
|
||||
'targetProductId': targetProductId,
|
||||
}).then((_) {
|
||||
_invalidateSelectableProductsCache();
|
||||
});
|
||||
|
||||
Future<Map<String, dynamic>> previewMerge({
|
||||
|
||||
@@ -3,6 +3,7 @@ class AdminProduct {
|
||||
final String name;
|
||||
final String? canonicalName;
|
||||
final String? normalizedName;
|
||||
final int? ownerId;
|
||||
final int? categoryId;
|
||||
final String? categoryPath;
|
||||
final bool? isActive;
|
||||
@@ -14,6 +15,7 @@ class AdminProduct {
|
||||
required this.name,
|
||||
this.canonicalName,
|
||||
this.normalizedName,
|
||||
this.ownerId,
|
||||
this.categoryId,
|
||||
this.categoryPath,
|
||||
this.isActive,
|
||||
@@ -43,6 +45,7 @@ class AdminProduct {
|
||||
name: (json['name'] ?? '').toString(),
|
||||
canonicalName: json['canonicalName']?.toString(),
|
||||
normalizedName: json['normalizedName']?.toString(),
|
||||
ownerId: ((json['owner'] as Map<String, dynamic>?)?['id'] as num?)?.toInt(),
|
||||
categoryId: (json['categoryId'] as num?)?.toInt(),
|
||||
categoryPath: names.isEmpty ? null : names.join(' > '),
|
||||
isActive: json['isActive'] as bool?,
|
||||
|
||||
@@ -3,6 +3,8 @@ class PendingProduct {
|
||||
final String name;
|
||||
final String? canonicalName;
|
||||
final DateTime? createdAt;
|
||||
final int? ownerId;
|
||||
final int? categoryId;
|
||||
final String? categoryPath;
|
||||
final String? ownerUsername;
|
||||
|
||||
@@ -11,6 +13,8 @@ class PendingProduct {
|
||||
required this.name,
|
||||
this.canonicalName,
|
||||
this.createdAt,
|
||||
this.ownerId,
|
||||
this.categoryId,
|
||||
this.categoryPath,
|
||||
this.ownerUsername,
|
||||
});
|
||||
@@ -45,6 +49,8 @@ class PendingProduct {
|
||||
createdAt: json['createdAt'] == null
|
||||
? null
|
||||
: DateTime.tryParse(json['createdAt'].toString()),
|
||||
ownerId: (owner is Map<String, dynamic>) ? (owner['id'] as num?)?.toInt() : null,
|
||||
categoryId: (json['categoryId'] as num?)?.toInt(),
|
||||
categoryPath: parts.isEmpty ? null : parts.join(' > '),
|
||||
ownerUsername: owner is Map<String, dynamic>
|
||||
? owner['username']?.toString()
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/api/api_error_mapper.dart';
|
||||
import 'admin_form_shared.dart';
|
||||
import '../data/admin_repository.dart';
|
||||
import '../domain/admin_product.dart';
|
||||
import '../domain/receipt_alias.dart';
|
||||
@@ -162,11 +163,23 @@ class _AdminAliasesPanelState extends ConsumerState<AdminAliasesPanel> {
|
||||
alias.displayProductName.toLowerCase().contains(query);
|
||||
}).toList();
|
||||
|
||||
final productById = <int, AdminProduct>{
|
||||
for (final product in _products) product.id: product,
|
||||
};
|
||||
|
||||
Widget buildAliasCard(ReceiptAlias alias) {
|
||||
final product = productById[alias.productId];
|
||||
final categoryPath = product?.categoryPath ?? 'okänd';
|
||||
|
||||
return Card(
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.link_outlined),
|
||||
title: Text(alias.receiptName, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(child: Text(alias.receiptName, style: const TextStyle(fontWeight: FontWeight.w500))),
|
||||
buildCategoryPathChip(categoryPath),
|
||||
],
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
|
||||
@@ -52,7 +52,7 @@ class _AdminDatabasePanelState extends ConsumerState<AdminDatabasePanel> {
|
||||
),
|
||||
_DatabaseTabConfig(
|
||||
tab: _DatabaseTab.products,
|
||||
title: context.l10n.profileProductsTab,
|
||||
title: 'Globala produkter',
|
||||
panel: const AdminProductsPanel(embedded: true),
|
||||
),
|
||||
_DatabaseTabConfig(
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/ui/product_picker_field.dart';
|
||||
import '../domain/admin_product.dart';
|
||||
|
||||
enum ProductScopeFilter { all, globalOnly, privateOnly }
|
||||
|
||||
List<String> buildLocationOptionsFromValues(Iterable<String?> values) {
|
||||
final set = <String>{};
|
||||
for (final value in values) {
|
||||
final trimmed = value?.trim();
|
||||
if (trimmed != null && trimmed.isNotEmpty) {
|
||||
set.add(trimmed);
|
||||
}
|
||||
}
|
||||
final list = set.toList()
|
||||
..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase()));
|
||||
return list;
|
||||
}
|
||||
|
||||
String? resolveLocationDropdownValue({
|
||||
required bool useManualLocation,
|
||||
required String currentValue,
|
||||
required List<String> options,
|
||||
required String manualLocationValue,
|
||||
}) {
|
||||
if (useManualLocation) return manualLocationValue;
|
||||
final value = currentValue.trim();
|
||||
if (value.isEmpty) return null;
|
||||
return options.contains(value) ? value : manualLocationValue;
|
||||
}
|
||||
|
||||
List<AdminProduct> filterSelectableAdminProducts({
|
||||
required List<AdminProduct> products,
|
||||
required int? ownerUserId,
|
||||
required int? categoryId,
|
||||
required ProductScopeFilter scopeFilter,
|
||||
required AdminProduct? selectedProduct,
|
||||
}) {
|
||||
final ownerFiltered = ownerUserId == null
|
||||
? products.where((p) => p.ownerId == null).toList()
|
||||
: products.where((p) => p.ownerId == null || p.ownerId == ownerUserId).toList();
|
||||
|
||||
final scopeFiltered = switch (scopeFilter) {
|
||||
ProductScopeFilter.all => ownerFiltered,
|
||||
ProductScopeFilter.globalOnly => ownerFiltered.where((p) => p.ownerId == null).toList(),
|
||||
ProductScopeFilter.privateOnly => ownerFiltered.where((p) => p.ownerId != null).toList(),
|
||||
};
|
||||
|
||||
final source = categoryId == null
|
||||
? scopeFiltered
|
||||
: scopeFiltered.where((p) => p.categoryId == categoryId).toList();
|
||||
|
||||
if (selectedProduct != null && !source.any((p) => p.id == selectedProduct.id)) {
|
||||
source.add(selectedProduct);
|
||||
}
|
||||
|
||||
source.sort((a, b) => a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()));
|
||||
return source;
|
||||
}
|
||||
|
||||
List<ProductOption> toProductOptions(List<AdminProduct> products) {
|
||||
return products
|
||||
.map(
|
||||
(p) => (
|
||||
id: p.id,
|
||||
name: p.ownerId == null ? p.displayName : '${p.displayName} (privat)',
|
||||
categoryId: p.categoryId,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
Widget buildCategoryPathChip(String? categoryPath, {double maxWidth = 220}) {
|
||||
final value = (categoryPath == null || categoryPath.trim().isEmpty)
|
||||
? 'okänd'
|
||||
: categoryPath.trim();
|
||||
return Tooltip(
|
||||
message: value,
|
||||
child: Chip(
|
||||
label: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: maxWidth),
|
||||
child: Text(
|
||||
value,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
softWrap: false,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import '../../../core/api/api_error_mapper.dart';
|
||||
import '../../../core/ui/category_then_product_picker.dart';
|
||||
import '../../../core/ui/searchable_category_field.dart';
|
||||
import '../../../core/ui/product_picker_field.dart';
|
||||
import 'admin_form_shared.dart';
|
||||
import '../data/admin_repository.dart';
|
||||
import '../domain/admin_category_node.dart';
|
||||
import '../domain/admin_inventory_item.dart';
|
||||
@@ -39,7 +40,6 @@ class _AdminInventoryPanelState extends ConsumerState<AdminInventoryPanel> {
|
||||
List<AdminInventoryItem> _items = [];
|
||||
List<AdminProduct> _products = [];
|
||||
List<AdminCategoryNode> _categories = [];
|
||||
List<CategorySelectOption> _categoryOptions = [];
|
||||
List<UserAdmin> _users = [];
|
||||
|
||||
@override
|
||||
@@ -60,7 +60,7 @@ class _AdminInventoryPanelState extends ConsumerState<AdminInventoryPanel> {
|
||||
userId: _selectedUserId,
|
||||
sort: _sortParam,
|
||||
),
|
||||
ref.read(adminRepositoryProvider).listGlobalProducts(),
|
||||
ref.read(adminRepositoryProvider).listSelectableProductsForAdmin(),
|
||||
ref.read(adminRepositoryProvider).listCategoryTree(),
|
||||
ref.read(adminRepositoryProvider).listUsers(),
|
||||
]);
|
||||
@@ -69,7 +69,6 @@ class _AdminInventoryPanelState extends ConsumerState<AdminInventoryPanel> {
|
||||
_items = results[0] as List<AdminInventoryItem>;
|
||||
_products = results[1] as List<AdminProduct>;
|
||||
_categories = results[2] as List<AdminCategoryNode>;
|
||||
_categoryOptions = _flattenCategoryOptions(_categories);
|
||||
_users = results[3] as List<UserAdmin>;
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -96,20 +95,6 @@ class _AdminInventoryPanelState extends ConsumerState<AdminInventoryPanel> {
|
||||
_InventorySort.quantityDesc => 'Mängd fallande',
|
||||
};
|
||||
|
||||
List<CategorySelectOption> _flattenCategoryOptions(
|
||||
List<AdminCategoryNode> nodes, [
|
||||
List<String> parents = const [],
|
||||
]) {
|
||||
final result = <CategorySelectOption>[];
|
||||
for (final node in nodes) {
|
||||
final pathParts = [...parents, node.name];
|
||||
final path = pathParts.join(' > ');
|
||||
result.add((value: node.id.toString(), label: path));
|
||||
result.addAll(_flattenCategoryOptions(node.children, pathParts));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
List<AdminInventoryItem> get _filtered {
|
||||
final q = _search.trim().toLowerCase();
|
||||
if (q.isEmpty) return _items;
|
||||
@@ -122,6 +107,10 @@ class _AdminInventoryPanelState extends ConsumerState<AdminInventoryPanel> {
|
||||
}).toList();
|
||||
}
|
||||
|
||||
List<String> get _locationOptions {
|
||||
return buildLocationOptionsFromValues(_items.map((item) => item.location));
|
||||
}
|
||||
|
||||
Future<void> _addItem() async {
|
||||
final values = await _showInventoryFormDialog(initialOwnerUserId: _selectedUserId);
|
||||
if (values == null) return;
|
||||
@@ -478,6 +467,7 @@ class _AdminInventoryPanelState extends ConsumerState<AdminInventoryPanel> {
|
||||
users: _users,
|
||||
products: _products,
|
||||
categories: _categories,
|
||||
locationOptions: _locationOptions,
|
||||
initial: initial,
|
||||
initialOwnerUserId: initialOwnerUserId,
|
||||
),
|
||||
@@ -678,11 +668,15 @@ class _AdminInventoryPanelState extends ConsumerState<AdminInventoryPanel> {
|
||||
itemBuilder: (context, index) {
|
||||
final item = filtered[index];
|
||||
return ListTile(
|
||||
title: Text(item.displayName),
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(child: Text(item.displayName)),
|
||||
buildCategoryPathChip(item.categoryPath),
|
||||
],
|
||||
),
|
||||
subtitle: Text(
|
||||
'${item.quantity} ${item.unit} · ${item.username} (${item.userEmail})'
|
||||
'${item.location == null || item.location!.isEmpty ? '' : ' · ${item.location}'}'
|
||||
'${item.categoryPath == null || item.categoryPath!.isEmpty ? '' : ' · ${item.categoryPath}'}',
|
||||
'${item.location == null || item.location!.isEmpty ? '' : ' · ${item.location}'}',
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -757,6 +751,7 @@ class _InventoryFormDialog extends StatefulWidget {
|
||||
final List<UserAdmin> users;
|
||||
final List<AdminProduct> products;
|
||||
final List<AdminCategoryNode> categories;
|
||||
final List<String> locationOptions;
|
||||
final AdminInventoryItem? initial;
|
||||
final int? initialOwnerUserId;
|
||||
|
||||
@@ -764,6 +759,7 @@ class _InventoryFormDialog extends StatefulWidget {
|
||||
required this.users,
|
||||
required this.products,
|
||||
required this.categories,
|
||||
required this.locationOptions,
|
||||
this.initial,
|
||||
this.initialOwnerUserId,
|
||||
});
|
||||
@@ -773,6 +769,8 @@ class _InventoryFormDialog extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _InventoryFormDialogState extends State<_InventoryFormDialog> {
|
||||
static const String _manualLocationValue = '__manual_location__';
|
||||
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late final TextEditingController _quantityController;
|
||||
late final TextEditingController _unitController;
|
||||
@@ -789,6 +787,8 @@ class _InventoryFormDialogState extends State<_InventoryFormDialog> {
|
||||
int? _categoryId;
|
||||
String? _categoryPath;
|
||||
String? _productErrorText;
|
||||
bool _useManualLocation = false;
|
||||
ProductScopeFilter _productScopeFilter = ProductScopeFilter.globalOnly;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -810,6 +810,9 @@ class _InventoryFormDialogState extends State<_InventoryFormDialog> {
|
||||
_commentController = TextEditingController(text: initial?.comment ?? '');
|
||||
_categorySearchController = TextEditingController(text: _categoryPath ?? '');
|
||||
_categoryOptions = _flattenCategoryOptions(widget.categories);
|
||||
final initialLocation = _locationController.text.trim();
|
||||
_useManualLocation = initialLocation.isNotEmpty &&
|
||||
!widget.locationOptions.contains(initialLocation);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -844,18 +847,39 @@ class _InventoryFormDialogState extends State<_InventoryFormDialog> {
|
||||
for (final product in widget.products) {
|
||||
if (product.id == id) return product;
|
||||
}
|
||||
final initial = widget.initial;
|
||||
if (initial != null && initial.productId == id) {
|
||||
return AdminProduct(
|
||||
id: initial.productId,
|
||||
name: initial.productName,
|
||||
canonicalName: initial.productCanonicalName,
|
||||
ownerId: initial.userId,
|
||||
categoryId: initial.categoryId,
|
||||
categoryPath: initial.categoryPath,
|
||||
status: 'private',
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
List<ProductOption> _productOptions() {
|
||||
final source = _categoryId == null
|
||||
? widget.products
|
||||
: widget.products.where((p) => p.categoryId == _categoryId).toList();
|
||||
final sorted = [...source]
|
||||
..sort((a, b) => a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()));
|
||||
return sorted
|
||||
.map((p) => (id: p.id, name: p.displayName, categoryId: p.categoryId))
|
||||
.toList();
|
||||
final filtered = filterSelectableAdminProducts(
|
||||
products: widget.products,
|
||||
ownerUserId: _ownerUserId,
|
||||
categoryId: _categoryId,
|
||||
scopeFilter: _productScopeFilter,
|
||||
selectedProduct: _productById(_productId),
|
||||
);
|
||||
return toProductOptions(filtered);
|
||||
}
|
||||
|
||||
String? _locationDropdownValue() {
|
||||
return resolveLocationDropdownValue(
|
||||
useManualLocation: _useManualLocation,
|
||||
currentValue: _locationController.text,
|
||||
options: widget.locationOptions,
|
||||
manualLocationValue: _manualLocationValue,
|
||||
);
|
||||
}
|
||||
|
||||
List<CategorySelectOption> _flattenCategoryOptions(
|
||||
@@ -914,12 +938,26 @@ class _InventoryFormDialogState extends State<_InventoryFormDialog> {
|
||||
),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (value) => setState(() => _ownerUserId = value),
|
||||
onChanged: (value) => setState(() {
|
||||
_ownerUserId = value;
|
||||
if (value == null) {
|
||||
_productScopeFilter = ProductScopeFilter.globalOnly;
|
||||
final selected = _productById(_productId);
|
||||
if (selected?.ownerId != null) {
|
||||
_productId = null;
|
||||
}
|
||||
}
|
||||
}),
|
||||
decoration: const InputDecoration(labelText: 'Ägare (användare)'),
|
||||
validator: (value) => value == null ? 'Välj användare' : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
] else ...[
|
||||
Text(
|
||||
'Produkt: ${widget.initial!.displayName}',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
@@ -975,7 +1013,33 @@ class _InventoryFormDialogState extends State<_InventoryFormDialog> {
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (_ownerUserId != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
SegmentedButton<ProductScopeFilter>(
|
||||
segments: const [
|
||||
ButtonSegment<ProductScopeFilter>(
|
||||
value: ProductScopeFilter.all,
|
||||
label: Text('Alla'),
|
||||
),
|
||||
ButtonSegment<ProductScopeFilter>(
|
||||
value: ProductScopeFilter.globalOnly,
|
||||
label: Text('Globala'),
|
||||
),
|
||||
ButtonSegment<ProductScopeFilter>(
|
||||
value: ProductScopeFilter.privateOnly,
|
||||
label: Text('Privata'),
|
||||
),
|
||||
],
|
||||
selected: {_productScopeFilter},
|
||||
onSelectionChanged: (selection) {
|
||||
if (selection.isEmpty) return;
|
||||
setState(() => _productScopeFilter = selection.first);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
] else ...[
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
TextFormField(
|
||||
controller: _quantityController,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
@@ -994,10 +1058,46 @@ class _InventoryFormDialogState extends State<_InventoryFormDialog> {
|
||||
(value == null || value.trim().isEmpty) ? 'Ange enhet' : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _locationController,
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: _locationDropdownValue(),
|
||||
decoration: const InputDecoration(labelText: 'Plats (valfritt)'),
|
||||
items: [
|
||||
...widget.locationOptions.map(
|
||||
(option) => DropdownMenuItem<String>(
|
||||
value: option,
|
||||
child: Text(option),
|
||||
),
|
||||
),
|
||||
const DropdownMenuItem<String>(
|
||||
value: _manualLocationValue,
|
||||
child: Text('Ange manuellt...'),
|
||||
),
|
||||
],
|
||||
onChanged: (value) {
|
||||
if (value == null) {
|
||||
setState(() {
|
||||
_useManualLocation = false;
|
||||
_locationController.clear();
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (value == _manualLocationValue) {
|
||||
setState(() => _useManualLocation = true);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_useManualLocation = false;
|
||||
_locationController.text = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
if (_useManualLocation) ...[
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _locationController,
|
||||
decoration: const InputDecoration(labelText: 'Plats (manuell)'),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _brandController,
|
||||
|
||||
@@ -7,6 +7,7 @@ import '../../../core/l10n/l10n.dart';
|
||||
import '../../../core/ui/category_then_product_picker.dart';
|
||||
import '../../../core/ui/searchable_category_field.dart';
|
||||
import '../../../core/ui/product_picker_field.dart';
|
||||
import 'admin_form_shared.dart';
|
||||
import '../data/admin_repository.dart';
|
||||
import '../domain/admin_category_node.dart';
|
||||
import '../domain/admin_pantry_item.dart';
|
||||
@@ -30,7 +31,6 @@ class _AdminPantryPanelState extends ConsumerState<AdminPantryPanel> {
|
||||
List<AdminPantryItem> _items = [];
|
||||
List<AdminProduct> _products = [];
|
||||
List<AdminCategoryNode> _categories = [];
|
||||
List<CategorySelectOption> _categoryOptions = [];
|
||||
List<UserAdmin> _users = [];
|
||||
|
||||
@override
|
||||
@@ -48,7 +48,7 @@ class _AdminPantryPanelState extends ConsumerState<AdminPantryPanel> {
|
||||
try {
|
||||
final results = await Future.wait<dynamic>([
|
||||
ref.read(adminRepositoryProvider).listAdminPantry(userId: _selectedUserId),
|
||||
ref.read(adminRepositoryProvider).listGlobalProducts(),
|
||||
ref.read(adminRepositoryProvider).listSelectableProductsForAdmin(),
|
||||
ref.read(adminRepositoryProvider).listCategoryTree(),
|
||||
ref.read(adminRepositoryProvider).listUsers(),
|
||||
]);
|
||||
@@ -57,7 +57,6 @@ class _AdminPantryPanelState extends ConsumerState<AdminPantryPanel> {
|
||||
_items = results[0] as List<AdminPantryItem>;
|
||||
_products = results[1] as List<AdminProduct>;
|
||||
_categories = results[2] as List<AdminCategoryNode>;
|
||||
_categoryOptions = _flattenCategoryOptions(_categories);
|
||||
_users = results[3] as List<UserAdmin>;
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -80,18 +79,8 @@ class _AdminPantryPanelState extends ConsumerState<AdminPantryPanel> {
|
||||
}).toList();
|
||||
}
|
||||
|
||||
List<CategorySelectOption> _flattenCategoryOptions(
|
||||
List<AdminCategoryNode> nodes, [
|
||||
List<String> parents = const [],
|
||||
]) {
|
||||
final result = <CategorySelectOption>[];
|
||||
for (final node in nodes) {
|
||||
final pathParts = [...parents, node.name];
|
||||
final path = pathParts.join(' > ');
|
||||
result.add((value: node.id.toString(), label: path));
|
||||
result.addAll(_flattenCategoryOptions(node.children, pathParts));
|
||||
}
|
||||
return result;
|
||||
List<String> get _locationOptions {
|
||||
return buildLocationOptionsFromValues(_items.map((item) => item.location));
|
||||
}
|
||||
|
||||
Future<void> _addItem() async {
|
||||
@@ -152,6 +141,7 @@ class _AdminPantryPanelState extends ConsumerState<AdminPantryPanel> {
|
||||
users: _users,
|
||||
products: _products,
|
||||
categories: _categories,
|
||||
locationOptions: _locationOptions,
|
||||
initial: initial,
|
||||
initialOwnerUserId: initialOwnerUserId,
|
||||
),
|
||||
@@ -427,11 +417,15 @@ class _AdminPantryPanelState extends ConsumerState<AdminPantryPanel> {
|
||||
itemBuilder: (context, index) {
|
||||
final item = filtered[index];
|
||||
return ListTile(
|
||||
title: Text(item.displayName),
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(child: Text(item.displayName)),
|
||||
buildCategoryPathChip(item.categoryPath),
|
||||
],
|
||||
),
|
||||
subtitle: Text(
|
||||
'${item.username} (${item.userEmail})'
|
||||
'${item.location == null || item.location!.trim().isEmpty ? '' : ' · ${item.location}'}'
|
||||
'${item.categoryPath == null || item.categoryPath!.trim().isEmpty ? '' : ' · ${item.categoryPath}'}',
|
||||
'${item.location == null || item.location!.trim().isEmpty ? '' : ' · ${item.location}'}',
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -489,6 +483,7 @@ class _PantryFormDialog extends StatefulWidget {
|
||||
final List<UserAdmin> users;
|
||||
final List<AdminProduct> products;
|
||||
final List<AdminCategoryNode> categories;
|
||||
final List<String> locationOptions;
|
||||
final AdminPantryItem? initial;
|
||||
final int? initialOwnerUserId;
|
||||
|
||||
@@ -496,6 +491,7 @@ class _PantryFormDialog extends StatefulWidget {
|
||||
required this.users,
|
||||
required this.products,
|
||||
required this.categories,
|
||||
required this.locationOptions,
|
||||
this.initial,
|
||||
this.initialOwnerUserId,
|
||||
});
|
||||
@@ -505,6 +501,8 @@ class _PantryFormDialog extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _PantryFormDialogState extends State<_PantryFormDialog> {
|
||||
static const String _manualLocationValue = '__manual_location__';
|
||||
|
||||
late final TextEditingController _locationController;
|
||||
late final TextEditingController _categorySearchController;
|
||||
late List<CategorySelectOption> _categoryOptions;
|
||||
@@ -514,6 +512,8 @@ class _PantryFormDialogState extends State<_PantryFormDialog> {
|
||||
int? _categoryId;
|
||||
String? _categoryPath;
|
||||
String? _productErrorText;
|
||||
bool _useManualLocation = false;
|
||||
ProductScopeFilter _productScopeFilter = ProductScopeFilter.globalOnly;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -527,6 +527,9 @@ class _PantryFormDialogState extends State<_PantryFormDialog> {
|
||||
_locationController = TextEditingController(text: initial?.location ?? '');
|
||||
_categorySearchController = TextEditingController(text: _categoryPath ?? '');
|
||||
_categoryOptions = _flattenCategoryOptions(widget.categories);
|
||||
final initialLocation = _locationController.text.trim();
|
||||
_useManualLocation = initialLocation.isNotEmpty &&
|
||||
!widget.locationOptions.contains(initialLocation);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -555,18 +558,39 @@ class _PantryFormDialogState extends State<_PantryFormDialog> {
|
||||
for (final product in widget.products) {
|
||||
if (product.id == id) return product;
|
||||
}
|
||||
final initial = widget.initial;
|
||||
if (initial != null && initial.productId == id) {
|
||||
return AdminProduct(
|
||||
id: initial.productId,
|
||||
name: initial.productName,
|
||||
canonicalName: initial.productCanonicalName,
|
||||
ownerId: initial.userId,
|
||||
categoryId: initial.categoryId,
|
||||
categoryPath: initial.categoryPath,
|
||||
status: 'private',
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
List<ProductOption> _productOptions() {
|
||||
final source = _categoryId == null
|
||||
? widget.products
|
||||
: widget.products.where((p) => p.categoryId == _categoryId).toList();
|
||||
final sorted = [...source]
|
||||
..sort((a, b) => a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()));
|
||||
return sorted
|
||||
.map((p) => (id: p.id, name: p.displayName, categoryId: p.categoryId))
|
||||
.toList();
|
||||
final filtered = filterSelectableAdminProducts(
|
||||
products: widget.products,
|
||||
ownerUserId: _ownerUserId,
|
||||
categoryId: _categoryId,
|
||||
scopeFilter: _productScopeFilter,
|
||||
selectedProduct: _productById(_productId),
|
||||
);
|
||||
return toProductOptions(filtered);
|
||||
}
|
||||
|
||||
String? _locationDropdownValue() {
|
||||
return resolveLocationDropdownValue(
|
||||
useManualLocation: _useManualLocation,
|
||||
currentValue: _locationController.text,
|
||||
options: widget.locationOptions,
|
||||
manualLocationValue: _manualLocationValue,
|
||||
);
|
||||
}
|
||||
|
||||
List<CategorySelectOption> _flattenCategoryOptions(
|
||||
@@ -616,11 +640,25 @@ class _PantryFormDialogState extends State<_PantryFormDialog> {
|
||||
),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (value) => setState(() => _ownerUserId = value),
|
||||
onChanged: (value) => setState(() {
|
||||
_ownerUserId = value;
|
||||
if (value == null) {
|
||||
_productScopeFilter = ProductScopeFilter.globalOnly;
|
||||
final selected = _productById(_productId);
|
||||
if (selected?.ownerId != null) {
|
||||
_productId = null;
|
||||
}
|
||||
}
|
||||
}),
|
||||
decoration: const InputDecoration(labelText: 'Ägare (användare)'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
] else ...[
|
||||
Text(
|
||||
'Produkt: ${widget.initial!.displayName}',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Ägare: ${widget.initial!.username} (${widget.initial!.userEmail})',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
@@ -673,11 +711,73 @@ class _PantryFormDialogState extends State<_PantryFormDialog> {
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _locationController,
|
||||
if (_ownerUserId != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
SegmentedButton<ProductScopeFilter>(
|
||||
segments: const [
|
||||
ButtonSegment<ProductScopeFilter>(
|
||||
value: ProductScopeFilter.all,
|
||||
label: Text('Alla'),
|
||||
),
|
||||
ButtonSegment<ProductScopeFilter>(
|
||||
value: ProductScopeFilter.globalOnly,
|
||||
label: Text('Globala'),
|
||||
),
|
||||
ButtonSegment<ProductScopeFilter>(
|
||||
value: ProductScopeFilter.privateOnly,
|
||||
label: Text('Privata'),
|
||||
),
|
||||
],
|
||||
selected: {_productScopeFilter},
|
||||
onSelectionChanged: (selection) {
|
||||
if (selection.isEmpty) return;
|
||||
setState(() => _productScopeFilter = selection.first);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
] else ...[
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: _locationDropdownValue(),
|
||||
decoration: const InputDecoration(labelText: 'Plats (valfritt)'),
|
||||
items: [
|
||||
...widget.locationOptions.map(
|
||||
(option) => DropdownMenuItem<String>(
|
||||
value: option,
|
||||
child: Text(option),
|
||||
),
|
||||
),
|
||||
const DropdownMenuItem<String>(
|
||||
value: _manualLocationValue,
|
||||
child: Text('Ange manuellt...'),
|
||||
),
|
||||
],
|
||||
onChanged: (value) {
|
||||
if (value == null) {
|
||||
setState(() {
|
||||
_useManualLocation = false;
|
||||
_locationController.clear();
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (value == _manualLocationValue) {
|
||||
setState(() => _useManualLocation = true);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_useManualLocation = false;
|
||||
_locationController.text = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
if (_useManualLocation) ...[
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _locationController,
|
||||
decoration: const InputDecoration(labelText: 'Plats (manuell)'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/api/api_error_mapper.dart';
|
||||
import '../../../core/l10n/l10n.dart';
|
||||
import 'admin_form_shared.dart';
|
||||
import '../data/admin_repository.dart';
|
||||
import '../domain/pending_product.dart';
|
||||
|
||||
@@ -109,13 +110,17 @@ class _AdminPendingProductsPanelState
|
||||
final isProcessing = _processingId == product.id;
|
||||
return Card(
|
||||
child: ListTile(
|
||||
title: Text(product.displayName),
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(child: Text(product.displayName)),
|
||||
buildCategoryPathChip(product.categoryPath),
|
||||
],
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (product.displayName != product.name)
|
||||
Text(product.name, style: theme.textTheme.bodySmall),
|
||||
Text('${context.l10n.adminCategoryPrefix}${product.categoryPath ?? '—'}'),
|
||||
Text('${context.l10n.adminSuggestedByPrefix}${product.ownerUsername ?? '—'}'),
|
||||
Text(
|
||||
'${context.l10n.adminDatePrefix}${product.createdAt == null ? '—' : MaterialLocalizations.of(context).formatShortDate(product.createdAt!)}',
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/api/api_error_mapper.dart';
|
||||
import 'admin_form_shared.dart';
|
||||
import '../data/admin_repository.dart';
|
||||
import '../domain/pending_product.dart';
|
||||
|
||||
@@ -109,13 +110,16 @@ class _AdminPrivateProductsPanelState extends ConsumerState<AdminPrivateProducts
|
||||
return Card(
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.publish_outlined),
|
||||
title: Text(product.displayName),
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(child: Text(product.displayName)),
|
||||
buildCategoryPathChip(product.categoryPath),
|
||||
],
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (product.categoryPath != null)
|
||||
Text('Kategori: ${product.categoryPath}'),
|
||||
Text('Ägare: ${product.ownerUsername ?? '—'}'),
|
||||
Text('Skapad: ${product.createdAt == null ? '—' : MaterialLocalizations.of(context).formatShortDate(product.createdAt!)}'),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user