feat(item-list): Suche und Filter auf Item-Liste (#76)

ItemListScreen: Suchleiste (OutlinedTextField) mit Search/Clear-Icons,
FilterChipRow mit Dropdown-Menüs für Kategorie, Lagerort und
Ablaufstatus (Abgelaufen/Bald ablaufend/OK). Leere Ergebnisse
zeigen 'Keine Items gefunden'. Suche durchsucht Name und Notizen
(case-insensitive), alle Filter sind kombinierbar.

ItemListViewModel: FilterState (MutableStateFlow) mit searchQuery,
categoryId, locationId, expiryFilter. combine() mit 4 Flows,
lokale Filterung auf gemappten ItemUiModel-Instanzen.

ItemUiModel: locationId und notes mit Defaults hinzugefügt.

13 neue Unit-Tests für Suche, Filter, Kombinationen und
Leerzustände. Alle 257 Tests grün.

Closes #76
This commit is contained in:
Jens Reinemann 2026-05-17 03:42:06 +02:00
parent 7c17f8ea2f
commit 6711a0e056
4 changed files with 625 additions and 25 deletions

View file

@ -1,6 +1,7 @@
package de.krisenvorrat.app.ui.item
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -10,22 +11,29 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.PhotoCamera
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@ -106,23 +114,46 @@ internal fun ItemListScreen(
}
}
) { innerPadding ->
if (uiState.isEmpty) {
EmptyState(
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
) {
ItemSearchBar(
query = uiState.searchQuery,
onQueryChanged = viewModel::onSearchQueryChanged,
onClearClick = viewModel::clearSearch
)
} else {
ItemList(
FilterChipRow(
uiState = uiState,
onCategorySelected = viewModel::onCategoryFilterChanged,
onLocationSelected = viewModel::onLocationFilterChanged,
onExpirySelected = viewModel::onExpiryFilterChanged
)
when {
uiState.isEmpty -> EmptyState(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
)
uiState.hasNoFilterResults -> NoFilterResultsState(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
)
else -> ItemList(
groupedItems = uiState.groupedItems,
onItemClick = onItemClick,
onDeleteClick = viewModel::showDeleteDialog,
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.weight(1f)
.fillMaxWidth()
)
}
}
}
if (uiState.isDeleteDialogVisible && uiState.itemToDelete != null) {
DeleteItemDialog(
@ -256,6 +287,145 @@ private fun ExpiryDateText(item: ItemUiModel) {
)
}
@Composable
private fun ItemSearchBar(
query: String,
onQueryChanged: (String) -> Unit,
onClearClick: () -> Unit,
modifier: Modifier = Modifier
) {
OutlinedTextField(
value = query,
onValueChange = onQueryChanged,
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
placeholder = { Text("Suchen...") },
leadingIcon = {
Icon(Icons.Default.Search, contentDescription = "Suchen")
},
trailingIcon = {
if (query.isNotEmpty()) {
IconButton(onClick = onClearClick) {
Icon(Icons.Default.Close, contentDescription = "Suche löschen")
}
}
},
singleLine = true
)
}
@Composable
private fun FilterChipRow(
uiState: ItemListUiState,
onCategorySelected: (Int?) -> Unit,
onLocationSelected: (Int?) -> Unit,
onExpirySelected: (ExpiryFilter?) -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
FilterDropdownChip(
label = "Kategorie",
selectedLabel = uiState.availableCategories
.find { it.first == uiState.selectedCategoryId }?.second,
options = uiState.availableCategories,
onOptionSelected = { onCategorySelected(it) },
onClear = { onCategorySelected(null) }
)
FilterDropdownChip(
label = "Lagerort",
selectedLabel = uiState.availableLocations
.find { it.first == uiState.selectedLocationId }?.second,
options = uiState.availableLocations,
onOptionSelected = { onLocationSelected(it) },
onClear = { onLocationSelected(null) }
)
FilterDropdownChip(
label = "Ablauf",
selectedLabel = uiState.selectedExpiryFilter?.label,
options = ExpiryFilter.entries.map { it.ordinal to it.label },
onOptionSelected = { ordinal ->
onExpirySelected(ExpiryFilter.entries[ordinal])
},
onClear = { onExpirySelected(null) }
)
}
}
@Composable
private fun FilterDropdownChip(
label: String,
selectedLabel: String?,
options: List<Pair<Int, String>>,
onOptionSelected: (Int) -> Unit,
onClear: () -> Unit
) {
var expanded by remember { mutableStateOf(false) }
val isSelected = selectedLabel != null
Box {
FilterChip(
selected = isSelected,
onClick = { expanded = true },
label = {
Text(if (isSelected) "$label: $selectedLabel" else label)
},
trailingIcon = {
Icon(
imageVector = Icons.Default.ArrowDropDown,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
}
)
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
if (isSelected) {
DropdownMenuItem(
text = { Text("Alle") },
onClick = {
expanded = false
onClear()
}
)
}
options.forEach { (id, name) ->
DropdownMenuItem(
text = { Text(name) },
onClick = {
expanded = false
onOptionSelected(id)
}
)
}
}
}
}
@Composable
private fun NoFilterResultsState(modifier: Modifier = Modifier) {
Box(
modifier = modifier,
contentAlignment = Alignment.Center
) {
Text(
text = "Keine Items gefunden",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
private fun DeleteItemDialog(
itemName: String,

View file

@ -15,12 +15,39 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
internal enum class ExpiryFilter(val label: String) {
EXPIRED("Abgelaufen"),
EXPIRING_SOON("Bald ablaufend"),
OK("OK")
}
private data class FilterState(
val searchQuery: String = "",
val categoryId: Int? = null,
val locationId: Int? = null,
val expiryFilter: ExpiryFilter? = null
)
internal data class ItemListUiState(
val groupedItems: Map<String, List<ItemUiModel>> = emptyMap(),
val totalItemCount: Int = 0,
val searchQuery: String = "",
val selectedCategoryId: Int? = null,
val selectedLocationId: Int? = null,
val selectedExpiryFilter: ExpiryFilter? = null,
val availableCategories: List<Pair<Int, String>> = emptyList(),
val availableLocations: List<Pair<Int, String>> = emptyList(),
val isDeleteDialogVisible: Boolean = false,
val itemToDelete: ItemUiModel? = null
) {
val isEmpty: Boolean get() = groupedItems.isEmpty()
val isEmpty: Boolean get() = totalItemCount == 0
val isFiltering: Boolean
get() = searchQuery.isNotBlank() ||
selectedCategoryId != null ||
selectedLocationId != null ||
selectedExpiryFilter != null
val hasNoFilterResults: Boolean
get() = isFiltering && groupedItems.isEmpty() && !isEmpty
}
@HiltViewModel
@ -30,6 +57,7 @@ internal class ItemListViewModel @Inject constructor(
private val locationRepository: LocationRepository
) : ViewModel() {
private val _filterState = MutableStateFlow(FilterState())
private val _uiState = MutableStateFlow(ItemListUiState())
val uiState: StateFlow<ItemListUiState> = _uiState.asStateFlow()
@ -38,12 +66,13 @@ internal class ItemListViewModel @Inject constructor(
combine(
itemRepository.getAll(),
categoryRepository.getAll(),
locationRepository.getAll()
) { items, categories, locations ->
locationRepository.getAll(),
_filterState
) { items, categories, locations, filters ->
val categoryMap = categories.associate { it.id to it.name }
val locationMap = locations.associate { it.id to it.name }
items.map { item ->
val allUiItems = items.map { item ->
ItemUiModel(
id = item.id,
name = item.name,
@ -52,15 +81,82 @@ internal class ItemListViewModel @Inject constructor(
unit = item.unit,
locationName = locationMap[item.locationId] ?: "Unbekannt",
expiryDate = item.expiryDate,
categoryId = item.categoryId
categoryId = item.categoryId,
locationId = item.locationId,
notes = item.notes
)
}.groupBy { it.categoryName }
}
val filteredItems = allUiItems
.filter { item ->
if (filters.searchQuery.isBlank()) true
else {
val query = filters.searchQuery.lowercase()
item.name.lowercase().contains(query) ||
item.notes.lowercase().contains(query)
}
}
.filter { item ->
filters.categoryId == null || item.categoryId == filters.categoryId
}
.filter { item ->
filters.locationId == null || item.locationId == filters.locationId
}
.filter { item ->
when (filters.expiryFilter) {
null -> true
ExpiryFilter.EXPIRED -> item.isExpired
ExpiryFilter.EXPIRING_SOON -> item.isExpiringSoon
ExpiryFilter.OK -> !item.isExpired && !item.isExpiringSoon
}
}
val grouped = filteredItems
.groupBy { it.categoryName }
.toSortedMap()
}.collect { grouped ->
_uiState.update { it.copy(groupedItems = grouped) }
ItemListUiState(
groupedItems = grouped,
totalItemCount = allUiItems.size,
searchQuery = filters.searchQuery,
selectedCategoryId = filters.categoryId,
selectedLocationId = filters.locationId,
selectedExpiryFilter = filters.expiryFilter,
availableCategories = categories.map { it.id to it.name }
.sortedBy { it.second },
availableLocations = locations.map { it.id to it.name }
.sortedBy { it.second }
)
}.collect { newState ->
_uiState.update { old ->
newState.copy(
isDeleteDialogVisible = old.isDeleteDialogVisible,
itemToDelete = old.itemToDelete
)
}
}
}
}
fun onSearchQueryChanged(query: String) {
_filterState.update { it.copy(searchQuery = query) }
}
fun onCategoryFilterChanged(categoryId: Int?) {
_filterState.update { it.copy(categoryId = categoryId) }
}
fun onLocationFilterChanged(locationId: Int?) {
_filterState.update { it.copy(locationId = locationId) }
}
fun onExpiryFilterChanged(expiryFilter: ExpiryFilter?) {
_filterState.update { it.copy(expiryFilter = expiryFilter) }
}
fun clearSearch() {
_filterState.update { it.copy(searchQuery = "") }
}
fun showDeleteDialog(item: ItemUiModel) {
_uiState.update { it.copy(isDeleteDialogVisible = true, itemToDelete = item) }

View file

@ -10,7 +10,9 @@ internal data class ItemUiModel(
val unit: String,
val locationName: String,
val expiryDate: LocalDate?,
val categoryId: Int
val categoryId: Int,
val locationId: Int = 0,
val notes: String = ""
) {
val isExpired: Boolean

View file

@ -214,6 +214,336 @@ class ItemListViewModelTest {
val keys = viewModel.uiState.value.groupedItems.keys.toList()
assertEquals(listOf("Arzneimittel", "Lebensmittel", "Wasser"), keys)
}
// region Search & Filter Tests
@Test
fun test_searchByName_filtersCorrectly() = runTest(testDispatcher) {
// Given
fakeCategoryRepository.emit(listOf(CategoryEntity(id = 1, name = "Lebensmittel")))
fakeLocationRepository.emit(listOf(LocationEntity(id = 1, name = "Keller")))
fakeItemRepository.emit(
listOf(
buildItemEntity(id = "a", name = "Reis"),
buildItemEntity(id = "b", name = "Konserve"),
buildItemEntity(id = "c", name = "Reismehl")
)
)
advanceUntilIdle()
// When
viewModel.onSearchQueryChanged("Reis")
advanceUntilIdle()
// Then
val allItems = viewModel.uiState.value.groupedItems.values.flatten()
assertEquals(2, allItems.size)
assertTrue(allItems.all { it.name.contains("Reis") })
}
@Test
fun test_searchByNotes_filtersCorrectly() = runTest(testDispatcher) {
// Given
fakeCategoryRepository.emit(listOf(CategoryEntity(id = 1, name = "Lebensmittel")))
fakeLocationRepository.emit(listOf(LocationEntity(id = 1, name = "Keller")))
fakeItemRepository.emit(
listOf(
buildItemEntity(id = "a", name = "Reis", notes = "glutenfrei"),
buildItemEntity(id = "b", name = "Nudeln", notes = "enthält Gluten")
)
)
advanceUntilIdle()
// When
viewModel.onSearchQueryChanged("glutenfrei")
advanceUntilIdle()
// Then
val allItems = viewModel.uiState.value.groupedItems.values.flatten()
assertEquals(1, allItems.size)
assertEquals("Reis", allItems.first().name)
}
@Test
fun test_searchIsCaseInsensitive() = runTest(testDispatcher) {
// Given
fakeCategoryRepository.emit(listOf(CategoryEntity(id = 1, name = "Lebensmittel")))
fakeLocationRepository.emit(listOf(LocationEntity(id = 1, name = "Keller")))
fakeItemRepository.emit(
listOf(
buildItemEntity(id = "a", name = "Reis"),
buildItemEntity(id = "b", name = "Nudeln")
)
)
advanceUntilIdle()
// When
viewModel.onSearchQueryChanged("reis")
advanceUntilIdle()
// Then
val allItems = viewModel.uiState.value.groupedItems.values.flatten()
assertEquals(1, allItems.size)
assertEquals("Reis", allItems.first().name)
}
@Test
fun test_filterByCategory_showsOnlyMatchingItems() = runTest(testDispatcher) {
// Given
fakeCategoryRepository.emit(
listOf(
CategoryEntity(id = 1, name = "Lebensmittel"),
CategoryEntity(id = 2, name = "Hygiene")
)
)
fakeLocationRepository.emit(listOf(LocationEntity(id = 1, name = "Keller")))
fakeItemRepository.emit(
listOf(
buildItemEntity(id = "a", name = "Reis", categoryId = 1),
buildItemEntity(id = "b", name = "Seife", categoryId = 2)
)
)
advanceUntilIdle()
// When
viewModel.onCategoryFilterChanged(1)
advanceUntilIdle()
// Then
val allItems = viewModel.uiState.value.groupedItems.values.flatten()
assertEquals(1, allItems.size)
assertEquals("Reis", allItems.first().name)
}
@Test
fun test_filterByLocation_showsOnlyMatchingItems() = runTest(testDispatcher) {
// Given
fakeCategoryRepository.emit(listOf(CategoryEntity(id = 1, name = "Lebensmittel")))
fakeLocationRepository.emit(
listOf(
LocationEntity(id = 1, name = "Keller"),
LocationEntity(id = 2, name = "Speisekammer")
)
)
fakeItemRepository.emit(
listOf(
buildItemEntity(id = "a", name = "Reis", locationId = 1),
buildItemEntity(id = "b", name = "Nudeln", locationId = 2)
)
)
advanceUntilIdle()
// When
viewModel.onLocationFilterChanged(2)
advanceUntilIdle()
// Then
val allItems = viewModel.uiState.value.groupedItems.values.flatten()
assertEquals(1, allItems.size)
assertEquals("Nudeln", allItems.first().name)
}
@Test
fun test_filterByExpiryExpired_showsOnlyExpiredItems() = runTest(testDispatcher) {
// Given
fakeCategoryRepository.emit(listOf(CategoryEntity(id = 1, name = "Lebensmittel")))
fakeLocationRepository.emit(listOf(LocationEntity(id = 1, name = "Keller")))
fakeItemRepository.emit(
listOf(
buildItemEntity(id = "a", name = "Abgelaufen", expiryDate = LocalDate.now().minusDays(5)),
buildItemEntity(id = "b", name = "Frisch", expiryDate = LocalDate.now().plusDays(90))
)
)
advanceUntilIdle()
// When
viewModel.onExpiryFilterChanged(ExpiryFilter.EXPIRED)
advanceUntilIdle()
// Then
val allItems = viewModel.uiState.value.groupedItems.values.flatten()
assertEquals(1, allItems.size)
assertEquals("Abgelaufen", allItems.first().name)
}
@Test
fun test_filterByExpirySoon_showsOnlyExpiringSoonItems() = runTest(testDispatcher) {
// Given
fakeCategoryRepository.emit(listOf(CategoryEntity(id = 1, name = "Lebensmittel")))
fakeLocationRepository.emit(listOf(LocationEntity(id = 1, name = "Keller")))
fakeItemRepository.emit(
listOf(
buildItemEntity(id = "a", name = "Abgelaufen", expiryDate = LocalDate.now().minusDays(5)),
buildItemEntity(id = "b", name = "BaldAblaufend", expiryDate = LocalDate.now().plusDays(10)),
buildItemEntity(id = "c", name = "Frisch", expiryDate = LocalDate.now().plusDays(90))
)
)
advanceUntilIdle()
// When
viewModel.onExpiryFilterChanged(ExpiryFilter.EXPIRING_SOON)
advanceUntilIdle()
// Then
val allItems = viewModel.uiState.value.groupedItems.values.flatten()
assertEquals(1, allItems.size)
assertEquals("BaldAblaufend", allItems.first().name)
}
@Test
fun test_filterByExpiryOk_showsOnlyOkItems() = runTest(testDispatcher) {
// Given
fakeCategoryRepository.emit(listOf(CategoryEntity(id = 1, name = "Lebensmittel")))
fakeLocationRepository.emit(listOf(LocationEntity(id = 1, name = "Keller")))
fakeItemRepository.emit(
listOf(
buildItemEntity(id = "a", name = "Abgelaufen", expiryDate = LocalDate.now().minusDays(5)),
buildItemEntity(id = "b", name = "Frisch", expiryDate = LocalDate.now().plusDays(90)),
buildItemEntity(id = "c", name = "OhneDatum", expiryDate = null)
)
)
advanceUntilIdle()
// When
viewModel.onExpiryFilterChanged(ExpiryFilter.OK)
advanceUntilIdle()
// Then
val allItems = viewModel.uiState.value.groupedItems.values.flatten()
assertEquals(2, allItems.size)
assertTrue(allItems.map { it.name }.containsAll(listOf("Frisch", "OhneDatum")))
}
@Test
fun test_combinedSearchAndFilter_appliesBoth() = runTest(testDispatcher) {
// Given
fakeCategoryRepository.emit(
listOf(
CategoryEntity(id = 1, name = "Lebensmittel"),
CategoryEntity(id = 2, name = "Hygiene")
)
)
fakeLocationRepository.emit(listOf(LocationEntity(id = 1, name = "Keller")))
fakeItemRepository.emit(
listOf(
buildItemEntity(id = "a", name = "Reis", categoryId = 1),
buildItemEntity(id = "b", name = "Reisseife", categoryId = 2),
buildItemEntity(id = "c", name = "Nudeln", categoryId = 1)
)
)
advanceUntilIdle()
// When
viewModel.onSearchQueryChanged("Reis")
viewModel.onCategoryFilterChanged(1)
advanceUntilIdle()
// Then
val allItems = viewModel.uiState.value.groupedItems.values.flatten()
assertEquals(1, allItems.size)
assertEquals("Reis", allItems.first().name)
}
@Test
fun test_clearSearch_showsAllItems() = runTest(testDispatcher) {
// Given
fakeCategoryRepository.emit(listOf(CategoryEntity(id = 1, name = "Lebensmittel")))
fakeLocationRepository.emit(listOf(LocationEntity(id = 1, name = "Keller")))
fakeItemRepository.emit(
listOf(
buildItemEntity(id = "a", name = "Reis"),
buildItemEntity(id = "b", name = "Nudeln")
)
)
advanceUntilIdle()
viewModel.onSearchQueryChanged("Reis")
advanceUntilIdle()
assertEquals(1, viewModel.uiState.value.groupedItems.values.flatten().size)
// When
viewModel.clearSearch()
advanceUntilIdle()
// Then
val allItems = viewModel.uiState.value.groupedItems.values.flatten()
assertEquals(2, allItems.size)
}
@Test
fun test_noFilterResults_hasNoFilterResultsIsTrue() = runTest(testDispatcher) {
// Given
fakeCategoryRepository.emit(listOf(CategoryEntity(id = 1, name = "Lebensmittel")))
fakeLocationRepository.emit(listOf(LocationEntity(id = 1, name = "Keller")))
fakeItemRepository.emit(listOf(buildItemEntity(id = "a", name = "Reis")))
advanceUntilIdle()
// When
viewModel.onSearchQueryChanged("XYZ-nicht-vorhanden")
advanceUntilIdle()
// Then
val state = viewModel.uiState.value
assertTrue(state.hasNoFilterResults)
assertFalse(state.isEmpty)
assertTrue(state.groupedItems.isEmpty())
}
@Test
fun test_availableCategoriesAndLocations_arePopulated() = runTest(testDispatcher) {
// Given
fakeCategoryRepository.emit(
listOf(
CategoryEntity(id = 2, name = "Hygiene"),
CategoryEntity(id = 1, name = "Lebensmittel")
)
)
fakeLocationRepository.emit(
listOf(
LocationEntity(id = 2, name = "Speisekammer"),
LocationEntity(id = 1, name = "Keller")
)
)
fakeItemRepository.emit(emptyList())
// When
advanceUntilIdle()
// Then
val state = viewModel.uiState.value
assertEquals(listOf(2 to "Hygiene", 1 to "Lebensmittel"), state.availableCategories)
assertEquals(listOf(1 to "Keller", 2 to "Speisekammer"), state.availableLocations)
}
@Test
fun test_filterByCategory_thenClear_showsAllItems() = runTest(testDispatcher) {
// Given
fakeCategoryRepository.emit(
listOf(
CategoryEntity(id = 1, name = "Lebensmittel"),
CategoryEntity(id = 2, name = "Hygiene")
)
)
fakeLocationRepository.emit(listOf(LocationEntity(id = 1, name = "Keller")))
fakeItemRepository.emit(
listOf(
buildItemEntity(id = "a", name = "Reis", categoryId = 1),
buildItemEntity(id = "b", name = "Seife", categoryId = 2)
)
)
advanceUntilIdle()
viewModel.onCategoryFilterChanged(1)
advanceUntilIdle()
assertEquals(1, viewModel.uiState.value.groupedItems.values.flatten().size)
// When
viewModel.onCategoryFilterChanged(null)
advanceUntilIdle()
// Then
assertEquals(2, viewModel.uiState.value.groupedItems.values.flatten().size)
}
// endregion
}
// region Test Helpers
@ -222,7 +552,9 @@ private fun buildItemEntity(
id: String = "id1",
name: String = "Konserve",
categoryId: Int = 1,
locationId: Int = 1
locationId: Int = 1,
notes: String = "",
expiryDate: LocalDate? = null
) = ItemEntity(
id = id,
name = name,
@ -231,9 +563,9 @@ private fun buildItemEntity(
unit = "Stk",
unitPrice = 1.5,
kcalPerKg = null,
expiryDate = null,
expiryDate = expiryDate,
locationId = locationId,
notes = "",
notes = notes,
lastUpdated = 0L
)