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