feat(item): add ItemFormViewModel and ItemFormScreen for create/edit
ItemFormViewModel: - Create-Modus (new article) and Edit-Modus (load existing by ID via SavedStateHandle navigation argument) - Form state with all ItemEntity fields as MutableStateFlow - Validation: name required, quantity > 0, category and location required - Save function (insert for create, update for edit) - Loads categories and locations for dropdown selection ItemFormScreen: - OutlinedTextField for name, quantity, unit, price, kcal/100g, min stock, notes - ExposedDropdownMenuBox for category and location selection - Material 3 DatePickerDialog for expiry date (MHD) - Inline validation error display per field - Save button in TopAppBar, back navigation on successful save - UUID generation for new articles Tests: - 18 unit tests covering create mode, edit mode, field updates, validation (all required fields), and save behavior (insert vs update) Closes #27
This commit is contained in:
parent
a1cd7e5199
commit
f0ad946140
3 changed files with 1093 additions and 0 deletions
371
app/src/main/java/de/krisenvorrat/app/ui/item/ItemFormScreen.kt
Normal file
371
app/src/main/java/de/krisenvorrat/app/ui/item/ItemFormScreen.kt
Normal file
|
|
@ -0,0 +1,371 @@
|
|||
package de.krisenvorrat.app.ui.item
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
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.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.DateRange
|
||||
import androidx.compose.material3.DatePicker
|
||||
import androidx.compose.material3.DatePickerDialog
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MenuAnchorType
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.rememberDatePickerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
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 java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun ItemFormScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
viewModel: ItemFormViewModel = hiltViewModel()
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(uiState.isSaved) {
|
||||
if (uiState.isSaved) {
|
||||
onNavigateBack()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(if (uiState.isEditMode) "Artikel bearbeiten" else "Neuer Artikel") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Zurück"
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(
|
||||
onClick = viewModel::save,
|
||||
enabled = !uiState.isSaving
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = "Speichern"
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.padding(horizontal = 16.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Name
|
||||
OutlinedTextField(
|
||||
value = uiState.name,
|
||||
onValueChange = viewModel::updateName,
|
||||
label = { Text("Name *") },
|
||||
isError = uiState.validationErrors.containsKey("name"),
|
||||
supportingText = uiState.validationErrors["name"]?.let { error ->
|
||||
{ Text(error) }
|
||||
},
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Kategorie Dropdown
|
||||
CategoryDropdown(
|
||||
categories = uiState.categories,
|
||||
selectedCategoryId = uiState.categoryId,
|
||||
onCategorySelected = viewModel::updateCategoryId,
|
||||
isError = uiState.validationErrors.containsKey("categoryId"),
|
||||
errorText = uiState.validationErrors["categoryId"]
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Menge
|
||||
OutlinedTextField(
|
||||
value = uiState.quantity,
|
||||
onValueChange = viewModel::updateQuantity,
|
||||
label = { Text("Menge *") },
|
||||
isError = uiState.validationErrors.containsKey("quantity"),
|
||||
supportingText = uiState.validationErrors["quantity"]?.let { error ->
|
||||
{ Text(error) }
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Einheit
|
||||
OutlinedTextField(
|
||||
value = uiState.unit,
|
||||
onValueChange = viewModel::updateUnit,
|
||||
label = { Text("Einheit") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Lagerort Dropdown
|
||||
LocationDropdown(
|
||||
locations = uiState.locations,
|
||||
selectedLocationId = uiState.locationId,
|
||||
onLocationSelected = viewModel::updateLocationId,
|
||||
isError = uiState.validationErrors.containsKey("locationId"),
|
||||
errorText = uiState.validationErrors["locationId"]
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Ablaufdatum
|
||||
ExpiryDateField(
|
||||
expiryDate = uiState.expiryDate,
|
||||
onDateSelected = viewModel::updateExpiryDate
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Preis
|
||||
OutlinedTextField(
|
||||
value = uiState.unitPrice,
|
||||
onValueChange = viewModel::updateUnitPrice,
|
||||
label = { Text("Preis (€)") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// kcal/100g
|
||||
OutlinedTextField(
|
||||
value = uiState.kcalPer100g,
|
||||
onValueChange = viewModel::updateKcalPer100g,
|
||||
label = { Text("kcal / 100g") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Mindestbestand
|
||||
OutlinedTextField(
|
||||
value = uiState.minStock,
|
||||
onValueChange = viewModel::updateMinStock,
|
||||
label = { Text("Mindestbestand") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Notizen
|
||||
OutlinedTextField(
|
||||
value = uiState.notes,
|
||||
onValueChange = viewModel::updateNotes,
|
||||
label = { Text("Notizen") },
|
||||
minLines = 3,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun CategoryDropdown(
|
||||
categories: List<de.krisenvorrat.app.data.db.entity.CategoryEntity>,
|
||||
selectedCategoryId: Int?,
|
||||
onCategorySelected: (Int) -> Unit,
|
||||
isError: Boolean,
|
||||
errorText: String?
|
||||
) {
|
||||
var isExpanded by remember { mutableStateOf(false) }
|
||||
val selectedName = categories.find { it.id == selectedCategoryId }?.name ?: ""
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = isExpanded,
|
||||
onExpandedChange = { isExpanded = it }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = selectedName,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Kategorie *") },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = isExpanded) },
|
||||
isError = isError,
|
||||
supportingText = errorText?.let { error -> { Text(error) } },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = isExpanded,
|
||||
onDismissRequest = { isExpanded = false }
|
||||
) {
|
||||
categories.forEach { category ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(category.name) },
|
||||
onClick = {
|
||||
onCategorySelected(category.id)
|
||||
isExpanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun LocationDropdown(
|
||||
locations: List<de.krisenvorrat.app.data.db.entity.LocationEntity>,
|
||||
selectedLocationId: Int?,
|
||||
onLocationSelected: (Int) -> Unit,
|
||||
isError: Boolean,
|
||||
errorText: String?
|
||||
) {
|
||||
var isExpanded by remember { mutableStateOf(false) }
|
||||
val selectedName = locations.find { it.id == selectedLocationId }?.name ?: ""
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = isExpanded,
|
||||
onExpandedChange = { isExpanded = it }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = selectedName,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Lagerort *") },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = isExpanded) },
|
||||
isError = isError,
|
||||
supportingText = errorText?.let { error -> { Text(error) } },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = isExpanded,
|
||||
onDismissRequest = { isExpanded = false }
|
||||
) {
|
||||
locations.forEach { location ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(location.name) },
|
||||
onClick = {
|
||||
onLocationSelected(location.id)
|
||||
isExpanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ExpiryDateField(
|
||||
expiryDate: LocalDate?,
|
||||
onDateSelected: (LocalDate?) -> Unit
|
||||
) {
|
||||
var isDatePickerVisible by remember { mutableStateOf(false) }
|
||||
val formatter = DateTimeFormatter.ofPattern("dd.MM.yyyy")
|
||||
val displayValue = expiryDate?.format(formatter) ?: ""
|
||||
|
||||
OutlinedTextField(
|
||||
value = displayValue,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Ablaufdatum (MHD)") },
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { isDatePickerVisible = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.DateRange,
|
||||
contentDescription = "Datum wählen"
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
if (isDatePickerVisible) {
|
||||
val initialMillis = expiryDate
|
||||
?.atStartOfDay(ZoneId.of("UTC"))
|
||||
?.toInstant()
|
||||
?.toEpochMilli()
|
||||
val datePickerState = rememberDatePickerState(initialSelectedDateMillis = initialMillis)
|
||||
|
||||
DatePickerDialog(
|
||||
onDismissRequest = { isDatePickerVisible = false },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
val selectedMillis = datePickerState.selectedDateMillis
|
||||
if (selectedMillis != null) {
|
||||
val selectedDate = Instant.ofEpochMilli(selectedMillis)
|
||||
.atZone(ZoneId.of("UTC"))
|
||||
.toLocalDate()
|
||||
onDateSelected(selectedDate)
|
||||
}
|
||||
isDatePickerVisible = false
|
||||
}
|
||||
) {
|
||||
Text("OK")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = {
|
||||
onDateSelected(null)
|
||||
isDatePickerVisible = false
|
||||
}) {
|
||||
Text("Entfernen")
|
||||
}
|
||||
}
|
||||
) {
|
||||
DatePicker(state = datePickerState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,209 @@
|
|||
package de.krisenvorrat.app.ui.item
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.krisenvorrat.app.data.db.entity.CategoryEntity
|
||||
import de.krisenvorrat.app.data.db.entity.ItemEntity
|
||||
import de.krisenvorrat.app.data.db.entity.LocationEntity
|
||||
import de.krisenvorrat.app.domain.repository.CategoryRepository
|
||||
import de.krisenvorrat.app.domain.repository.ItemRepository
|
||||
import de.krisenvorrat.app.domain.repository.LocationRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.LocalDate
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
internal data class ItemFormUiState(
|
||||
val isEditMode: Boolean = false,
|
||||
val itemId: String = "",
|
||||
val name: String = "",
|
||||
val categoryId: Int? = null,
|
||||
val quantity: String = "",
|
||||
val unit: String = "",
|
||||
val unitPrice: String = "",
|
||||
val kcalPer100g: String = "",
|
||||
val expiryDate: LocalDate? = null,
|
||||
val locationId: Int? = null,
|
||||
val minStock: String = "",
|
||||
val notes: String = "",
|
||||
val categories: List<CategoryEntity> = emptyList(),
|
||||
val locations: List<LocationEntity> = emptyList(),
|
||||
val validationErrors: Map<String, String> = emptyMap(),
|
||||
val isSaving: Boolean = false,
|
||||
val isSaved: Boolean = false,
|
||||
val isLoading: Boolean = false
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
internal class ItemFormViewModel @Inject constructor(
|
||||
private val itemRepository: ItemRepository,
|
||||
private val categoryRepository: CategoryRepository,
|
||||
private val locationRepository: LocationRepository,
|
||||
savedStateHandle: SavedStateHandle
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(ItemFormUiState())
|
||||
val uiState: StateFlow<ItemFormUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val editItemId: String? = savedStateHandle.get<String>("itemId")
|
||||
|
||||
init {
|
||||
loadDropdownData()
|
||||
if (editItemId != null) {
|
||||
loadItem(editItemId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadDropdownData() {
|
||||
viewModelScope.launch {
|
||||
categoryRepository.getAll().collect { categories ->
|
||||
_uiState.update { it.copy(categories = categories) }
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
locationRepository.getAll().collect { locations ->
|
||||
_uiState.update { it.copy(locations = locations) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadItem(itemId: String) {
|
||||
_uiState.update { it.copy(isLoading = true) }
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val item = itemRepository.getById(itemId)
|
||||
if (item != null) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isEditMode = true,
|
||||
itemId = item.id,
|
||||
name = item.name,
|
||||
categoryId = item.categoryId,
|
||||
quantity = item.quantity.toBigDecimal().stripTrailingZeros().toPlainString(),
|
||||
unit = item.unit,
|
||||
unitPrice = item.unitPrice.toBigDecimal().stripTrailingZeros().toPlainString(),
|
||||
kcalPer100g = item.kcalPer100g?.toString() ?: "",
|
||||
expiryDate = item.expiryDate,
|
||||
locationId = item.locationId,
|
||||
minStock = item.minStock.toBigDecimal().stripTrailingZeros().toPlainString(),
|
||||
notes = item.notes,
|
||||
isLoading = false
|
||||
)
|
||||
}
|
||||
} else {
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateName(value: String) {
|
||||
_uiState.update { it.copy(name = value, validationErrors = it.validationErrors - "name") }
|
||||
}
|
||||
|
||||
fun updateCategoryId(value: Int) {
|
||||
_uiState.update { it.copy(categoryId = value, validationErrors = it.validationErrors - "categoryId") }
|
||||
}
|
||||
|
||||
fun updateQuantity(value: String) {
|
||||
_uiState.update { it.copy(quantity = value, validationErrors = it.validationErrors - "quantity") }
|
||||
}
|
||||
|
||||
fun updateUnit(value: String) {
|
||||
_uiState.update { it.copy(unit = value) }
|
||||
}
|
||||
|
||||
fun updateUnitPrice(value: String) {
|
||||
_uiState.update { it.copy(unitPrice = value) }
|
||||
}
|
||||
|
||||
fun updateKcalPer100g(value: String) {
|
||||
_uiState.update { it.copy(kcalPer100g = value) }
|
||||
}
|
||||
|
||||
fun updateExpiryDate(value: LocalDate?) {
|
||||
_uiState.update { it.copy(expiryDate = value) }
|
||||
}
|
||||
|
||||
fun updateLocationId(value: Int) {
|
||||
_uiState.update { it.copy(locationId = value, validationErrors = it.validationErrors - "locationId") }
|
||||
}
|
||||
|
||||
fun updateMinStock(value: String) {
|
||||
_uiState.update { it.copy(minStock = value) }
|
||||
}
|
||||
|
||||
fun updateNotes(value: String) {
|
||||
_uiState.update { it.copy(notes = value) }
|
||||
}
|
||||
|
||||
fun save() {
|
||||
val state = _uiState.value
|
||||
val errors = validate(state)
|
||||
if (errors.isNotEmpty()) {
|
||||
_uiState.update { it.copy(validationErrors = errors) }
|
||||
return
|
||||
}
|
||||
|
||||
_uiState.update { it.copy(isSaving = true) }
|
||||
|
||||
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(),
|
||||
unitPrice = state.unitPrice.toDoubleOrNull() ?: 0.0,
|
||||
kcalPer100g = state.kcalPer100g.toIntOrNull(),
|
||||
expiryDate = state.expiryDate,
|
||||
locationId = state.locationId!!,
|
||||
minStock = state.minStock.toDoubleOrNull() ?: 0.0,
|
||||
notes = state.notes.trim(),
|
||||
lastUpdated = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
if (state.isEditMode) {
|
||||
itemRepository.update(item)
|
||||
} else {
|
||||
itemRepository.insert(item)
|
||||
}
|
||||
_uiState.update { it.copy(isSaving = false, isSaved = true) }
|
||||
} catch (_: Exception) {
|
||||
_uiState.update { it.copy(isSaving = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun validate(state: ItemFormUiState): Map<String, String> {
|
||||
val errors = mutableMapOf<String, String>()
|
||||
|
||||
if (state.name.isBlank()) {
|
||||
errors["name"] = "Name ist erforderlich"
|
||||
}
|
||||
|
||||
val quantity = state.quantity.toDoubleOrNull()
|
||||
if (quantity == null || quantity <= 0) {
|
||||
errors["quantity"] = "Menge muss größer als 0 sein"
|
||||
}
|
||||
|
||||
if (state.categoryId == null) {
|
||||
errors["categoryId"] = "Kategorie ist erforderlich"
|
||||
}
|
||||
|
||||
if (state.locationId == null) {
|
||||
errors["locationId"] = "Lagerort ist erforderlich"
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,513 @@
|
|||
package de.krisenvorrat.app.ui.item
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import de.krisenvorrat.app.data.db.entity.CategoryEntity
|
||||
import de.krisenvorrat.app.data.db.entity.ItemEntity
|
||||
import de.krisenvorrat.app.data.db.entity.LocationEntity
|
||||
import de.krisenvorrat.app.domain.repository.CategoryRepository
|
||||
import de.krisenvorrat.app.domain.repository.ItemRepository
|
||||
import de.krisenvorrat.app.domain.repository.LocationRepository
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.time.LocalDate
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class ItemFormViewModelTest {
|
||||
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
private lateinit var fakeItemRepository: FakeItemFormRepository
|
||||
private lateinit var fakeCategoryRepository: FakeFormCategoryRepository
|
||||
private lateinit var fakeLocationRepository: FakeFormLocationRepository
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
fakeItemRepository = FakeItemFormRepository()
|
||||
fakeCategoryRepository = FakeFormCategoryRepository()
|
||||
fakeLocationRepository = FakeFormLocationRepository()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
private fun createViewModel(itemId: String? = null): ItemFormViewModel {
|
||||
val savedStateHandle = SavedStateHandle().apply {
|
||||
if (itemId != null) set("itemId", itemId)
|
||||
}
|
||||
return ItemFormViewModel(
|
||||
itemRepository = fakeItemRepository,
|
||||
categoryRepository = fakeCategoryRepository,
|
||||
locationRepository = fakeLocationRepository,
|
||||
savedStateHandle = savedStateHandle
|
||||
)
|
||||
}
|
||||
|
||||
// --- Create Mode Tests ---
|
||||
|
||||
@Test
|
||||
fun test_init_createMode_stateIsEmpty() = runTest(testDispatcher) {
|
||||
// Given / When
|
||||
val viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Then
|
||||
val state = viewModel.uiState.value
|
||||
assertFalse(state.isEditMode)
|
||||
assertEquals("", state.name)
|
||||
assertEquals(null, state.categoryId)
|
||||
assertEquals("", state.quantity)
|
||||
assertEquals(null, state.locationId)
|
||||
assertFalse(state.isSaved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_init_createMode_loadsCategories() = runTest(testDispatcher) {
|
||||
// Given
|
||||
fakeCategoryRepository.emit(
|
||||
listOf(
|
||||
CategoryEntity(id = 1, name = "Lebensmittel"),
|
||||
CategoryEntity(id = 2, name = "Hygiene")
|
||||
)
|
||||
)
|
||||
|
||||
// When
|
||||
val viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Then
|
||||
assertEquals(2, viewModel.uiState.value.categories.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_init_createMode_loadsLocations() = runTest(testDispatcher) {
|
||||
// Given
|
||||
fakeLocationRepository.emit(
|
||||
listOf(
|
||||
LocationEntity(id = 1, name = "Keller"),
|
||||
LocationEntity(id = 2, name = "Küche")
|
||||
)
|
||||
)
|
||||
|
||||
// When
|
||||
val viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Then
|
||||
assertEquals(2, viewModel.uiState.value.locations.size)
|
||||
}
|
||||
|
||||
// --- Edit Mode Tests ---
|
||||
|
||||
@Test
|
||||
fun test_init_editMode_loadsExistingItem() = runTest(testDispatcher) {
|
||||
// Given
|
||||
val item = ItemEntity(
|
||||
id = "edit-1",
|
||||
name = "Konserve",
|
||||
categoryId = 1,
|
||||
quantity = 5.0,
|
||||
unit = "Stk",
|
||||
unitPrice = 2.5,
|
||||
kcalPer100g = 120,
|
||||
expiryDate = LocalDate.of(2026, 12, 31),
|
||||
locationId = 2,
|
||||
minStock = 2.0,
|
||||
notes = "Bohnen",
|
||||
lastUpdated = 1000L
|
||||
)
|
||||
fakeItemRepository.addItem(item)
|
||||
|
||||
// When
|
||||
val viewModel = createViewModel(itemId = "edit-1")
|
||||
advanceUntilIdle()
|
||||
|
||||
// Then
|
||||
val state = viewModel.uiState.value
|
||||
assertTrue(state.isEditMode)
|
||||
assertEquals("edit-1", state.itemId)
|
||||
assertEquals("Konserve", state.name)
|
||||
assertEquals(1, state.categoryId)
|
||||
assertEquals("5", state.quantity)
|
||||
assertEquals("Stk", state.unit)
|
||||
assertEquals("2.5", state.unitPrice)
|
||||
assertEquals("120", state.kcalPer100g)
|
||||
assertEquals(LocalDate.of(2026, 12, 31), state.expiryDate)
|
||||
assertEquals(2, state.locationId)
|
||||
assertEquals("2", state.minStock)
|
||||
assertEquals("Bohnen", state.notes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_init_editMode_withUnknownId_staysInCreateMode() = runTest(testDispatcher) {
|
||||
// Given – no item with this ID exists
|
||||
|
||||
// When
|
||||
val viewModel = createViewModel(itemId = "nonexistent")
|
||||
advanceUntilIdle()
|
||||
|
||||
// Then
|
||||
assertFalse(viewModel.uiState.value.isEditMode)
|
||||
}
|
||||
|
||||
// --- Field Update Tests ---
|
||||
|
||||
@Test
|
||||
fun test_updateName_updatesState() = runTest(testDispatcher) {
|
||||
// Given
|
||||
val viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// When
|
||||
viewModel.updateName("Reis")
|
||||
|
||||
// Then
|
||||
assertEquals("Reis", viewModel.uiState.value.name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_updateCategoryId_updatesState() = runTest(testDispatcher) {
|
||||
// Given
|
||||
val viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// When
|
||||
viewModel.updateCategoryId(3)
|
||||
|
||||
// Then
|
||||
assertEquals(3, viewModel.uiState.value.categoryId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_updateQuantity_updatesState() = runTest(testDispatcher) {
|
||||
// Given
|
||||
val viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// When
|
||||
viewModel.updateQuantity("10.5")
|
||||
|
||||
// Then
|
||||
assertEquals("10.5", viewModel.uiState.value.quantity)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_updateExpiryDate_updatesState() = runTest(testDispatcher) {
|
||||
// Given
|
||||
val viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
val date = LocalDate.of(2027, 6, 15)
|
||||
|
||||
// When
|
||||
viewModel.updateExpiryDate(date)
|
||||
|
||||
// Then
|
||||
assertEquals(date, viewModel.uiState.value.expiryDate)
|
||||
}
|
||||
|
||||
// --- Validation Tests ---
|
||||
|
||||
@Test
|
||||
fun test_save_withEmptyName_showsValidationError() = runTest(testDispatcher) {
|
||||
// Given
|
||||
val viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
viewModel.updateQuantity("5")
|
||||
viewModel.updateCategoryId(1)
|
||||
viewModel.updateLocationId(1)
|
||||
|
||||
// When
|
||||
viewModel.save()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Then
|
||||
assertTrue(viewModel.uiState.value.validationErrors.containsKey("name"))
|
||||
assertFalse(viewModel.uiState.value.isSaved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_save_withZeroQuantity_showsValidationError() = runTest(testDispatcher) {
|
||||
// Given
|
||||
val viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
viewModel.updateName("Reis")
|
||||
viewModel.updateQuantity("0")
|
||||
viewModel.updateCategoryId(1)
|
||||
viewModel.updateLocationId(1)
|
||||
|
||||
// When
|
||||
viewModel.save()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Then
|
||||
assertTrue(viewModel.uiState.value.validationErrors.containsKey("quantity"))
|
||||
assertFalse(viewModel.uiState.value.isSaved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_save_withInvalidQuantity_showsValidationError() = runTest(testDispatcher) {
|
||||
// Given
|
||||
val viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
viewModel.updateName("Reis")
|
||||
viewModel.updateQuantity("abc")
|
||||
viewModel.updateCategoryId(1)
|
||||
viewModel.updateLocationId(1)
|
||||
|
||||
// When
|
||||
viewModel.save()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Then
|
||||
assertTrue(viewModel.uiState.value.validationErrors.containsKey("quantity"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_save_withoutCategory_showsValidationError() = runTest(testDispatcher) {
|
||||
// Given
|
||||
val viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
viewModel.updateName("Reis")
|
||||
viewModel.updateQuantity("5")
|
||||
viewModel.updateLocationId(1)
|
||||
|
||||
// When
|
||||
viewModel.save()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Then
|
||||
assertTrue(viewModel.uiState.value.validationErrors.containsKey("categoryId"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_save_withoutLocation_showsValidationError() = runTest(testDispatcher) {
|
||||
// Given
|
||||
val viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
viewModel.updateName("Reis")
|
||||
viewModel.updateQuantity("5")
|
||||
viewModel.updateCategoryId(1)
|
||||
|
||||
// When
|
||||
viewModel.save()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Then
|
||||
assertTrue(viewModel.uiState.value.validationErrors.containsKey("locationId"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_save_withMultipleMissingFields_showsAllErrors() = runTest(testDispatcher) {
|
||||
// Given
|
||||
val viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// When – all required fields are empty
|
||||
viewModel.save()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Then
|
||||
val errors = viewModel.uiState.value.validationErrors
|
||||
assertTrue(errors.containsKey("name"))
|
||||
assertTrue(errors.containsKey("quantity"))
|
||||
assertTrue(errors.containsKey("categoryId"))
|
||||
assertTrue(errors.containsKey("locationId"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_updateName_clearsNameValidationError() = runTest(testDispatcher) {
|
||||
// Given
|
||||
val viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
viewModel.save() // triggers validation errors
|
||||
|
||||
// When
|
||||
viewModel.updateName("Reis")
|
||||
|
||||
// Then
|
||||
assertFalse(viewModel.uiState.value.validationErrors.containsKey("name"))
|
||||
}
|
||||
|
||||
// --- Save Tests ---
|
||||
|
||||
@Test
|
||||
fun test_save_createMode_withValidData_insertsItem() = runTest(testDispatcher) {
|
||||
// Given
|
||||
val viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
viewModel.updateName("Reis")
|
||||
viewModel.updateQuantity("5")
|
||||
viewModel.updateCategoryId(1)
|
||||
viewModel.updateLocationId(2)
|
||||
viewModel.updateUnit("kg")
|
||||
|
||||
// When
|
||||
viewModel.save()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Then
|
||||
assertTrue(viewModel.uiState.value.isSaved)
|
||||
assertEquals(1, fakeItemRepository.insertedItems.size)
|
||||
val inserted = fakeItemRepository.insertedItems.first()
|
||||
assertEquals("Reis", inserted.name)
|
||||
assertEquals(5.0, inserted.quantity, 0.001)
|
||||
assertEquals(1, inserted.categoryId)
|
||||
assertEquals(2, inserted.locationId)
|
||||
assertEquals("kg", inserted.unit)
|
||||
assertNotNull(inserted.id) // UUID was generated
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_save_editMode_withValidData_updatesItem() = runTest(testDispatcher) {
|
||||
// Given
|
||||
val item = ItemEntity(
|
||||
id = "upd-1",
|
||||
name = "Alt",
|
||||
categoryId = 1,
|
||||
quantity = 2.0,
|
||||
unit = "Stk",
|
||||
unitPrice = 0.0,
|
||||
kcalPer100g = null,
|
||||
expiryDate = null,
|
||||
locationId = 1,
|
||||
minStock = 0.0,
|
||||
notes = "",
|
||||
lastUpdated = 0L
|
||||
)
|
||||
fakeItemRepository.addItem(item)
|
||||
val viewModel = createViewModel(itemId = "upd-1")
|
||||
advanceUntilIdle()
|
||||
|
||||
// When
|
||||
viewModel.updateName("Neu")
|
||||
viewModel.updateQuantity("10")
|
||||
viewModel.save()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Then
|
||||
assertTrue(viewModel.uiState.value.isSaved)
|
||||
assertEquals(1, fakeItemRepository.updatedItems.size)
|
||||
val updated = fakeItemRepository.updatedItems.first()
|
||||
assertEquals("upd-1", updated.id)
|
||||
assertEquals("Neu", updated.name)
|
||||
assertEquals(10.0, updated.quantity, 0.001)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_save_createMode_doesNotUseEditModeId() = runTest(testDispatcher) {
|
||||
// Given
|
||||
val viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
viewModel.updateName("Test")
|
||||
viewModel.updateQuantity("1")
|
||||
viewModel.updateCategoryId(1)
|
||||
viewModel.updateLocationId(1)
|
||||
|
||||
// When
|
||||
viewModel.save()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Then
|
||||
val inserted = fakeItemRepository.insertedItems.first()
|
||||
assertTrue(inserted.id.isNotBlank())
|
||||
assertTrue(fakeItemRepository.updatedItems.isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
// region Test Fakes
|
||||
|
||||
private class FakeItemFormRepository : ItemRepository {
|
||||
private val items = mutableListOf<ItemEntity>()
|
||||
val insertedItems = mutableListOf<ItemEntity>()
|
||||
val updatedItems = mutableListOf<ItemEntity>()
|
||||
|
||||
fun addItem(item: ItemEntity) {
|
||||
items.add(item)
|
||||
}
|
||||
|
||||
override fun getAll(): Flow<List<ItemEntity>> = MutableStateFlow(items.toList())
|
||||
|
||||
override suspend fun getById(id: String): ItemEntity? = items.find { it.id == id }
|
||||
|
||||
override suspend fun insert(item: ItemEntity) {
|
||||
insertedItems.add(item)
|
||||
}
|
||||
|
||||
override suspend fun update(item: ItemEntity) {
|
||||
updatedItems.add(item)
|
||||
}
|
||||
|
||||
override suspend fun delete(item: ItemEntity) {
|
||||
items.remove(item)
|
||||
}
|
||||
|
||||
override fun getByCategory(categoryId: Int): Flow<List<ItemEntity>> =
|
||||
MutableStateFlow(items.filter { it.categoryId == categoryId })
|
||||
|
||||
override fun getByLocation(locationId: Int): Flow<List<ItemEntity>> =
|
||||
MutableStateFlow(items.filter { it.locationId == locationId })
|
||||
|
||||
override fun getExpiringSoon(daysUntil: Int): Flow<List<ItemEntity>> =
|
||||
MutableStateFlow(emptyList())
|
||||
}
|
||||
|
||||
private class FakeFormCategoryRepository : CategoryRepository {
|
||||
private val flow = MutableStateFlow<List<CategoryEntity>>(emptyList())
|
||||
|
||||
fun emit(categories: List<CategoryEntity>) {
|
||||
flow.value = categories
|
||||
}
|
||||
|
||||
override fun getAll(): Flow<List<CategoryEntity>> = flow
|
||||
|
||||
override suspend fun insert(category: CategoryEntity) {
|
||||
flow.value = flow.value + category
|
||||
}
|
||||
|
||||
override suspend fun update(category: CategoryEntity) {
|
||||
flow.value = flow.value.map { if (it.id == category.id) category else it }
|
||||
}
|
||||
|
||||
override suspend fun delete(category: CategoryEntity) {
|
||||
flow.value = flow.value.filter { it.id != category.id }
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeFormLocationRepository : LocationRepository {
|
||||
private val flow = MutableStateFlow<List<LocationEntity>>(emptyList())
|
||||
|
||||
fun emit(locations: List<LocationEntity>) {
|
||||
flow.value = locations
|
||||
}
|
||||
|
||||
override fun getAll(): Flow<List<LocationEntity>> = flow
|
||||
|
||||
override suspend fun insert(location: LocationEntity) {
|
||||
flow.value = flow.value + location
|
||||
}
|
||||
|
||||
override suspend fun update(location: LocationEntity) {
|
||||
flow.value = flow.value.map { if (it.id == location.id) location else it }
|
||||
}
|
||||
|
||||
override suspend fun delete(location: LocationEntity) {
|
||||
flow.value = flow.value.filter { it.id != location.id }
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
Loading…
Reference in a new issue