feat: add isPrivate field to Product model and implement private product creation and retrieval

This commit is contained in:
Nils-Johan Gynther
2026-05-01 02:29:38 +02:00
parent 1fd910b561
commit 9ee061d5f3
10 changed files with 215 additions and 18 deletions
+2
View File
@@ -4,6 +4,8 @@ class AuthApiPaths {
class ProductApiPaths {
static const list = '/products';
static const mine = '/products/mine';
static const createPrivate = '/products/private';
static const pending = '/products/pending';
static const aiCategorizeBulk = '/products/ai-categorize-bulk';
static const deleted = '/products/deleted';
@@ -6,6 +6,9 @@ import '../../features/admin/domain/admin_category_node.dart';
///
/// Returnerar det valda produkt-id:t, eller null om användaren avbryter.
///
/// [onCreate] — valfri callback för att skapa ny produkt; anropas med produktnamnet
/// och returnerar `ProductOption` för den skapade produkten.
///
/// Anropas via [CategoryThenProductPicker.show].
class CategoryThenProductPicker {
CategoryThenProductPicker._();
@@ -35,12 +38,16 @@ class CategoryThenProductPicker {
///
/// [preselectedCategoryId] — om satt scrollas trädet till den noden och den
/// markeras visuellt. Användaren kan fortfarande välja en annan kategori.
///
/// [onCreate] — valfri callback; om satt visas "Skapa ny" i produktpickern.
/// Anropas med produktnamnet och ska returnera den nya `ProductOption`.
static Future<int?> show(
BuildContext context, {
required List<AdminCategoryNode> categoryTree,
required List<ProductOption> products,
int? currentProductId,
int? preselectedCategoryId,
Future<ProductOption?> Function(String name, int categoryId)? onCreate,
}) async {
// Steg 1 — välj kategori
final selectedCategory = await showModalBottomSheet<AdminCategoryNode>(
@@ -64,6 +71,11 @@ class CategoryThenProductPicker {
.toList();
final useList = filtered.isNotEmpty ? filtered : products;
// Bygg eventuell onCreate-callback med categoryId inbunden
final onCreateBound = onCreate == null
? null
: (String name) => onCreate(name, selectedCategory.id);
// Steg 2 — välj produkt
if (!context.mounted) return null;
return ProductPickerField.showSheet(
@@ -72,6 +84,7 @@ class CategoryThenProductPicker {
value: currentProductId,
label: 'Produkt i "${selectedCategory.name}"',
categoryFilter: null, // redan förfiltrerat
onCreate: onCreateBound,
);
}
}
+50 -5
View File
@@ -112,7 +112,8 @@ class ProductPickerField extends StatelessWidget {
/// Returnerar valt produkt-id, null (ingen ändring), eller [_clearSelectionToken] (rensa).
///
/// [categoryFilter] — om satt visas bara produkter vars categoryId finns i listan.
/// Används med AI-kategorisuggestion för att förifiltrera på rätt kategorigren.
/// [onCreate] — valfri callback för att skapa en ny produkt; anropas med produktnamnet
/// och returnerar den nya `ProductOption` om skapandet lyckades.
static Future<int?> showSheet(
BuildContext context, {
required List<ProductOption> products,
@@ -120,6 +121,7 @@ class ProductPickerField extends StatelessWidget {
String label = 'Produkt',
String? initialQuery,
Set<int>? categoryFilter,
Future<ProductOption?> Function(String name)? onCreate,
}) async {
// Filtrera på kategori om angiven, men visa alla om filtret ger nollresultat
final baseList = categoryFilter != null && categoryFilter.isNotEmpty
@@ -134,17 +136,54 @@ class ProductPickerField extends StatelessWidget {
builder: (sheetContext) {
final controller = TextEditingController(text: initialQuery ?? '');
var query = initialQuery ?? '';
// Mutable lokal kopia — nya produkter kan läggas till
var displayList = useList.toList();
return StatefulBuilder(
builder: (ctx, setModalState) {
final normalizedQuery = query.trim().toLowerCase();
final filtered = normalizedQuery.isEmpty
? useList
: useList
? displayList
: displayList
.where((p) => p.name.toLowerCase().contains(normalizedQuery))
.toList();
final isFiltered = useList.length < products.length;
final isFiltered = displayList.length < products.length;
// "Skapa ny produkt"-dialog
Future<void> openCreateDialog() async {
final nameCtrl = TextEditingController(text: query.trim());
final confirmed = await showDialog<bool>(
context: ctx,
builder: (dCtx) => AlertDialog(
title: const Text('Ny produkt'),
content: TextField(
controller: nameCtrl,
autofocus: true,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(
labelText: 'Produktnamn',
border: OutlineInputBorder(),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dCtx, false),
child: const Text('Avbryt'),
),
FilledButton(
onPressed: () => Navigator.pop(dCtx, true),
child: const Text('Skapa'),
),
],
),
);
if (confirmed != true || !ctx.mounted) return;
final newProduct = await onCreate!(nameCtrl.text);
if (newProduct == null || !ctx.mounted) return;
setModalState(() => displayList = [...displayList, newProduct]);
if (ctx.mounted) Navigator.pop(ctx, newProduct.id);
}
return SizedBox(
height: MediaQuery.of(ctx).size.height * 0.85,
@@ -161,12 +200,18 @@ class ProductPickerField extends StatelessWidget {
Text(label, style: Theme.of(ctx).textTheme.titleMedium),
if (isFiltered)
Text(
'Visar ${useList.length} produkter i föreslagen kategori',
'Visar ${displayList.length} produkter i föreslagen kategori',
style: Theme.of(ctx).textTheme.labelSmall?.copyWith(color: Colors.green.shade700),
),
],
),
),
if (onCreate != null)
TextButton.icon(
onPressed: openCreateDialog,
icon: const Icon(Icons.add),
label: const Text('Skapa ny'),
),
TextButton.icon(
onPressed: () => Navigator.pop(ctx, _clearSelectionToken),
icon: const Icon(Icons.clear),