From 645578b66e333a760467593e52ad443eaf70cd4b Mon Sep 17 00:00:00 2001 From: Jens Reinemann Date: Mon, 18 May 2026 21:39:24 +0200 Subject: [PATCH] feat(item-form): add unit dropdown with predefined list and custom option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Provides 8 predefined units (Stück, g, kg, ml, l, Packung, Dose, Flasche) plus a custom input option. Normalizes legacy unit strings on app start. Dynamic price label shows selected unit. Closes #114 --- .../de/bollwerk/app/data/db/dao/ItemDao.kt | 3 + .../app/data/repository/ItemRepositoryImpl.kt | 3 + .../bollwerk/app/domain/model/UnitOption.kt | 25 +++++++ .../de/bollwerk/app/ui/item/ItemFormScreen.kt | 73 +++++++++++++++++-- .../bollwerk/app/ui/item/ItemFormViewModel.kt | 31 ++++++-- .../de/bollwerk/app/data/export/TestFakes.kt | 2 + .../data/repository/ItemRepositoryImplTest.kt | 5 ++ .../app/ui/item/ItemFormViewModelTest.kt | 4 +- 8 files changed, 132 insertions(+), 14 deletions(-) create mode 100644 app/src/main/java/de/bollwerk/app/domain/model/UnitOption.kt diff --git a/app/src/main/java/de/bollwerk/app/data/db/dao/ItemDao.kt b/app/src/main/java/de/bollwerk/app/data/db/dao/ItemDao.kt index 82a0547..3daa5b2 100644 --- a/app/src/main/java/de/bollwerk/app/data/db/dao/ItemDao.kt +++ b/app/src/main/java/de/bollwerk/app/data/db/dao/ItemDao.kt @@ -57,4 +57,7 @@ internal interface ItemDao { @Query("SELECT location_id FROM items ORDER BY last_updated DESC LIMIT 1") suspend fun getLastUsedLocationId(): Int? + + @Query("UPDATE items SET unit = 'Stück' WHERE unit IN ('Stk', 'Stk.')") + suspend fun normalizeUnits() } diff --git a/app/src/main/java/de/bollwerk/app/data/repository/ItemRepositoryImpl.kt b/app/src/main/java/de/bollwerk/app/data/repository/ItemRepositoryImpl.kt index 8a884d4..f0c6f61 100644 --- a/app/src/main/java/de/bollwerk/app/data/repository/ItemRepositoryImpl.kt +++ b/app/src/main/java/de/bollwerk/app/data/repository/ItemRepositoryImpl.kt @@ -33,6 +33,9 @@ internal class ItemRepositoryImpl @Inject constructor( ) : ItemRepository { init { + scope.launch { + withContext(Dispatchers.IO) { dao.normalizeUnits() } + } scope.launch { webSocketClient.events.collect { event -> if (event is WebSocketEvent.Connected) { diff --git a/app/src/main/java/de/bollwerk/app/domain/model/UnitOption.kt b/app/src/main/java/de/bollwerk/app/domain/model/UnitOption.kt new file mode 100644 index 0000000..5a969f2 --- /dev/null +++ b/app/src/main/java/de/bollwerk/app/domain/model/UnitOption.kt @@ -0,0 +1,25 @@ +package de.bollwerk.app.domain.model + +/// Vordefinierte Einheiten für das Item-Formular. +internal object UnitOptions { + + const val CUSTOM_OPTION = "Benutzerdefiniert" + + val predefined: List = listOf( + "Stück", + "g", + "kg", + "ml", + "l", + "Packung", + "Dose", + "Flasche" + ) + + /// Alle Dropdown-Einträge inkl. Custom-Option am Ende. + val dropdownEntries: List = predefined + CUSTOM_OPTION + + /// Prüft ob ein Wert in der Vordefiniert-Liste enthalten ist. + fun isPredefined(value: String): Boolean = + predefined.any { it.equals(value, ignoreCase = true) } +} diff --git a/app/src/main/java/de/bollwerk/app/ui/item/ItemFormScreen.kt b/app/src/main/java/de/bollwerk/app/ui/item/ItemFormScreen.kt index fc79319..e67c8c4 100644 --- a/app/src/main/java/de/bollwerk/app/ui/item/ItemFormScreen.kt +++ b/app/src/main/java/de/bollwerk/app/ui/item/ItemFormScreen.kt @@ -40,6 +40,7 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.bollwerk.app.domain.model.UnitOptions import java.time.LocalDate import java.time.YearMonth import java.time.format.DateTimeFormatter @@ -135,15 +136,25 @@ internal fun ItemFormScreen( Spacer(modifier = Modifier.height(8.dp)) - // Einheit - OutlinedTextField( - value = uiState.unit, - onValueChange = viewModel::updateUnit, - label = { Text("Einheit") }, - singleLine = true, - modifier = Modifier.fillMaxWidth() + // Einheit Dropdown + UnitDropdown( + selectedUnit = uiState.unit, + onUnitSelected = viewModel::updateUnit ) + // Benutzerdefiniertes Einheit-Feld + if (uiState.isCustomUnit) { + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = uiState.customUnit, + onValueChange = viewModel::updateCustomUnit, + label = { Text("Eigene Einheit") }, + placeholder = { Text("z.B. Karton") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + } + Spacer(modifier = Modifier.height(8.dp)) // Lagerort Dropdown @@ -169,7 +180,14 @@ internal fun ItemFormScreen( OutlinedTextField( value = uiState.unitPrice, onValueChange = viewModel::updateUnitPrice, - label = { Text("Preis (€)") }, + label = { + val unitLabel = if (uiState.isCustomUnit) { + uiState.customUnit.ifBlank { "Einheit" } + } else { + uiState.unit + } + Text("Preis pro $unitLabel (€)") + }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), singleLine = true, modifier = Modifier.fillMaxWidth() @@ -458,3 +476,42 @@ private fun MonthYearPickerDialog( } ) } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun UnitDropdown( + selectedUnit: String, + onUnitSelected: (String) -> Unit +) { + var isExpanded by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + expanded = isExpanded, + onExpandedChange = { isExpanded = it } + ) { + OutlinedTextField( + value = selectedUnit, + onValueChange = {}, + readOnly = true, + label = { Text("Einheit") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = isExpanded) }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(MenuAnchorType.PrimaryNotEditable) + ) + ExposedDropdownMenu( + expanded = isExpanded, + onDismissRequest = { isExpanded = false } + ) { + UnitOptions.dropdownEntries.forEach { unit -> + DropdownMenuItem( + text = { Text(unit) }, + onClick = { + onUnitSelected(unit) + isExpanded = false + } + ) + } + } + } +} diff --git a/app/src/main/java/de/bollwerk/app/ui/item/ItemFormViewModel.kt b/app/src/main/java/de/bollwerk/app/ui/item/ItemFormViewModel.kt index 57f04af..2f09e32 100644 --- a/app/src/main/java/de/bollwerk/app/ui/item/ItemFormViewModel.kt +++ b/app/src/main/java/de/bollwerk/app/ui/item/ItemFormViewModel.kt @@ -7,6 +7,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import de.bollwerk.app.data.db.entity.CategoryEntity import de.bollwerk.app.data.db.entity.ItemEntity import de.bollwerk.app.data.db.entity.LocationEntity +import de.bollwerk.app.domain.model.UnitOptions import de.bollwerk.app.domain.repository.CategoryRepository import de.bollwerk.app.domain.repository.ItemRepository import de.bollwerk.app.domain.repository.LocationRepository @@ -27,7 +28,9 @@ internal data class ItemFormUiState( val name: String = "", val categoryId: Int? = null, val quantity: String = "", - val unit: String = "", + val unit: String = "Stück", + val isCustomUnit: Boolean = false, + val customUnit: String = "", val unitPrice: String = "", val kcalPerUnit: String = "", val expiryDate: LocalDate? = null, @@ -111,10 +114,13 @@ internal class ItemFormViewModel @Inject constructor( private fun applyPrefill(json: String) { try { val prefill = jsonParser.decodeFromString(json) + val isCustom = prefill.unit.isNotBlank() && !UnitOptions.isPredefined(prefill.unit) _uiState.update { state -> state.copy( name = prefill.name, - unit = prefill.unit, + unit = if (isCustom) UnitOptions.CUSTOM_OPTION else prefill.unit.ifBlank { "Stück" }, + isCustomUnit = isCustom, + customUnit = if (isCustom) prefill.unit else "", kcalPerUnit = prefill.kcalPerUnit?.toString() ?: "", notes = prefill.notes ) @@ -133,6 +139,7 @@ internal class ItemFormViewModel @Inject constructor( try { val item = itemRepository.getById(itemId) if (item != null) { + val isCustom = !UnitOptions.isPredefined(item.unit) _uiState.update { it.copy( isEditMode = true, @@ -140,7 +147,9 @@ internal class ItemFormViewModel @Inject constructor( name = item.name, categoryId = item.categoryId, quantity = item.quantity.toBigDecimal().stripTrailingZeros().toPlainString(), - unit = item.unit, + unit = if (isCustom) UnitOptions.CUSTOM_OPTION else item.unit, + isCustomUnit = isCustom, + customUnit = if (isCustom) item.unit else "", unitPrice = item.unitPrice.toBigDecimal().stripTrailingZeros().toPlainString(), kcalPerUnit = item.kcalPerUnit?.toString() ?: "", expiryDate = item.expiryDate, @@ -171,7 +180,17 @@ internal class ItemFormViewModel @Inject constructor( } fun updateUnit(value: String) { - _uiState.update { it.copy(unit = value) } + if (value == UnitOptions.CUSTOM_OPTION) { + _uiState.update { it.copy(unit = value, isCustomUnit = true) } + } else { + _uiState.update { it.copy(unit = value, isCustomUnit = false, customUnit = "") } + } + } + + fun updateCustomUnit(value: String) { + if (value.length <= 16) { + _uiState.update { it.copy(customUnit = value) } + } } fun updateUnitPrice(value: String) { @@ -204,12 +223,14 @@ internal class ItemFormViewModel @Inject constructor( _uiState.update { it.copy(isSaving = true) } + val effectiveUnit = if (state.isCustomUnit) state.customUnit.trim() else state.unit.trim() + val item = ItemEntity( id = if (state.isEditMode) state.itemId else UUID.randomUUID().toString(), name = state.name.trim(), categoryId = state.categoryId!!, quantity = state.quantity.toDouble(), - unit = state.unit.trim(), + unit = effectiveUnit, unitPrice = state.unitPrice.toDoubleOrNull() ?: 0.0, kcalPerUnit = state.kcalPerUnit.toIntOrNull(), expiryDate = state.expiryDate, diff --git a/app/src/test/java/de/bollwerk/app/data/export/TestFakes.kt b/app/src/test/java/de/bollwerk/app/data/export/TestFakes.kt index b605bce..15389e3 100644 --- a/app/src/test/java/de/bollwerk/app/data/export/TestFakes.kt +++ b/app/src/test/java/de/bollwerk/app/data/export/TestFakes.kt @@ -92,6 +92,8 @@ internal class FakeItemDao : ItemDao { emit() } + override suspend fun normalizeUnits() {} + fun getItems(): List = items.toList() } diff --git a/app/src/test/java/de/bollwerk/app/data/repository/ItemRepositoryImplTest.kt b/app/src/test/java/de/bollwerk/app/data/repository/ItemRepositoryImplTest.kt index bc1f969..bfc33fd 100644 --- a/app/src/test/java/de/bollwerk/app/data/repository/ItemRepositoryImplTest.kt +++ b/app/src/test/java/de/bollwerk/app/data/repository/ItemRepositoryImplTest.kt @@ -106,6 +106,11 @@ private class FakeItemDao : ItemDao { items.removeAll { it.id in ids } emit() } + + override suspend fun normalizeUnits() { + items.replaceAll { if (it.unit in listOf("Stk", "Stk.")) it.copy(unit = "Stück") else it } + emit() + } } private class FakePendingSyncOpDao : PendingSyncOpDao { diff --git a/app/src/test/java/de/bollwerk/app/ui/item/ItemFormViewModelTest.kt b/app/src/test/java/de/bollwerk/app/ui/item/ItemFormViewModelTest.kt index 380610f..a9ca8e2 100644 --- a/app/src/test/java/de/bollwerk/app/ui/item/ItemFormViewModelTest.kt +++ b/app/src/test/java/de/bollwerk/app/ui/item/ItemFormViewModelTest.kt @@ -227,7 +227,9 @@ class ItemFormViewModelTest { assertEquals("Konserve", state.name) assertEquals(1, state.categoryId) assertEquals("5", state.quantity) - assertEquals("Stk", state.unit) + assertEquals("Benutzerdefiniert", state.unit) + assertTrue(state.isCustomUnit) + assertEquals("Stk", state.customUnit) assertEquals("2.5", state.unitPrice) assertEquals("120", state.kcalPerUnit) assertEquals(LocalDate.of(2026, 12, 31), state.expiryDate)