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:
parent
975976fd06
commit
645578b66e
8 changed files with 132 additions and 14 deletions
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
25
app/src/main/java/de/bollwerk/app/domain/model/UnitOption.kt
Normal file
25
app/src/main/java/de/bollwerk/app/domain/model/UnitOption.kt
Normal 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) }
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ItemFormPrefill>(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,
|
||||
|
|
|
|||
|
|
@ -92,6 +92,8 @@ internal class FakeItemDao : ItemDao {
|
|||
emit()
|
||||
}
|
||||
|
||||
override suspend fun normalizeUnits() {}
|
||||
|
||||
fun getItems(): List<ItemEntity> = items.toList()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue