From f0ad9461401262535da974fb7800d73ed7f4bf08 Mon Sep 17 00:00:00 2001 From: Jens Reinemann Date: Thu, 14 May 2026 01:11:36 +0200 Subject: [PATCH] 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 --- .../app/ui/item/ItemFormScreen.kt | 371 +++++++++++++ .../app/ui/item/ItemFormViewModel.kt | 209 +++++++ .../app/ui/item/ItemFormViewModelTest.kt | 513 ++++++++++++++++++ 3 files changed, 1093 insertions(+) create mode 100644 app/src/main/java/de/krisenvorrat/app/ui/item/ItemFormScreen.kt create mode 100644 app/src/main/java/de/krisenvorrat/app/ui/item/ItemFormViewModel.kt create mode 100644 app/src/test/java/de/krisenvorrat/app/ui/item/ItemFormViewModelTest.kt diff --git a/app/src/main/java/de/krisenvorrat/app/ui/item/ItemFormScreen.kt b/app/src/main/java/de/krisenvorrat/app/ui/item/ItemFormScreen.kt new file mode 100644 index 0000000..9489ac9 --- /dev/null +++ b/app/src/main/java/de/krisenvorrat/app/ui/item/ItemFormScreen.kt @@ -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, + 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, + 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) + } + } +} diff --git a/app/src/main/java/de/krisenvorrat/app/ui/item/ItemFormViewModel.kt b/app/src/main/java/de/krisenvorrat/app/ui/item/ItemFormViewModel.kt new file mode 100644 index 0000000..e58b2d3 --- /dev/null +++ b/app/src/main/java/de/krisenvorrat/app/ui/item/ItemFormViewModel.kt @@ -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 = emptyList(), + val locations: List = emptyList(), + val validationErrors: Map = 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 = _uiState.asStateFlow() + + private val editItemId: String? = savedStateHandle.get("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 { + val errors = mutableMapOf() + + 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 + } +} diff --git a/app/src/test/java/de/krisenvorrat/app/ui/item/ItemFormViewModelTest.kt b/app/src/test/java/de/krisenvorrat/app/ui/item/ItemFormViewModelTest.kt new file mode 100644 index 0000000..4002a6f --- /dev/null +++ b/app/src/test/java/de/krisenvorrat/app/ui/item/ItemFormViewModelTest.kt @@ -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() + val insertedItems = mutableListOf() + val updatedItems = mutableListOf() + + fun addItem(item: ItemEntity) { + items.add(item) + } + + override fun getAll(): Flow> = 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> = + MutableStateFlow(items.filter { it.categoryId == categoryId }) + + override fun getByLocation(locationId: Int): Flow> = + MutableStateFlow(items.filter { it.locationId == locationId }) + + override fun getExpiringSoon(daysUntil: Int): Flow> = + MutableStateFlow(emptyList()) +} + +private class FakeFormCategoryRepository : CategoryRepository { + private val flow = MutableStateFlow>(emptyList()) + + fun emit(categories: List) { + flow.value = categories + } + + override fun getAll(): Flow> = 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>(emptyList()) + + fun emit(locations: List) { + flow.value = locations + } + + override fun getAll(): Flow> = 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