diff --git a/backend/src/inventory/dto/bulk-delete-inventory.dto.ts b/backend/src/inventory/dto/bulk-delete-inventory.dto.ts new file mode 100644 index 00000000..1a7a2819 --- /dev/null +++ b/backend/src/inventory/dto/bulk-delete-inventory.dto.ts @@ -0,0 +1,11 @@ +import { Type } from 'class-transformer'; +import { ArrayMinSize, IsArray, IsInt, Min } from 'class-validator'; + +export class BulkDeleteInventoryDto { + @IsArray() + @ArrayMinSize(1) + @Type(() => Number) + @IsInt({ each: true }) + @Min(1, { each: true }) + ids!: number[]; +} diff --git a/backend/src/inventory/dto/merge-many-inventory.dto.ts b/backend/src/inventory/dto/merge-many-inventory.dto.ts new file mode 100644 index 00000000..28e19279 --- /dev/null +++ b/backend/src/inventory/dto/merge-many-inventory.dto.ts @@ -0,0 +1,15 @@ +import { Type } from 'class-transformer'; +import { ArrayMinSize, IsArray, IsInt, IsOptional, IsString, Min } from 'class-validator'; + +export class MergeManyInventoryDto { + @IsArray() + @ArrayMinSize(2) + @Type(() => Number) + @IsInt({ each: true }) + @Min(1, { each: true }) + ids!: number[]; + + @IsOptional() + @IsString() + targetUnit?: string; +} diff --git a/backend/src/inventory/inventory.controller.ts b/backend/src/inventory/inventory.controller.ts index 6d278274..4490a657 100644 --- a/backend/src/inventory/inventory.controller.ts +++ b/backend/src/inventory/inventory.controller.ts @@ -18,6 +18,8 @@ import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { Roles } from '../auth/decorators/roles.decorator'; import { MergeInventoryDto } from './dto/merge-inventory.dto'; import { CreateAdminInventoryDto } from './dto/create-admin-inventory.dto'; +import { BulkDeleteInventoryDto } from './dto/bulk-delete-inventory.dto'; +import { MergeManyInventoryDto } from './dto/merge-many-inventory.dto'; @Controller('inventory') export class InventoryController { @@ -97,6 +99,22 @@ export class InventoryController { targetInventoryId, ); } + + @Post('merge-many') + mergeMany( + @CurrentUser() user: { userId: number }, + @Body() body: MergeManyInventoryDto, + ) { + return this.inventoryService.mergeMany(user.userId, body.ids, body.targetUnit); + } + + @Post('bulk-delete') + bulkDelete( + @CurrentUser() user: { userId: number }, + @Body() body: BulkDeleteInventoryDto, + ) { + return this.inventoryService.bulkDelete(user.userId, body.ids); + } @Post(':id/consume') consume( diff --git a/backend/src/inventory/inventory.service.ts b/backend/src/inventory/inventory.service.ts index c86048cb..1ce4a647 100644 --- a/backend/src/inventory/inventory.service.ts +++ b/backend/src/inventory/inventory.service.ts @@ -4,6 +4,7 @@ import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { CreateInventoryDto } from './dto/create-inventory.dto'; import { UpdateInventoryDto } from './dto/update-inventory.dto'; +import { convertUnit, normalizeUnit } from '../common/utils/units'; type InventoryQuery = { location?: string; @@ -348,6 +349,132 @@ export class InventoryService { return this.prisma.inventoryItem.delete({ where: { id } }); } + async bulkDelete(userId: number, ids: number[]) { + const uniqueIds = [...new Set(ids)]; + if (uniqueIds.length === 0) { + throw new BadRequestException('No inventory ids supplied'); + } + + const items = await this.prisma.inventoryItem.findMany({ + where: { id: { in: uniqueIds }, userId }, + select: { id: true }, + }); + + if (items.length !== uniqueIds.length) { + throw new ForbiddenException('One or more inventory items are missing or do not belong to current user'); + } + + const result = await this.prisma.inventoryItem.deleteMany({ + where: { id: { in: uniqueIds }, userId }, + }); + + return { deletedCount: result.count }; + } + + async mergeMany(userId: number, ids: number[], targetUnit?: string) { + const uniqueIds = [...new Set(ids)]; + if (uniqueIds.length < 2) { + throw new BadRequestException('At least two inventory items are required to merge'); + } + + const items = await this.prisma.inventoryItem.findMany({ + where: { id: { in: uniqueIds }, userId }, + include: { + product: this.productWithCategoryInclude, + }, + orderBy: { createdAt: 'asc' }, + }); + + if (items.length !== uniqueIds.length) { + throw new ForbiddenException('One or more inventory items are missing or do not belong to current user'); + } + + const firstProductId = items[0].productId; + if (items.some((item) => item.productId !== firstProductId)) { + throw new BadRequestException('Selected inventory items must belong to the same product'); + } + + const normalizedUnits = new Set(items.map((item) => normalizeUnit(item.unit))); + const resolvedTargetUnitRaw = targetUnit?.trim(); + + if (!resolvedTargetUnitRaw && normalizedUnits.size > 1) { + throw new BadRequestException('targetUnit is required when merging different units'); + } + + const resolvedTargetUnit = normalizeUnit( + resolvedTargetUnitRaw && resolvedTargetUnitRaw.length > 0 + ? resolvedTargetUnitRaw + : items[0].unit, + ); + + if (!items.some((item) => normalizeUnit(item.unit) === resolvedTargetUnit)) { + throw new BadRequestException('targetUnit must match one of the selected item units'); + } + + let mergedQuantity = 0; + for (const item of items) { + const quantity = Number(item.quantity); + try { + mergedQuantity += convertUnit(quantity, item.unit, resolvedTargetUnit); + } catch { + throw new BadRequestException( + `Cannot merge item ${item.id}: incompatible unit "${item.unit}" for target unit "${resolvedTargetUnit}"`, + ); + } + } + + const target = + items.find((item) => normalizeUnit(item.unit) === resolvedTargetUnit) ?? + items[0]; + const sourceItems = items.filter((item) => item.id !== target.id); + + const firstNonNull = (values: (T | null | undefined)[]): T | null => { + for (const value of values) { + if (value !== null && value !== undefined) { + return value; + } + } + return null; + }; + + return this.prisma.$transaction(async (tx) => { + const updated = await tx.inventoryItem.update({ + where: { id: target.id }, + data: { + quantity: new Prisma.Decimal(mergedQuantity), + unit: resolvedTargetUnit, + location: target.location ?? firstNonNull(sourceItems.map((s) => s.location)), + brand: target.brand ?? firstNonNull(sourceItems.map((s) => s.brand)), + origin: target.origin ?? firstNonNull(sourceItems.map((s) => s.origin)), + receiptName: target.receiptName ?? firstNonNull(sourceItems.map((s) => s.receiptName)), + purchaseDate: target.purchaseDate ?? firstNonNull(sourceItems.map((s) => s.purchaseDate)), + opened: target.opened, + suitableFor: target.suitableFor ?? firstNonNull(sourceItems.map((s) => s.suitableFor)), + bestBeforeDate: target.bestBeforeDate ?? firstNonNull(sourceItems.map((s) => s.bestBeforeDate)), + comment: target.comment ?? firstNonNull(sourceItems.map((s) => s.comment)), + }, + include: { + product: this.productWithCategoryInclude, + }, + }); + + const sourceIds = sourceItems.map((item) => item.id); + + if (sourceIds.length > 0) { + await tx.inventoryConsumption.updateMany({ + where: { inventoryItemId: { in: sourceIds } }, + data: { inventoryItemId: target.id }, + }); + + await tx.inventoryItem.deleteMany({ + where: { id: { in: sourceIds }, userId }, + }); + } + + return updated; + }); + } + private async moveInventoryItemToPantryCore(item: { id: number; userId: number; diff --git a/flutter/lib/core/api/api_paths.dart b/flutter/lib/core/api/api_paths.dart index 943dade7..01e99a1b 100644 --- a/flutter/lib/core/api/api_paths.dart +++ b/flutter/lib/core/api/api_paths.dart @@ -59,6 +59,8 @@ class RecipeApiPaths { class InventoryApiPaths { static const list = '/inventory'; + static const mergeMany = '/inventory/merge-many'; + static const bulkDelete = '/inventory/bulk-delete'; static String update(int id) => '/inventory/$id'; static String remove(int id) => '/inventory/$id'; static String moveToPantry(int id) => '/inventory/$id/move-to-pantry'; diff --git a/flutter/lib/core/ui/app_shell.dart b/flutter/lib/core/ui/app_shell.dart index 9523f7b0..d61f05b0 100644 --- a/flutter/lib/core/ui/app_shell.dart +++ b/flutter/lib/core/ui/app_shell.dart @@ -168,12 +168,19 @@ class AppShell extends ConsumerWidget { PopupMenuButton( tooltip: 'Profil och konto', icon: const Icon(Icons.account_circle_outlined), - onSelected: (value) { + onSelected: (value) async { switch (value) { case 'profile': if (location != '/profile' && context.mounted) { onNavigateToPath('/profile'); } + break; + case 'logout': + await ref.read(authStateProvider.notifier).logout(); + if (context.mounted) { + onNavigateToPath('/login'); + } + break; } }, itemBuilder: (context) => const [ @@ -185,6 +192,15 @@ class AppShell extends ConsumerWidget { contentPadding: EdgeInsets.zero, ), ), + PopupMenuDivider(), + PopupMenuItem( + value: 'logout', + child: ListTile( + leading: Icon(Icons.logout), + title: Text('Logga ut'), + contentPadding: EdgeInsets.zero, + ), + ), ], ), ], diff --git a/flutter/lib/features/inventory/data/inventory_repository.dart b/flutter/lib/features/inventory/data/inventory_repository.dart index b861f981..9b124684 100644 --- a/flutter/lib/features/inventory/data/inventory_repository.dart +++ b/flutter/lib/features/inventory/data/inventory_repository.dart @@ -52,6 +52,31 @@ class InventoryRepository { await _api.deleteJson(InventoryApiPaths.remove(id), token: token); } + Future bulkDeleteInventoryItems(List ids, {String? token}) async { + await _api.postJson( + InventoryApiPaths.bulkDelete, + body: {'ids': ids}, + token: token, + ); + } + + Future mergeInventoryItems( + List ids, { + String? targetUnit, + String? token, + }) async { + final body = {'ids': ids}; + if (targetUnit != null && targetUnit.trim().isNotEmpty) { + body['targetUnit'] = targetUnit.trim(); + } + final data = await _api.postJson( + InventoryApiPaths.mergeMany, + body: body, + token: token, + ); + return InventoryItem.fromJson(data as Map); + } + Future moveInventoryItemToPantry(int id, {String? token}) async { await _api.postJson(InventoryApiPaths.moveToPantry(id), body: null, token: token); } diff --git a/flutter/lib/features/inventory/presentation/inventory_screen.dart b/flutter/lib/features/inventory/presentation/inventory_screen.dart index 6b0eb764..7f4cd2ef 100644 --- a/flutter/lib/features/inventory/presentation/inventory_screen.dart +++ b/flutter/lib/features/inventory/presentation/inventory_screen.dart @@ -5,13 +5,27 @@ import 'package:go_router/go_router.dart'; import '../../../core/api/api_error_mapper.dart'; import '../../../core/l10n/l10n.dart'; import '../../../core/ui/async_state_views.dart'; +import '../../auth/data/auth_providers.dart'; import '../data/inventory_providers.dart'; import 'swipeable_inventory_tile.dart'; -class InventoryScreen extends ConsumerWidget { +class InventoryScreen extends ConsumerStatefulWidget { const InventoryScreen({super.key}); + @override + ConsumerState createState() => _InventoryScreenState(); +} + +class _InventoryScreenState extends ConsumerState { + final Set _selectedIds = {}; + static const _locationOptions = ['', 'Kyl', 'Frys', 'Skafferi']; + + static const _weightOrVolumeUnits = { + 'g', 'gram', 'mg', 'milligram', 'hg', 'hektogram', 'kg', 'kilo', 'kilogram', + 'ml', 'milliliter', 'cl', 'centiliter', 'dl', 'deciliter', 'l', 'liter', + }; + List<({String value, String label})> _sortOptions(BuildContext context) => [ (value: '', label: context.l10n.inventorySortLatest), (value: 'nameAsc', label: context.l10n.inventorySortNameAsc), @@ -20,6 +34,232 @@ class InventoryScreen extends ConsumerWidget { (value: 'l1CategoryAsc', label: 'L1-kategori (A-O)'), ]; + void _startSelection(int id) { + setState(() { + _selectedIds.add(id); + }); + } + + void _toggleSelection(int id) { + setState(() { + if (_selectedIds.contains(id)) { + _selectedIds.remove(id); + } else { + _selectedIds.add(id); + } + }); + } + + void _clearSelection() { + setState(() { + _selectedIds.clear(); + }); + } + + void _selectAllVisible(List visibleItems) { + setState(() { + _selectedIds + ..clear() + ..addAll(visibleItems.map((item) => item.id as int)); + }); + } + + bool _isWeightOrVolumeUnit(String unit) { + return _weightOrVolumeUnits.contains(unit.trim().toLowerCase()); + } + + String _preferredUnit(List units) { + for (final unit in units) { + if (_isWeightOrVolumeUnit(unit)) return unit; + } + return units.first; + } + + Future _askMergeTargetUnit(BuildContext context, List units) async { + if (units.isEmpty) return null; + if (units.length == 1) return units.first; + + var selected = _preferredUnit(units); + + return showDialog( + context: context, + builder: (dialogContext) => StatefulBuilder( + builder: (dialogContext, setDialogState) => AlertDialog( + title: const Text('Välj enhet för merge'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Poster har olika enhet. Välj vilken enhet den sammanslagna posten ska använda.', + ), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: selected, + decoration: const InputDecoration( + labelText: 'Enhet', + border: OutlineInputBorder(), + ), + items: units + .map( + (unit) => DropdownMenuItem( + value: unit, + child: Text(unit), + ), + ) + .toList(), + onChanged: (value) { + if (value == null) return; + setDialogState(() => selected = value); + }, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(context.l10n.cancelAction), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, selected), + child: const Text('Merge'), + ), + ], + ), + ), + ); + } + + Future _mergeSelected(BuildContext context, List allItems) async { + final selectedItems = allItems + .where((item) => _selectedIds.contains(item.id)) + .toList(); + + if (selectedItems.length < 2) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Välj minst två poster för merge.')), + ); + return; + } + + final productIds = selectedItems.map((item) => item.productId).toSet(); + if (productIds.length > 1) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Valda poster måste ha samma produkt för merge.')), + ); + return; + } + + final units = []; + final seenUnits = {}; + for (final item in selectedItems) { + final unit = (item.unit as String).trim(); + final key = unit.toLowerCase(); + if (seenUnits.add(key)) { + units.add(unit); + } + } + + final targetUnit = await _askMergeTargetUnit(context, units); + if (!context.mounted || targetUnit == null) return; + + try { + final token = await ref.read(authStateProvider.future); + final ids = selectedItems.map((item) => item.id as int).toList(); + await ref.read(inventoryRepositoryProvider).mergeInventoryItems( + ids, + targetUnit: units.length > 1 ? targetUnit : null, + token: token, + ); + ref.invalidate(inventoryProvider); + if (!mounted) return; + setState(() => _selectedIds.clear()); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Poster sammanslagna.')), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)), + ); + } + } + + Future _deleteSelected(BuildContext context, List allItems) async { + final ids = allItems + .where((item) => _selectedIds.contains(item.id)) + .map((item) => item.id as int) + .toList(); + if (ids.isEmpty) return; + + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Ta bort markerade poster'), + content: Text('Vill du verkligen ta bort ${ids.length} markerade poster?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: Text(context.l10n.cancelAction), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: Text(context.l10n.deleteAction), + ), + ], + ), + ); + + if (confirmed != true || !context.mounted) return; + + try { + final token = await ref.read(authStateProvider.future); + await ref.read(inventoryRepositoryProvider).bulkDeleteInventoryItems(ids, token: token); + ref.invalidate(inventoryProvider); + if (!mounted) return; + setState(() => _selectedIds.clear()); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('${ids.length} poster borttagna.')), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)), + ); + } + } + + Future _openBulkActions(BuildContext context, List allItems) async { + if (_selectedIds.isEmpty) return; + final action = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Hantera markerade poster'), + content: Text('Du har markerat ${_selectedIds.length} poster. Välj åtgärd.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(context.l10n.cancelAction), + ), + OutlinedButton( + onPressed: () => Navigator.pop(dialogContext, 'delete'), + child: Text(context.l10n.deleteAction), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, 'merge'), + child: const Text('Merge'), + ), + ], + ), + ); + + if (action == 'merge') { + await _mergeSelected(context, allItems); + } else if (action == 'delete') { + await _deleteSelected(context, allItems); + } + } + @override Widget build(BuildContext context, WidgetRef ref) { final location = ref.watch(inventoryLocationFilterProvider); @@ -33,6 +273,17 @@ class InventoryScreen extends ConsumerWidget { onRetry: () => ref.invalidate(inventoryProvider), ), data: (items) { + final itemIds = items.map((item) => item.id).toSet(); + final staleIds = _selectedIds.where((id) => !itemIds.contains(id)).toList(); + if (staleIds.isNotEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + setState(() { + _selectedIds.removeWhere((id) => !itemIds.contains(id)); + }); + }); + } + final visibleItems = [...items]; if (sort == 'l1CategoryAsc') { visibleItems.sort((a, b) { @@ -125,6 +376,48 @@ class InventoryScreen extends ConsumerWidget { ), ); + final selectedSection = _selectedIds.isEmpty + ? const SizedBox.shrink() + : Padding( + padding: const EdgeInsets.fromLTRB(12, 4, 12, 4), + child: Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${_selectedIds.length} markerade poster', + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + FilledButton.icon( + onPressed: () => _openBulkActions(context, items), + icon: const Icon(Icons.playlist_add_check), + label: const Text('Hantera markerade'), + ), + OutlinedButton.icon( + onPressed: () => _selectAllVisible(visibleItems), + icon: const Icon(Icons.select_all), + label: const Text('Markera alla synliga'), + ), + OutlinedButton.icon( + onPressed: _clearSelection, + icon: const Icon(Icons.deselect), + label: const Text('Avmarkera'), + ), + ], + ), + ], + ), + ), + ), + ); + if (visibleItems.isEmpty) { return Stack( children: [ @@ -133,6 +426,7 @@ class InventoryScreen extends ConsumerWidget { padding: const EdgeInsets.only(bottom: 88), children: [ headerSection, + selectedSection, filterSection, EmptyStateView(title: context.l10n.inventoryEmpty), ], @@ -155,13 +449,26 @@ class InventoryScreen extends ConsumerWidget { ListView.separated( key: const PageStorageKey('inventory-main-list'), padding: const EdgeInsets.only(bottom: 88), - itemCount: visibleItems.length + 2, + itemCount: visibleItems.length + (_selectedIds.isEmpty ? 2 : 3), separatorBuilder: (_, __) => const Divider(height: 1), itemBuilder: (context, index) { if (index == 0) return filterSection; if (index == 1) return headerSection; - final item = visibleItems[index - 2]; - return SwipeableInventoryTile(item: item); + if (_selectedIds.isNotEmpty && index == 2) return selectedSection; + final item = visibleItems[index - (_selectedIds.isEmpty ? 2 : 3)]; + return SwipeableInventoryTile( + item: item, + selectableMode: _selectedIds.isNotEmpty, + selected: _selectedIds.contains(item.id), + onToggleSelected: () => _toggleSelection(item.id), + onLongPress: () { + if (_selectedIds.isEmpty) { + _startSelection(item.id); + } else { + _toggleSelection(item.id); + } + }, + ); }, ), Positioned( diff --git a/flutter/lib/features/inventory/presentation/swipeable_inventory_tile.dart b/flutter/lib/features/inventory/presentation/swipeable_inventory_tile.dart index 6c2ea955..4c4f91c9 100644 --- a/flutter/lib/features/inventory/presentation/swipeable_inventory_tile.dart +++ b/flutter/lib/features/inventory/presentation/swipeable_inventory_tile.dart @@ -52,8 +52,19 @@ String _fmtStep(double step) => /// know the gesture is available without any extra instruction. class SwipeableInventoryTile extends ConsumerStatefulWidget { final InventoryItem item; + final bool selectableMode; + final bool selected; + final VoidCallback? onToggleSelected; + final VoidCallback? onLongPress; - const SwipeableInventoryTile({super.key, required this.item}); + const SwipeableInventoryTile({ + super.key, + required this.item, + this.selectableMode = false, + this.selected = false, + this.onToggleSelected, + this.onLongPress, + }); @override ConsumerState createState() => @@ -74,14 +85,14 @@ class _SwipeableInventoryTileState // ── Gesture handlers ──────────────────────────────────────────────────── void _onUpdate(DragUpdateDetails d) { - if (_acting) return; + if (_acting || widget.selectableMode) return; setState(() { _drag = (_drag + d.delta.dx).clamp(-_maxReveal, _maxReveal); }); } void _onEnd(DragEndDetails d) { - if (_acting) return; + if (_acting || widget.selectableMode) return; if (_drag > _threshold) { _adjust(1); } else if (_drag < -_threshold) { @@ -144,10 +155,28 @@ class _SwipeableInventoryTileState final stepLabel = _fmtStep(step); final unit = widget.item.unit; + final foreground = _ForegroundTile( + item: widget.item, + acting: _acting, + selectableMode: widget.selectableMode, + selected: widget.selected, + onToggleSelected: widget.onToggleSelected, + onLongPress: widget.onLongPress, + ); + + if (widget.selectableMode) { + return GestureDetector( + onLongPress: widget.onLongPress, + behavior: HitTestBehavior.opaque, + child: foreground, + ); + } + return GestureDetector( onHorizontalDragUpdate: _onUpdate, onHorizontalDragEnd: _onEnd, onHorizontalDragCancel: _snapBack, + onLongPress: widget.onLongPress, behavior: HitTestBehavior.opaque, child: ClipRect( child: Stack( @@ -226,7 +255,7 @@ class _SwipeableInventoryTileState // ── Foreground tile ───────────────────────────────────────── Transform.translate( offset: Offset(_drag, 0), - child: _ForegroundTile(item: widget.item, acting: _acting), + child: foreground, ), ], ), @@ -240,8 +269,19 @@ class _SwipeableInventoryTileState class _ForegroundTile extends ConsumerWidget { final InventoryItem item; final bool acting; + final bool selectableMode; + final bool selected; + final VoidCallback? onToggleSelected; + final VoidCallback? onLongPress; - const _ForegroundTile({required this.item, required this.acting}); + const _ForegroundTile({ + required this.item, + required this.acting, + this.selectableMode = false, + this.selected = false, + this.onToggleSelected, + this.onLongPress, + }); @override Widget build(BuildContext context, WidgetRef ref) { @@ -258,16 +298,24 @@ class _ForegroundTile extends ConsumerWidget { // Opaque background so the tile sits on top of the reveal panels. color: theme.colorScheme.surface, child: ListTile( - title: Text(item.productName), + leading: selectableMode + ? Checkbox( + value: selected, + onChanged: (_) => onToggleSelected?.call(), + ) + : null, + title: Text(item.displayName), // 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), + if (!selectableMode) ...[ + Icon( + Icons.swap_horiz, + size: 13, + color: theme.colorScheme.outline, + ), + const SizedBox(width: 3), + ], Expanded( child: Text( subtitleText, @@ -282,8 +330,11 @@ class _ForegroundTile extends ConsumerWidget { height: 24, child: CircularProgressIndicator(strokeWidth: 2), ) - : _TrailingActions(item: item), - onTap: () => context.push('/inventory/${item.id}'), + : (selectableMode ? null : _TrailingActions(item: item)), + onTap: selectableMode + ? onToggleSelected + : () => context.push('/inventory/${item.id}'), + onLongPress: onLongPress, ), ); }