From 14d782aeec7ffa85c09dd1672fd274a04395bf2a Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Wed, 22 Apr 2026 21:19:36 +0200 Subject: [PATCH] feat(inventory): implement swipeable inventory tile and product picker field --- flutter/lib/core/ui/product_picker_field.dart | 247 ++++++++++++ .../presentation/create_inventory_screen.dart | 47 +-- .../presentation/inventory_screen.dart | 110 +----- .../swipeable_inventory_tile.dart | 351 ++++++++++++++++++ .../meal_plan/data/meal_plan_providers.dart | 18 +- .../pantry/presentation/pantry_screen.dart | 29 +- 6 files changed, 632 insertions(+), 170 deletions(-) create mode 100644 flutter/lib/core/ui/product_picker_field.dart create mode 100644 flutter/lib/features/inventory/presentation/swipeable_inventory_tile.dart diff --git a/flutter/lib/core/ui/product_picker_field.dart b/flutter/lib/core/ui/product_picker_field.dart new file mode 100644 index 00000000..47a20b67 --- /dev/null +++ b/flutter/lib/core/ui/product_picker_field.dart @@ -0,0 +1,247 @@ +import 'package:flutter/material.dart'; + +/// A named record representing a selectable product option. +typedef ProductOption = ({int id, String name}); + +/// A form field that opens a searchable bottom sheet for selecting a product. +/// +/// Replaces a long [DropdownButtonFormField] when the product list is large. +/// Works both inside and outside a [Form]. +class ProductPickerField extends StatelessWidget { + final List products; + + /// Currently selected product id, or null if nothing is selected. + final int? value; + + /// Called when the user picks a product. + final ValueChanged? onChanged; + + /// Whether the field is interactive. + final bool enabled; + + /// Shows a loading spinner inside the field while products are being fetched. + final bool isLoading; + + /// Label shown in the field border. + final String label; + + /// Inline error message shown below the field. + final String? errorText; + + const ProductPickerField({ + super.key, + required this.products, + this.value, + this.onChanged, + this.enabled = true, + this.isLoading = false, + this.label = 'Produkt', + this.errorText, + }); + + @override + Widget build(BuildContext context) { + final selected = value == null + ? null + : products.cast().firstWhere( + (p) => p?.id == value, + orElse: () => null, + ); + + final interactive = enabled && !isLoading; + + return MouseRegion( + cursor: interactive ? SystemMouseCursors.click : SystemMouseCursors.basic, + child: GestureDetector( + onTap: interactive ? () => _openPicker(context) : null, + child: InputDecorator( + isEmpty: value == null, + decoration: InputDecoration( + labelText: label, + border: const OutlineInputBorder(), + errorText: errorText, + enabled: interactive, + suffixIcon: isLoading + ? const Padding( + padding: EdgeInsets.all(12), + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + : const Icon(Icons.search), + ), + child: value == null + ? null + : Text( + selected?.name ?? '', + overflow: TextOverflow.ellipsis, + ), + ), + ), + ); + } + + Future _openPicker(BuildContext context) async { + final result = await showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (_) => _ProductPickerSheet( + products: products, + selectedId: value, + ), + ); + if (result != null) { + onChanged?.call(result.id); + } + } +} + +// --------------------------------------------------------------------------- + +class _ProductPickerSheet extends StatefulWidget { + final List products; + final int? selectedId; + + const _ProductPickerSheet({ + required this.products, + this.selectedId, + }); + + @override + State<_ProductPickerSheet> createState() => _ProductPickerSheetState(); +} + +class _ProductPickerSheetState extends State<_ProductPickerSheet> { + final _searchController = TextEditingController(); + late List _filtered; + + @override + void initState() { + super.initState(); + _filtered = widget.products; + _searchController.addListener(_onSearch); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + void _onSearch() { + final query = _searchController.text.trim().toLowerCase(); + setState(() { + _filtered = query.isEmpty + ? widget.products + : widget.products + .where((p) => p.name.toLowerCase().contains(query)) + .toList(); + }); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return DraggableScrollableSheet( + expand: false, + initialChildSize: 0.6, + minChildSize: 0.35, + maxChildSize: 0.92, + builder: (context, scrollController) { + return Column( + children: [ + // Drag handle + const SizedBox(height: 8), + Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: theme.colorScheme.outlineVariant, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 12), + + // Search field + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: TextField( + controller: _searchController, + autofocus: true, + textInputAction: TextInputAction.search, + decoration: InputDecoration( + hintText: 'Sök produkt...', + prefixIcon: const Icon(Icons.search), + border: const OutlineInputBorder(), + suffixIcon: ListenableBuilder( + listenable: _searchController, + builder: (_, __) => _searchController.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + tooltip: 'Rensa sökning', + onPressed: _searchController.clear, + ) + : const SizedBox.shrink(), + ), + ), + ), + ), + const SizedBox(height: 4), + + // Result count + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + '${_filtered.length} ${_filtered.length == 1 ? 'produkt' : 'produkter'}', + style: theme.textTheme.bodySmall, + ), + ), + ), + + const Divider(height: 1), + + // Product list + Expanded( + child: _filtered.isEmpty + ? Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + 'Inga produkter matchade\n"${_searchController.text}"', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium, + ), + ), + ) + : ListView.builder( + controller: scrollController, + itemCount: _filtered.length, + itemBuilder: (context, index) { + final product = _filtered[index]; + final isSelected = product.id == widget.selectedId; + return ListTile( + title: Text(product.name), + selected: isSelected, + trailing: isSelected + ? Icon( + Icons.check, + color: theme.colorScheme.primary, + ) + : null, + onTap: () => Navigator.pop(context, product), + ); + }, + ), + ), + ], + ); + }, + ); + } +} diff --git a/flutter/lib/features/inventory/presentation/create_inventory_screen.dart b/flutter/lib/features/inventory/presentation/create_inventory_screen.dart index 615e9f1d..7218da1b 100644 --- a/flutter/lib/features/inventory/presentation/create_inventory_screen.dart +++ b/flutter/lib/features/inventory/presentation/create_inventory_screen.dart @@ -6,6 +6,7 @@ import '../../../core/api/api_error_mapper.dart'; import '../../../core/api/api_paths.dart'; import '../../../core/api/api_providers.dart'; import '../../../core/forms/form_options.dart'; +import '../../../core/ui/product_picker_field.dart'; import '../../auth/data/auth_providers.dart'; import '../data/inventory_providers.dart'; @@ -143,6 +144,14 @@ class _CreateInventoryScreenState final bName = (b['canonicalName'] ?? b['name'] ?? '').toString(); return aName.toLowerCase().compareTo(bName.toLowerCase()); }); + final productOptions = sortedProducts + .map( + (p) => ( + id: p['id'] as int, + name: (p['canonicalName'] ?? p['name'] ?? '').toString(), + ), + ) + .toList(); return Scaffold( appBar: AppBar(title: const Text('Lägg till inventariepost')), @@ -151,39 +160,13 @@ class _CreateInventoryScreenState child: ListView( padding: const EdgeInsets.all(16), children: [ - DropdownButtonFormField( + ProductPickerField( + products: productOptions, value: _selectedProductId, - isExpanded: true, - decoration: InputDecoration( - labelText: 'Produkt *', - border: const OutlineInputBorder(), - suffixIcon: _loadingProducts - ? const Padding( - padding: EdgeInsets.all(12), - child: SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ) - : null, - ), - items: sortedProducts - .map( - (product) => DropdownMenuItem( - value: product['id'] as int, - child: Text( - ((product['canonicalName'] ?? product['name']) as Object) - .toString(), - overflow: TextOverflow.ellipsis, - ), - ), - ) - .toList(), - onChanged: (_loadingProducts || _saving) - ? null - : (value) => setState(() => _selectedProductId = value), - validator: (value) => value == null ? 'Välj produkt' : null, + isLoading: _loadingProducts, + enabled: !_saving, + label: 'Produkt *', + onChanged: (value) => setState(() => _selectedProductId = value), ), const SizedBox(height: 12), Row( diff --git a/flutter/lib/features/inventory/presentation/inventory_screen.dart b/flutter/lib/features/inventory/presentation/inventory_screen.dart index a3d421d3..fb4ec1d2 100644 --- a/flutter/lib/features/inventory/presentation/inventory_screen.dart +++ b/flutter/lib/features/inventory/presentation/inventory_screen.dart @@ -7,6 +7,7 @@ import '../../../core/ui/async_state_views.dart'; import '../../auth/data/auth_providers.dart'; import '../data/inventory_providers.dart'; import '../domain/inventory_item.dart'; +import 'swipeable_inventory_tile.dart'; class InventoryScreen extends ConsumerWidget { const InventoryScreen({super.key}); @@ -113,7 +114,7 @@ class InventoryScreen extends ConsumerWidget { itemBuilder: (context, index) { if (index == 0) return filterSection; final item = items[index - 1]; - return _InventoryTile(item: item); + return SwipeableInventoryTile(item: item); }, ), Positioned( @@ -132,111 +133,4 @@ class InventoryScreen extends ConsumerWidget { } } -class _InventoryTile extends StatelessWidget { - final InventoryItem item; - const _InventoryTile({required this.item}); - - @override - Widget build(BuildContext context) { - final subtitle = [ - '${item.quantity} ${item.unit}', - if (item.location != null && item.location!.isNotEmpty) item.location!, - if (item.bestBeforeDate != null) 'Bäst före: ${_formatDate(item.bestBeforeDate!)}', - ].join(' · '); - - return ListTile( - title: Text(item.productName), - subtitle: Text(subtitle), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (item.opened) - const Padding( - padding: EdgeInsets.only(right: 4), - child: Chip( - label: Text('Öppnad'), - padding: EdgeInsets.zero, - visualDensity: VisualDensity.compact, - ), - ), - Tooltip( - message: 'Konsumera', - child: IconButton( - icon: const Icon(Icons.remove_circle_outline), - onPressed: () => context.push('/inventory/${item.id}/consume'), - ), - ), - Tooltip( - message: 'Redigera', - child: IconButton( - icon: const Icon(Icons.edit_outlined), - onPressed: () => context.push('/inventory/${item.id}/edit'), - ), - ), - _DeleteInventoryButton(item: item), - ], - ), - onTap: () => context.push('/inventory/${item.id}'), - ); - } - - String _formatDate(String iso) { - try { - final dt = DateTime.parse(iso); - return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}'; - } catch (_) { - return iso; - } - } -} - -class _DeleteInventoryButton extends ConsumerWidget { - final InventoryItem item; - - const _DeleteInventoryButton({required this.item}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - return Tooltip( - message: 'Ta bort', - child: IconButton( - icon: const Icon(Icons.delete_outline, color: Colors.red), - onPressed: () async { - final confirmed = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('Ta bort inventariepost?'), - content: Text('Vill du ta bort "${item.productName}"?'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: const Text('Avbryt'), - ), - FilledButton( - onPressed: () => Navigator.pop(ctx, true), - child: const Text('Ta bort'), - ), - ], - ), - ); - - if (confirmed != true) return; - - try { - final token = await ref.read(authStateProvider.future); - await ref - .read(inventoryRepositoryProvider) - .deleteInventoryItem(item.id, token: token); - ref.invalidate(inventoryProvider); - } catch (error) { - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(mapErrorToUserMessage(error, context))), - ); - } - }, - ), - ); - } -} diff --git a/flutter/lib/features/inventory/presentation/swipeable_inventory_tile.dart b/flutter/lib/features/inventory/presentation/swipeable_inventory_tile.dart new file mode 100644 index 00000000..772934fe --- /dev/null +++ b/flutter/lib/features/inventory/presentation/swipeable_inventory_tile.dart @@ -0,0 +1,351 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../core/api/api_error_mapper.dart'; +import '../../auth/data/auth_providers.dart'; +import '../data/inventory_providers.dart'; +import '../domain/inventory_item.dart'; + +/// A [ListTile] wrapped in a swipe-to-adjust widget. +/// +/// • Swipe **right** (+) → adds 1 unit to [item.quantity] via PATCH. +/// • Swipe **left** (−) → consumes 1 unit via the consume endpoint, +/// preserving consumption history. +/// +/// A small swipe-hint icon is shown at the start of the subtitle so users +/// know the gesture is available without any extra instruction. +class SwipeableInventoryTile extends ConsumerStatefulWidget { + final InventoryItem item; + + const SwipeableInventoryTile({super.key, required this.item}); + + @override + ConsumerState createState() => + _SwipeableInventoryTileState(); +} + +class _SwipeableInventoryTileState + extends ConsumerState { + /// Pixels dragged horizontally. Positive = right, negative = left. + double _drag = 0; + + /// While true, ignore new drag gestures (API call in flight). + bool _acting = false; + + static const _threshold = 72.0; // minimum drag to trigger an action + static const _maxReveal = 88.0; // maximum horizontal travel shown + + // ── Gesture handlers ──────────────────────────────────────────────────── + + void _onUpdate(DragUpdateDetails d) { + if (_acting) return; + setState(() { + _drag = (_drag + d.delta.dx).clamp(-_maxReveal, _maxReveal); + }); + } + + void _onEnd(DragEndDetails d) { + if (_acting) return; + if (_drag > _threshold) { + _adjust(+1); + } else if (_drag < -_threshold) { + _adjust(-1); + } + _snapBack(); + } + + void _snapBack() => setState(() => _drag = 0); + + Future _adjust(int direction) async { + setState(() => _acting = true); + try { + final token = await ref.read(authStateProvider.future); + final repo = ref.read(inventoryRepositoryProvider); + + if (direction > 0) { + // Increase: direct PATCH with new quantity. + await repo.updateInventoryItem( + widget.item.id, + {'quantity': widget.item.quantity + 1}, + token: token, + ); + } else { + // Decrease: use consume endpoint so history is preserved. + // Guard against going below zero. + if (widget.item.quantity <= 0) return; + await repo.consumeInventoryItem( + widget.item.id, + amountUsed: 1, + token: token, + ); + } + + ref.invalidate(inventoryProvider); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(mapErrorToUserMessage(e, context))), + ); + } finally { + if (mounted) setState(() => _acting = false); + } + } + + // ── Build ──────────────────────────────────────────────────────────────── + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + // How far (0.0–1.0) we are from triggering an action, used for opacity. + final rightProgress = (_drag / _threshold).clamp(0.0, 1.0); + final leftProgress = (-_drag / _threshold).clamp(0.0, 1.0); + + return GestureDetector( + onHorizontalDragUpdate: _onUpdate, + onHorizontalDragEnd: _onEnd, + onHorizontalDragCancel: _snapBack, + behavior: HitTestBehavior.opaque, + child: ClipRect( + child: Stack( + children: [ + // ── Right-swipe background (add +1) ───────────────────────── + Positioned.fill( + child: Row( + children: [ + Expanded( + child: Opacity( + opacity: rightProgress, + child: Container( + color: colorScheme.primaryContainer, + alignment: Alignment.centerLeft, + padding: const EdgeInsets.only(left: 20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.add_circle_outline, + color: colorScheme.onPrimaryContainer, + ), + const SizedBox(height: 2), + Text( + '+1', + style: theme.textTheme.labelSmall?.copyWith( + color: colorScheme.onPrimaryContainer, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ), + ), + ], + ), + ), + + // ── Left-swipe background (remove −1) ─────────────────────── + Positioned.fill( + child: Row( + children: [ + const Spacer(), + Expanded( + child: Opacity( + opacity: leftProgress, + child: Container( + color: colorScheme.tertiaryContainer, + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.remove_circle_outline, + color: colorScheme.onTertiaryContainer, + ), + const SizedBox(height: 2), + Text( + '−1', + style: theme.textTheme.labelSmall?.copyWith( + color: colorScheme.onTertiaryContainer, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ), + ), + ], + ), + ), + + // ── Foreground tile ───────────────────────────────────────── + Transform.translate( + offset: Offset(_drag, 0), + child: _ForegroundTile(item: widget.item, acting: _acting), + ), + ], + ), + ), + ); + } +} + +// ── Foreground tile ────────────────────────────────────────────────────────── + +class _ForegroundTile extends ConsumerWidget { + final InventoryItem item; + final bool acting; + + const _ForegroundTile({required this.item, required this.acting}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + + final subtitleText = [ + '${_fmtQty(item.quantity)} ${item.unit}', + if (item.location != null && item.location!.isNotEmpty) item.location!, + if (item.bestBeforeDate != null) + 'Bäst före: ${_formatDate(item.bestBeforeDate!)}', + ].join(' · '); + + return ColoredBox( + // Opaque background so the tile sits on top of the reveal panels. + color: theme.colorScheme.surface, + child: ListTile( + title: Text(item.productName), + // Subtitle: small swipe-hint icon + text on the same row. + subtitle: Row( + children: [ + Icon( + Icons.swap_horiz, + size: 13, + color: theme.colorScheme.outline, + ), + const SizedBox(width: 3), + Expanded( + child: Text( + subtitleText, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + trailing: acting + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : _TrailingActions(item: item), + onTap: () => context.push('/inventory/${item.id}'), + ), + ); + } + + String _fmtQty(double v) => + v == v.roundToDouble() ? v.toStringAsFixed(0) : v.toStringAsFixed(1); + + String _formatDate(String iso) { + try { + final dt = DateTime.parse(iso); + return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-' + '${dt.day.toString().padLeft(2, '0')}'; + } catch (_) { + return iso; + } + } +} + +// ── Trailing action buttons ───────────────────────────────────────────────── + +class _TrailingActions extends ConsumerWidget { + final InventoryItem item; + + const _TrailingActions({required this.item}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (item.opened) + const Padding( + padding: EdgeInsets.only(right: 4), + child: Chip( + label: Text('Öppnad'), + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + ), + ), + Tooltip( + message: 'Konsumera', + child: IconButton( + icon: const Icon(Icons.remove_circle_outline), + onPressed: () => context.push('/inventory/${item.id}/consume'), + ), + ), + Tooltip( + message: 'Redigera', + child: IconButton( + icon: const Icon(Icons.edit_outlined), + onPressed: () => context.push('/inventory/${item.id}/edit'), + ), + ), + _DeleteButton(item: item), + ], + ); + } +} + +class _DeleteButton extends ConsumerWidget { + final InventoryItem item; + + const _DeleteButton({required this.item}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Tooltip( + message: 'Ta bort', + child: IconButton( + icon: const Icon(Icons.delete_outline, color: Colors.red), + onPressed: () async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Ta bort inventariepost?'), + content: Text('Vill du ta bort "${item.productName}"?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Avbryt'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Ta bort'), + ), + ], + ), + ); + if (confirmed != true) return; + try { + final token = await ref.read(authStateProvider.future); + await ref + .read(inventoryRepositoryProvider) + .deleteInventoryItem(item.id, token: token); + ref.invalidate(inventoryProvider); + } catch (e) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(mapErrorToUserMessage(e, context))), + ); + } + }, + ), + ); + } +} diff --git a/flutter/lib/features/meal_plan/data/meal_plan_providers.dart b/flutter/lib/features/meal_plan/data/meal_plan_providers.dart index bec31c1a..be101723 100644 --- a/flutter/lib/features/meal_plan/data/meal_plan_providers.dart +++ b/flutter/lib/features/meal_plan/data/meal_plan_providers.dart @@ -24,18 +24,16 @@ final mealPlanDashboardProvider = FutureProvider((ref) async return guardedApiCall(ref, () async { final repository = ref.read(mealPlanRepositoryProvider); - final entries = await repository.fetchEntries(week.fromIso, week.toIso, token: token); - final shoppingItems = await repository.fetchShoppingList(week.fromIso, week.toIso, token: token); - final inventoryCompareItems = await repository.fetchInventoryCompare( - week.fromIso, - week.toIso, - token: token, - ); + + // Start all three requests in parallel. + final entriesFuture = repository.fetchEntries(week.fromIso, week.toIso, token: token); + final shoppingFuture = repository.fetchShoppingList(week.fromIso, week.toIso, token: token); + final compareFuture = repository.fetchInventoryCompare(week.fromIso, week.toIso, token: token); return MealPlanDashboard( - entries: entries, - shoppingItems: shoppingItems, - inventoryCompareItems: inventoryCompareItems, + entries: await entriesFuture, + shoppingItems: await shoppingFuture, + inventoryCompareItems: await compareFuture, ); }); }); \ No newline at end of file diff --git a/flutter/lib/features/pantry/presentation/pantry_screen.dart b/flutter/lib/features/pantry/presentation/pantry_screen.dart index edcbce08..1f954659 100644 --- a/flutter/lib/features/pantry/presentation/pantry_screen.dart +++ b/flutter/lib/features/pantry/presentation/pantry_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/api/api_error_mapper.dart'; import '../../../core/forms/form_options.dart'; +import '../../../core/ui/product_picker_field.dart'; import '../../../features/inventory/data/inventory_providers.dart'; import '../../auth/data/auth_providers.dart'; import '../../../core/ui/async_state_views.dart'; @@ -264,6 +265,9 @@ class _PantryScreenState extends ConsumerState { (a, b) => a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()), ); + final availableOptions = availableProducts + .map((p) => (id: p.id, name: p.displayName)) + .toList(); final grouped = >{}; for (final item in pantryItems) { @@ -289,27 +293,12 @@ class _PantryScreenState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( - child: DropdownButtonFormField( + child: ProductPickerField( + products: availableOptions, value: _selectedProductId, - isExpanded: true, - decoration: const InputDecoration( - labelText: 'Produkt', - border: OutlineInputBorder(), - ), - items: availableProducts - .map( - (product) => DropdownMenuItem( - value: product.id, - child: Text( - product.displayName, - overflow: TextOverflow.ellipsis, - ), - ), - ) - .toList(), - onChanged: _isSubmitting - ? null - : (value) => setState(() => _selectedProductId = value), + enabled: !_isSubmitting && availableProducts.isNotEmpty, + label: 'Produkt', + onChanged: (value) => setState(() => _selectedProductId = value), ), ), const SizedBox(width: 8),