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