feat: Implement caching for selectable products and enhance product filtering in admin panels
Test Suite / test (24.15.0) (push) Has been cancelled

This commit is contained in:
Nils-Johan Gynther
2026-05-11 18:42:35 +02:00
parent d75fd00666
commit a4f65c6065
10 changed files with 481 additions and 79 deletions
@@ -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)'),
),
],
],
),
),
@@ -714,4 +814,4 @@ class _PantryFormDialogState extends State<_PantryFormDialog> {
],
);
}
}
}
@@ -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!)}'),
],