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")
|
@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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
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.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
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue