feat(item-form): add unit dropdown with predefined list and custom option

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
This commit is contained in:
Jens Reinemann 2026-05-18 21:39:24 +02:00
parent 975976fd06
commit 645578b66e
8 changed files with 132 additions and 14 deletions

View file

@ -57,4 +57,7 @@ internal interface ItemDao {
@Query("SELECT location_id FROM items ORDER BY last_updated DESC LIMIT 1") @Query("SELECT location_id FROM items ORDER BY last_updated DESC LIMIT 1")
suspend fun getLastUsedLocationId(): Int? suspend fun getLastUsedLocationId(): Int?
@Query("UPDATE items SET unit = 'Stück' WHERE unit IN ('Stk', 'Stk.')")
suspend fun normalizeUnits()
} }

View file

@ -33,6 +33,9 @@ internal class ItemRepositoryImpl @Inject constructor(
) : ItemRepository { ) : ItemRepository {
init { init {
scope.launch {
withContext(Dispatchers.IO) { dao.normalizeUnits() }
}
scope.launch { scope.launch {
webSocketClient.events.collect { event -> webSocketClient.events.collect { event ->
if (event is WebSocketEvent.Connected) { if (event is WebSocketEvent.Connected) {

View file

@ -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<String> = listOf(
"Stück",
"g",
"kg",
"ml",
"l",
"Packung",
"Dose",
"Flasche"
)
/// Alle Dropdown-Einträge inkl. Custom-Option am Ende.
val dropdownEntries: List<String> = 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) }
}

View file

@ -40,6 +40,7 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.bollwerk.app.domain.model.UnitOptions
import java.time.LocalDate import java.time.LocalDate
import java.time.YearMonth import java.time.YearMonth
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@ -135,15 +136,25 @@ internal fun ItemFormScreen(
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
// Einheit // Einheit Dropdown
OutlinedTextField( UnitDropdown(
value = uiState.unit, selectedUnit = uiState.unit,
onValueChange = viewModel::updateUnit, onUnitSelected = viewModel::updateUnit
label = { Text("Einheit") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
) )
// 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)) Spacer(modifier = Modifier.height(8.dp))
// Lagerort Dropdown // Lagerort Dropdown
@ -169,7 +180,14 @@ internal fun ItemFormScreen(
OutlinedTextField( OutlinedTextField(
value = uiState.unitPrice, value = uiState.unitPrice,
onValueChange = viewModel::updateUnitPrice, 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), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth() 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
}
)
}
}
}
}

View file

@ -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.CategoryEntity
import de.bollwerk.app.data.db.entity.ItemEntity import de.bollwerk.app.data.db.entity.ItemEntity
import de.bollwerk.app.data.db.entity.LocationEntity 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.CategoryRepository
import de.bollwerk.app.domain.repository.ItemRepository import de.bollwerk.app.domain.repository.ItemRepository
import de.bollwerk.app.domain.repository.LocationRepository import de.bollwerk.app.domain.repository.LocationRepository
@ -27,7 +28,9 @@ internal data class ItemFormUiState(
val name: String = "", val name: String = "",
val categoryId: Int? = null, val categoryId: Int? = null,
val quantity: String = "", val quantity: String = "",
val unit: String = "", val unit: String = "Stück",
val isCustomUnit: Boolean = false,
val customUnit: String = "",
val unitPrice: String = "", val unitPrice: String = "",
val kcalPerUnit: String = "", val kcalPerUnit: String = "",
val expiryDate: LocalDate? = null, val expiryDate: LocalDate? = null,
@ -111,10 +114,13 @@ internal class ItemFormViewModel @Inject constructor(
private fun applyPrefill(json: String) { private fun applyPrefill(json: String) {
try { try {
val prefill = jsonParser.decodeFromString<ItemFormPrefill>(json) val prefill = jsonParser.decodeFromString<ItemFormPrefill>(json)
val isCustom = prefill.unit.isNotBlank() && !UnitOptions.isPredefined(prefill.unit)
_uiState.update { state -> _uiState.update { state ->
state.copy( state.copy(
name = prefill.name, 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() ?: "", kcalPerUnit = prefill.kcalPerUnit?.toString() ?: "",
notes = prefill.notes notes = prefill.notes
) )
@ -133,6 +139,7 @@ internal class ItemFormViewModel @Inject constructor(
try { try {
val item = itemRepository.getById(itemId) val item = itemRepository.getById(itemId)
if (item != null) { if (item != null) {
val isCustom = !UnitOptions.isPredefined(item.unit)
_uiState.update { _uiState.update {
it.copy( it.copy(
isEditMode = true, isEditMode = true,
@ -140,7 +147,9 @@ internal class ItemFormViewModel @Inject constructor(
name = item.name, name = item.name,
categoryId = item.categoryId, categoryId = item.categoryId,
quantity = item.quantity.toBigDecimal().stripTrailingZeros().toPlainString(), 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(), unitPrice = item.unitPrice.toBigDecimal().stripTrailingZeros().toPlainString(),
kcalPerUnit = item.kcalPerUnit?.toString() ?: "", kcalPerUnit = item.kcalPerUnit?.toString() ?: "",
expiryDate = item.expiryDate, expiryDate = item.expiryDate,
@ -171,7 +180,17 @@ internal class ItemFormViewModel @Inject constructor(
} }
fun updateUnit(value: String) { 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) { fun updateUnitPrice(value: String) {
@ -204,12 +223,14 @@ internal class ItemFormViewModel @Inject constructor(
_uiState.update { it.copy(isSaving = true) } _uiState.update { it.copy(isSaving = true) }
val effectiveUnit = if (state.isCustomUnit) state.customUnit.trim() else state.unit.trim()
val item = ItemEntity( val item = ItemEntity(
id = if (state.isEditMode) state.itemId else UUID.randomUUID().toString(), id = if (state.isEditMode) state.itemId else UUID.randomUUID().toString(),
name = state.name.trim(), name = state.name.trim(),
categoryId = state.categoryId!!, categoryId = state.categoryId!!,
quantity = state.quantity.toDouble(), quantity = state.quantity.toDouble(),
unit = state.unit.trim(), unit = effectiveUnit,
unitPrice = state.unitPrice.toDoubleOrNull() ?: 0.0, unitPrice = state.unitPrice.toDoubleOrNull() ?: 0.0,
kcalPerUnit = state.kcalPerUnit.toIntOrNull(), kcalPerUnit = state.kcalPerUnit.toIntOrNull(),
expiryDate = state.expiryDate, expiryDate = state.expiryDate,

View file

@ -92,6 +92,8 @@ internal class FakeItemDao : ItemDao {
emit() emit()
} }
override suspend fun normalizeUnits() {}
fun getItems(): List<ItemEntity> = items.toList() fun getItems(): List<ItemEntity> = items.toList()
} }

View file

@ -106,6 +106,11 @@ private class FakeItemDao : ItemDao {
items.removeAll { it.id in ids } items.removeAll { it.id in ids }
emit() 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 { private class FakePendingSyncOpDao : PendingSyncOpDao {

View file

@ -227,7 +227,9 @@ class ItemFormViewModelTest {
assertEquals("Konserve", state.name) assertEquals("Konserve", state.name)
assertEquals(1, state.categoryId) assertEquals(1, state.categoryId)
assertEquals("5", state.quantity) 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("2.5", state.unitPrice)
assertEquals("120", state.kcalPerUnit) assertEquals("120", state.kcalPerUnit)
assertEquals(LocalDate.of(2026, 12, 31), state.expiryDate) assertEquals(LocalDate.of(2026, 12, 31), state.expiryDate)