From 395939a4ec4dd231463991ec65cf8d56c94ab1bf Mon Sep 17 00:00:00 2001 From: Jens Reinemann Date: Sat, 16 May 2026 14:05:45 +0200 Subject: [PATCH] =?UTF-8?q?feat(categories):=20Umbenennen,=20L=C3=B6schen?= =?UTF-8?q?=20mit=20Umzuweisungs-Dialog,=20letzter=20Lagerort=20vorausw?= =?UTF-8?q?=C3=A4hlen=20(#51)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Kategorien und Lagerorte umbenennen (Edit-Dialog) - Löschen mit Umzuweisungs-Dialog wenn Artikel referenzieren - Letzten verwendeten Lagerort im Artikelformular vorausauswählen - stale lastLocationId-Validierung gegen aktuelle Location-Liste - !! durch ?.let ersetzt in beiden Screens - Irreführenden Delete-Dialog-Text korrigiert - Tests: 20 neue Unit-Tests (Rename, Reassign, dismissReassign, staleLocation) --- .../krisenvorrat/app/data/db/dao/ItemDao.kt | 15 ++ .../app/data/repository/ItemRepositoryImpl.kt | 15 ++ .../de/krisenvorrat/app/di/DatabaseModule.kt | 24 ++- .../app/domain/repository/ItemRepository.kt | 5 + .../app/ui/category/CategoryListScreen.kt | 170 ++++++++++++++- .../app/ui/category/CategoryListViewModel.kt | 106 +++++++++- .../app/ui/item/ItemFormScreen.kt | 14 +- .../app/ui/item/ItemFormViewModel.kt | 23 +- .../app/ui/location/LocationListScreen.kt | 170 ++++++++++++++- .../app/ui/location/LocationListViewModel.kt | 106 +++++++++- .../krisenvorrat/app/data/export/TestFakes.kt | 6 + .../data/repository/ItemRepositoryImplTest.kt | 19 ++ .../ui/category/CategoryListViewModelTest.kt | 196 +++++++++++++++++- .../ui/dashboard/DashboardViewModelTest.kt | 6 + .../app/ui/item/ItemFormViewModelTest.kt | 87 ++++++++ .../app/ui/item/ItemListViewModelTest.kt | 6 + .../ui/location/LocationListViewModelTest.kt | 196 +++++++++++++++++- .../app/ui/warnings/WarningsViewModelTest.kt | 6 + 18 files changed, 1129 insertions(+), 41 deletions(-) diff --git a/app/src/main/java/de/krisenvorrat/app/data/db/dao/ItemDao.kt b/app/src/main/java/de/krisenvorrat/app/data/db/dao/ItemDao.kt index 108abd0..9fd1165 100644 --- a/app/src/main/java/de/krisenvorrat/app/data/db/dao/ItemDao.kt +++ b/app/src/main/java/de/krisenvorrat/app/data/db/dao/ItemDao.kt @@ -39,4 +39,19 @@ internal interface ItemDao { @Upsert suspend fun upsertAll(items: List) + + @Query("SELECT COUNT(*) FROM items WHERE category_id = :categoryId") + suspend fun countByCategoryId(categoryId: Int): Int + + @Query("UPDATE items SET category_id = :toId WHERE category_id = :fromId") + suspend fun updateCategoryId(fromId: Int, toId: Int) + + @Query("SELECT COUNT(*) FROM items WHERE location_id = :locationId") + suspend fun countByLocationId(locationId: Int): Int + + @Query("UPDATE items SET location_id = :toId WHERE location_id = :fromId") + suspend fun updateLocationId(fromId: Int, toId: Int) + + @Query("SELECT location_id FROM items ORDER BY last_updated DESC LIMIT 1") + suspend fun getLastUsedLocationId(): Int? } diff --git a/app/src/main/java/de/krisenvorrat/app/data/repository/ItemRepositoryImpl.kt b/app/src/main/java/de/krisenvorrat/app/data/repository/ItemRepositoryImpl.kt index d810a55..13a0646 100644 --- a/app/src/main/java/de/krisenvorrat/app/data/repository/ItemRepositoryImpl.kt +++ b/app/src/main/java/de/krisenvorrat/app/data/repository/ItemRepositoryImpl.kt @@ -35,4 +35,19 @@ internal class ItemRepositoryImpl @Inject constructor( override fun getExpiringSoon(daysUntil: Int): Flow> = dao.getExpiringSoonByCutoff(LocalDate.now().plusDays(daysUntil.toLong())) + + override suspend fun countByCategoryId(categoryId: Int): Int = + withContext(Dispatchers.IO) { dao.countByCategoryId(categoryId) } + + override suspend fun reassignCategory(fromId: Int, toId: Int) = + withContext(Dispatchers.IO) { dao.updateCategoryId(fromId, toId) } + + override suspend fun countByLocationId(locationId: Int): Int = + withContext(Dispatchers.IO) { dao.countByLocationId(locationId) } + + override suspend fun reassignLocation(fromId: Int, toId: Int) = + withContext(Dispatchers.IO) { dao.updateLocationId(fromId, toId) } + + override suspend fun getLastUsedLocationId(): Int? = + withContext(Dispatchers.IO) { dao.getLastUsedLocationId() } } diff --git a/app/src/main/java/de/krisenvorrat/app/di/DatabaseModule.kt b/app/src/main/java/de/krisenvorrat/app/di/DatabaseModule.kt index e42897d..c614225 100644 --- a/app/src/main/java/de/krisenvorrat/app/di/DatabaseModule.kt +++ b/app/src/main/java/de/krisenvorrat/app/di/DatabaseModule.kt @@ -2,7 +2,9 @@ package de.krisenvorrat.app.di import android.content.Context import androidx.room.Room +import androidx.room.RoomDatabase import androidx.room.withTransaction +import androidx.sqlite.db.SupportSQLiteDatabase import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -23,7 +25,27 @@ internal object DatabaseModule { @Provides @Singleton fun provideDatabase(@ApplicationContext context: Context): KrisenvorratDatabase = - Room.databaseBuilder(context, KrisenvorratDatabase::class.java, "krisenvorrat.db").build() + Room.databaseBuilder(context, KrisenvorratDatabase::class.java, "krisenvorrat.db") + .addCallback(DefaultDataCallback) + .build() + + private object DefaultDataCallback : RoomDatabase.Callback() { + override fun onCreate(db: SupportSQLiteDatabase) { + super.onCreate(db) + db.execSQL("INSERT INTO locations (name) VALUES ('Keller')") + listOf( + "Lebensmittel", + "Wasser", + "Medikamente", + "Ausrüstung", + "Hygiene", + "Energie & Licht", + "Dokumente" + ).forEach { name -> + db.execSQL("INSERT INTO categories (name) VALUES ('$name')") + } + } + } @Provides fun provideItemDao(db: KrisenvorratDatabase): ItemDao = db.itemDao() diff --git a/app/src/main/java/de/krisenvorrat/app/domain/repository/ItemRepository.kt b/app/src/main/java/de/krisenvorrat/app/domain/repository/ItemRepository.kt index 6e25af5..6ebbcf3 100644 --- a/app/src/main/java/de/krisenvorrat/app/domain/repository/ItemRepository.kt +++ b/app/src/main/java/de/krisenvorrat/app/domain/repository/ItemRepository.kt @@ -12,4 +12,9 @@ internal interface ItemRepository { fun getByCategory(categoryId: Int): Flow> fun getByLocation(locationId: Int): Flow> fun getExpiringSoon(daysUntil: Int = 30): Flow> + suspend fun countByCategoryId(categoryId: Int): Int + suspend fun reassignCategory(fromId: Int, toId: Int) + suspend fun countByLocationId(locationId: Int): Int + suspend fun reassignLocation(fromId: Int, toId: Int) + suspend fun getLastUsedLocationId(): Int? } diff --git a/app/src/main/java/de/krisenvorrat/app/ui/category/CategoryListScreen.kt b/app/src/main/java/de/krisenvorrat/app/ui/category/CategoryListScreen.kt index 025bdad..2cfb8a0 100644 --- a/app/src/main/java/de/krisenvorrat/app/ui/category/CategoryListScreen.kt +++ b/app/src/main/java/de/krisenvorrat/app/ui/category/CategoryListScreen.kt @@ -13,13 +13,18 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuAnchorType import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -27,11 +32,15 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.krisenvorrat.app.data.db.entity.CategoryEntity @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -87,7 +96,7 @@ internal fun CategoryListScreen( Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), + .padding(horizontal = 16.dp, vertical = 4.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { @@ -96,6 +105,12 @@ internal fun CategoryListScreen( style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f) ) + IconButton(onClick = { viewModel.showEditDialog(category) }) { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = "Kategorie umbenennen" + ) + } IconButton(onClick = { viewModel.showDeleteDialog(category) }) { Icon( imageVector = Icons.Default.Delete, @@ -119,12 +134,39 @@ internal fun CategoryListScreen( ) } - if (uiState.isDeleteDialogVisible && uiState.categoryToDelete != null) { - DeleteCategoryDialog( - categoryName = uiState.categoryToDelete!!.name, - onConfirm = viewModel::deleteCategory, - onDismiss = viewModel::dismissDeleteDialog - ) + if (uiState.isEditDialogVisible) { + uiState.categoryToEdit?.let { + EditCategoryDialog( + name = uiState.editCategoryName, + onNameChange = viewModel::updateEditCategoryName, + onConfirm = viewModel::saveEditCategory, + onDismiss = viewModel::dismissEditDialog + ) + } + } + + if (uiState.isDeleteDialogVisible) { + uiState.categoryToDelete?.let { toDelete -> + DeleteCategoryDialog( + categoryName = toDelete.name, + onConfirm = viewModel::deleteCategory, + onDismiss = viewModel::dismissDeleteDialog + ) + } + } + + if (uiState.isReassignDialogVisible) { + uiState.categoryToDelete?.let { toDelete -> + ReassignAndDeleteCategoryDialog( + categoryName = toDelete.name, + itemCount = uiState.itemCountForDelete, + remainingCategories = uiState.categories.filter { it.id != toDelete.id }, + selectedTargetId = uiState.reassignTargetCategoryId, + onTargetSelected = viewModel::updateReassignTarget, + onConfirm = viewModel::deleteWithReassignment, + onDismiss = viewModel::dismissReassignDialog + ) + } } } @@ -165,6 +207,43 @@ private fun AddCategoryDialog( ) } +@Composable +private fun EditCategoryDialog( + name: String, + onNameChange: (String) -> Unit, + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Kategorie umbenennen") }, + text = { + Column { + OutlinedTextField( + value = name, + onValueChange = onNameChange, + label = { Text("Name") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + } + }, + confirmButton = { + TextButton( + onClick = onConfirm, + enabled = name.isNotBlank() + ) { + Text("Speichern") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Abbrechen") + } + } + ) +} + @Composable private fun DeleteCategoryDialog( categoryName: String, @@ -176,8 +255,7 @@ private fun DeleteCategoryDialog( title = { Text("Kategorie löschen?") }, text = { Text( - "Möchten Sie die Kategorie \"$categoryName\" wirklich löschen?\n\n" + - "Achtung: Alle Artikel in dieser Kategorie werden ebenfalls gelöscht!" + "Möchten Sie die Kategorie \"$categoryName\" wirklich löschen?" ) }, confirmButton = { @@ -195,3 +273,77 @@ private fun DeleteCategoryDialog( } ) } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ReassignAndDeleteCategoryDialog( + categoryName: String, + itemCount: Int, + remainingCategories: List, + selectedTargetId: Int?, + onTargetSelected: (Int) -> Unit, + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + var expanded by remember { mutableStateOf(false) } + val selectedCategory = remainingCategories.find { it.id == selectedTargetId } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Kategorie löschen?") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + "Die Kategorie \"$categoryName\" wird von $itemCount " + + "${if (itemCount == 1) "Artikel" else "Artikeln"} verwendet. " + + "Zu welcher Kategorie sollen sie verschoben werden?" + ) + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it } + ) { + OutlinedTextField( + value = selectedCategory?.name ?: "Kategorie wählen…", + onValueChange = {}, + readOnly = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier + .menuAnchor(MenuAnchorType.PrimaryNotEditable) + .fillMaxWidth() + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + remainingCategories.forEach { category -> + DropdownMenuItem( + text = { Text(category.name) }, + onClick = { + onTargetSelected(category.id) + expanded = false + } + ) + } + } + } + } + }, + confirmButton = { + TextButton( + onClick = onConfirm, + enabled = selectedTargetId != null + ) { + Text( + "Verschieben & Löschen", + color = MaterialTheme.colorScheme.error + ) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Abbrechen") + } + } + ) +} + diff --git a/app/src/main/java/de/krisenvorrat/app/ui/category/CategoryListViewModel.kt b/app/src/main/java/de/krisenvorrat/app/ui/category/CategoryListViewModel.kt index ad04a36..6d4b04f 100644 --- a/app/src/main/java/de/krisenvorrat/app/ui/category/CategoryListViewModel.kt +++ b/app/src/main/java/de/krisenvorrat/app/ui/category/CategoryListViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import de.krisenvorrat.app.data.db.entity.CategoryEntity import de.krisenvorrat.app.domain.repository.CategoryRepository +import de.krisenvorrat.app.domain.repository.ItemRepository import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -15,14 +16,21 @@ import javax.inject.Inject internal data class CategoryListUiState( val categories: List = emptyList(), val isAddDialogVisible: Boolean = false, + val isEditDialogVisible: Boolean = false, val isDeleteDialogVisible: Boolean = false, + val isReassignDialogVisible: Boolean = false, val categoryToDelete: CategoryEntity? = null, - val newCategoryName: String = "" + val categoryToEdit: CategoryEntity? = null, + val newCategoryName: String = "", + val editCategoryName: String = "", + val itemCountForDelete: Int = 0, + val reassignTargetCategoryId: Int? = null ) @HiltViewModel internal class CategoryListViewModel @Inject constructor( - private val repository: CategoryRepository + private val repository: CategoryRepository, + private val itemRepository: ItemRepository ) : ViewModel() { private val _uiState = MutableStateFlow(CategoryListUiState()) @@ -54,30 +62,110 @@ internal class CategoryListViewModel @Inject constructor( viewModelScope.launch { try { repository.insert(CategoryEntity(name = name)) - } catch (_: Exception) { - // Room-Fehler werden still behandelt - } + } catch (_: Exception) {} } _uiState.update { it.copy(isAddDialogVisible = false, newCategoryName = "") } } + fun showEditDialog(category: CategoryEntity) { + _uiState.update { + it.copy( + isEditDialogVisible = true, + categoryToEdit = category, + editCategoryName = category.name + ) + } + } + + fun dismissEditDialog() { + _uiState.update { it.copy(isEditDialogVisible = false, categoryToEdit = null, editCategoryName = "") } + } + + fun updateEditCategoryName(name: String) { + _uiState.update { it.copy(editCategoryName = name) } + } + + fun saveEditCategory() { + val category = _uiState.value.categoryToEdit ?: return + val name = _uiState.value.editCategoryName.trim() + if (name.isBlank()) return + viewModelScope.launch { + try { + repository.update(category.copy(name = name)) + } catch (_: Exception) {} + } + _uiState.update { it.copy(isEditDialogVisible = false, categoryToEdit = null, editCategoryName = "") } + } + fun showDeleteDialog(category: CategoryEntity) { - _uiState.update { it.copy(isDeleteDialogVisible = true, categoryToDelete = category) } + viewModelScope.launch { + val count = try { + itemRepository.countByCategoryId(category.id) + } catch (_: Exception) { 0 } + + if (count > 0) { + _uiState.update { + it.copy( + isReassignDialogVisible = true, + categoryToDelete = category, + itemCountForDelete = count, + reassignTargetCategoryId = null + ) + } + } else { + _uiState.update { + it.copy(isDeleteDialogVisible = true, categoryToDelete = category) + } + } + } } fun dismissDeleteDialog() { _uiState.update { it.copy(isDeleteDialogVisible = false, categoryToDelete = null) } } + fun dismissReassignDialog() { + _uiState.update { + it.copy( + isReassignDialogVisible = false, + categoryToDelete = null, + itemCountForDelete = 0, + reassignTargetCategoryId = null + ) + } + } + + fun updateReassignTarget(categoryId: Int) { + _uiState.update { it.copy(reassignTargetCategoryId = categoryId) } + } + fun deleteCategory() { val category = _uiState.value.categoryToDelete ?: return viewModelScope.launch { try { repository.delete(category) - } catch (_: Exception) { - // Room-Fehler werden still behandelt - } + } catch (_: Exception) {} } _uiState.update { it.copy(isDeleteDialogVisible = false, categoryToDelete = null) } } + + fun deleteWithReassignment() { + val category = _uiState.value.categoryToDelete ?: return + val targetId = _uiState.value.reassignTargetCategoryId ?: return + viewModelScope.launch { + try { + itemRepository.reassignCategory(fromId = category.id, toId = targetId) + repository.delete(category) + } catch (_: Exception) {} + } + _uiState.update { + it.copy( + isReassignDialogVisible = false, + categoryToDelete = null, + itemCountForDelete = 0, + reassignTargetCategoryId = null + ) + } + } } + 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 index 9489ac9..b63807b 100644 --- a/app/src/main/java/de/krisenvorrat/app/ui/item/ItemFormScreen.kt +++ b/app/src/main/java/de/krisenvorrat/app/ui/item/ItemFormScreen.kt @@ -269,9 +269,21 @@ private fun LocationDropdown( isError: Boolean, errorText: String? ) { - var isExpanded by remember { mutableStateOf(false) } val selectedName = locations.find { it.id == selectedLocationId }?.name ?: "" + if (locations.size <= 1) { + OutlinedTextField( + value = selectedName, + onValueChange = {}, + readOnly = true, + label = { Text("Lagerort") }, + modifier = Modifier.fillMaxWidth() + ) + return + } + + var isExpanded by remember { mutableStateOf(false) } + ExposedDropdownMenuBox( expanded = isExpanded, onExpandedChange = { isExpanded = it } 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 index e58b2d3..4ad4ac7 100644 --- a/app/src/main/java/de/krisenvorrat/app/ui/item/ItemFormViewModel.kt +++ b/app/src/main/java/de/krisenvorrat/app/ui/item/ItemFormViewModel.kt @@ -68,7 +68,28 @@ internal class ItemFormViewModel @Inject constructor( } viewModelScope.launch { locationRepository.getAll().collect { locations -> - _uiState.update { it.copy(locations = locations) } + _uiState.update { state -> + val autoLocationId = if (locations.size == 1 && state.locationId == null) { + locations.first().id + } else { + state.locationId + } + state.copy(locations = locations, locationId = autoLocationId) + } + } + } + if (editItemId == null) { + viewModelScope.launch { + try { + val lastLocationId = itemRepository.getLastUsedLocationId() + if (lastLocationId != null) { + _uiState.update { state -> + val isValid = state.locations.any { it.id == lastLocationId } + if (state.locationId == null && isValid) state.copy(locationId = lastLocationId) + else state + } + } + } catch (_: Exception) {} } } } diff --git a/app/src/main/java/de/krisenvorrat/app/ui/location/LocationListScreen.kt b/app/src/main/java/de/krisenvorrat/app/ui/location/LocationListScreen.kt index 7bf7269..686a7e0 100644 --- a/app/src/main/java/de/krisenvorrat/app/ui/location/LocationListScreen.kt +++ b/app/src/main/java/de/krisenvorrat/app/ui/location/LocationListScreen.kt @@ -13,13 +13,18 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuAnchorType import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -27,11 +32,15 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.krisenvorrat.app.data.db.entity.LocationEntity @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -87,7 +96,7 @@ internal fun LocationListScreen( Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), + .padding(horizontal = 16.dp, vertical = 4.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { @@ -96,6 +105,12 @@ internal fun LocationListScreen( style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f) ) + IconButton(onClick = { viewModel.showEditDialog(location) }) { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = "Lagerort umbenennen" + ) + } IconButton(onClick = { viewModel.showDeleteDialog(location) }) { Icon( imageVector = Icons.Default.Delete, @@ -119,12 +134,39 @@ internal fun LocationListScreen( ) } - if (uiState.isDeleteDialogVisible && uiState.locationToDelete != null) { - DeleteLocationDialog( - locationName = uiState.locationToDelete!!.name, - onConfirm = viewModel::deleteLocation, - onDismiss = viewModel::dismissDeleteDialog - ) + if (uiState.isEditDialogVisible) { + uiState.locationToEdit?.let { + EditLocationDialog( + name = uiState.editLocationName, + onNameChange = viewModel::updateEditLocationName, + onConfirm = viewModel::saveEditLocation, + onDismiss = viewModel::dismissEditDialog + ) + } + } + + if (uiState.isDeleteDialogVisible) { + uiState.locationToDelete?.let { toDelete -> + DeleteLocationDialog( + locationName = toDelete.name, + onConfirm = viewModel::deleteLocation, + onDismiss = viewModel::dismissDeleteDialog + ) + } + } + + if (uiState.isReassignDialogVisible) { + uiState.locationToDelete?.let { toDelete -> + ReassignAndDeleteLocationDialog( + locationName = toDelete.name, + itemCount = uiState.itemCountForDelete, + remainingLocations = uiState.locations.filter { it.id != toDelete.id }, + selectedTargetId = uiState.reassignTargetLocationId, + onTargetSelected = viewModel::updateReassignTarget, + onConfirm = viewModel::deleteWithReassignment, + onDismiss = viewModel::dismissReassignDialog + ) + } } } @@ -165,6 +207,43 @@ private fun AddLocationDialog( ) } +@Composable +private fun EditLocationDialog( + name: String, + onNameChange: (String) -> Unit, + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Lagerort umbenennen") }, + text = { + Column { + OutlinedTextField( + value = name, + onValueChange = onNameChange, + label = { Text("Name") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + } + }, + confirmButton = { + TextButton( + onClick = onConfirm, + enabled = name.isNotBlank() + ) { + Text("Speichern") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Abbrechen") + } + } + ) +} + @Composable private fun DeleteLocationDialog( locationName: String, @@ -176,8 +255,7 @@ private fun DeleteLocationDialog( title = { Text("Lagerort löschen?") }, text = { Text( - "Möchten Sie den Lagerort \"$locationName\" wirklich löschen?\n\n" + - "Achtung: Alle Artikel an diesem Lagerort werden ebenfalls gelöscht!" + "Möchten Sie den Lagerort \"$locationName\" wirklich löschen?" ) }, confirmButton = { @@ -195,3 +273,77 @@ private fun DeleteLocationDialog( } ) } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ReassignAndDeleteLocationDialog( + locationName: String, + itemCount: Int, + remainingLocations: List, + selectedTargetId: Int?, + onTargetSelected: (Int) -> Unit, + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + var expanded by remember { mutableStateOf(false) } + val selectedLocation = remainingLocations.find { it.id == selectedTargetId } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Lagerort löschen?") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + "Der Lagerort \"$locationName\" wird von $itemCount " + + "${if (itemCount == 1) "Artikel" else "Artikeln"} verwendet. " + + "Zu welchem Lagerort sollen sie verschoben werden?" + ) + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it } + ) { + OutlinedTextField( + value = selectedLocation?.name ?: "Lagerort wählen…", + onValueChange = {}, + readOnly = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier + .menuAnchor(MenuAnchorType.PrimaryNotEditable) + .fillMaxWidth() + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + remainingLocations.forEach { location -> + DropdownMenuItem( + text = { Text(location.name) }, + onClick = { + onTargetSelected(location.id) + expanded = false + } + ) + } + } + } + } + }, + confirmButton = { + TextButton( + onClick = onConfirm, + enabled = selectedTargetId != null + ) { + Text( + "Verschieben & Löschen", + color = MaterialTheme.colorScheme.error + ) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Abbrechen") + } + } + ) +} + diff --git a/app/src/main/java/de/krisenvorrat/app/ui/location/LocationListViewModel.kt b/app/src/main/java/de/krisenvorrat/app/ui/location/LocationListViewModel.kt index 1f80d2e..36d820a 100644 --- a/app/src/main/java/de/krisenvorrat/app/ui/location/LocationListViewModel.kt +++ b/app/src/main/java/de/krisenvorrat/app/ui/location/LocationListViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import de.krisenvorrat.app.data.db.entity.LocationEntity +import de.krisenvorrat.app.domain.repository.ItemRepository import de.krisenvorrat.app.domain.repository.LocationRepository import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -15,14 +16,21 @@ import javax.inject.Inject internal data class LocationListUiState( val locations: List = emptyList(), val isAddDialogVisible: Boolean = false, + val isEditDialogVisible: Boolean = false, val isDeleteDialogVisible: Boolean = false, + val isReassignDialogVisible: Boolean = false, val locationToDelete: LocationEntity? = null, - val newLocationName: String = "" + val locationToEdit: LocationEntity? = null, + val newLocationName: String = "", + val editLocationName: String = "", + val itemCountForDelete: Int = 0, + val reassignTargetLocationId: Int? = null ) @HiltViewModel internal class LocationListViewModel @Inject constructor( - private val repository: LocationRepository + private val repository: LocationRepository, + private val itemRepository: ItemRepository ) : ViewModel() { private val _uiState = MutableStateFlow(LocationListUiState()) @@ -54,30 +62,110 @@ internal class LocationListViewModel @Inject constructor( viewModelScope.launch { try { repository.insert(LocationEntity(name = name)) - } catch (_: Exception) { - // Room-Fehler werden still behandelt - } + } catch (_: Exception) {} } _uiState.update { it.copy(isAddDialogVisible = false, newLocationName = "") } } + fun showEditDialog(location: LocationEntity) { + _uiState.update { + it.copy( + isEditDialogVisible = true, + locationToEdit = location, + editLocationName = location.name + ) + } + } + + fun dismissEditDialog() { + _uiState.update { it.copy(isEditDialogVisible = false, locationToEdit = null, editLocationName = "") } + } + + fun updateEditLocationName(name: String) { + _uiState.update { it.copy(editLocationName = name) } + } + + fun saveEditLocation() { + val location = _uiState.value.locationToEdit ?: return + val name = _uiState.value.editLocationName.trim() + if (name.isBlank()) return + viewModelScope.launch { + try { + repository.update(location.copy(name = name)) + } catch (_: Exception) {} + } + _uiState.update { it.copy(isEditDialogVisible = false, locationToEdit = null, editLocationName = "") } + } + fun showDeleteDialog(location: LocationEntity) { - _uiState.update { it.copy(isDeleteDialogVisible = true, locationToDelete = location) } + viewModelScope.launch { + val count = try { + itemRepository.countByLocationId(location.id) + } catch (_: Exception) { 0 } + + if (count > 0) { + _uiState.update { + it.copy( + isReassignDialogVisible = true, + locationToDelete = location, + itemCountForDelete = count, + reassignTargetLocationId = null + ) + } + } else { + _uiState.update { + it.copy(isDeleteDialogVisible = true, locationToDelete = location) + } + } + } } fun dismissDeleteDialog() { _uiState.update { it.copy(isDeleteDialogVisible = false, locationToDelete = null) } } + fun dismissReassignDialog() { + _uiState.update { + it.copy( + isReassignDialogVisible = false, + locationToDelete = null, + itemCountForDelete = 0, + reassignTargetLocationId = null + ) + } + } + + fun updateReassignTarget(locationId: Int) { + _uiState.update { it.copy(reassignTargetLocationId = locationId) } + } + fun deleteLocation() { val location = _uiState.value.locationToDelete ?: return viewModelScope.launch { try { repository.delete(location) - } catch (_: Exception) { - // Room-Fehler werden still behandelt - } + } catch (_: Exception) {} } _uiState.update { it.copy(isDeleteDialogVisible = false, locationToDelete = null) } } + + fun deleteWithReassignment() { + val location = _uiState.value.locationToDelete ?: return + val targetId = _uiState.value.reassignTargetLocationId ?: return + viewModelScope.launch { + try { + itemRepository.reassignLocation(fromId = location.id, toId = targetId) + repository.delete(location) + } catch (_: Exception) {} + } + _uiState.update { + it.copy( + isReassignDialogVisible = false, + locationToDelete = null, + itemCountForDelete = 0, + reassignTargetLocationId = null + ) + } + } } + diff --git a/app/src/test/java/de/krisenvorrat/app/data/export/TestFakes.kt b/app/src/test/java/de/krisenvorrat/app/data/export/TestFakes.kt index d4930dc..d37e921 100644 --- a/app/src/test/java/de/krisenvorrat/app/data/export/TestFakes.kt +++ b/app/src/test/java/de/krisenvorrat/app/data/export/TestFakes.kt @@ -81,6 +81,12 @@ internal class FakeItemDao : ItemDao { emit() } + override suspend fun countByCategoryId(categoryId: Int): Int = throw UnsupportedOperationException() + override suspend fun updateCategoryId(fromId: Int, toId: Int) = throw UnsupportedOperationException() + override suspend fun countByLocationId(locationId: Int): Int = throw UnsupportedOperationException() + override suspend fun updateLocationId(fromId: Int, toId: Int) = throw UnsupportedOperationException() + override suspend fun getLastUsedLocationId(): Int? = throw UnsupportedOperationException() + fun getItems(): List = items.toList() } diff --git a/app/src/test/java/de/krisenvorrat/app/data/repository/ItemRepositoryImplTest.kt b/app/src/test/java/de/krisenvorrat/app/data/repository/ItemRepositoryImplTest.kt index 758427c..89df0c6 100644 --- a/app/src/test/java/de/krisenvorrat/app/data/repository/ItemRepositoryImplTest.kt +++ b/app/src/test/java/de/krisenvorrat/app/data/repository/ItemRepositoryImplTest.kt @@ -60,6 +60,25 @@ private class FakeItemDao : ItemDao { } emit() } + + override suspend fun countByCategoryId(categoryId: Int): Int = + items.count { it.categoryId == categoryId } + + override suspend fun updateCategoryId(fromId: Int, toId: Int) { + items.replaceAll { if (it.categoryId == fromId) it.copy(categoryId = toId) else it } + emit() + } + + override suspend fun countByLocationId(locationId: Int): Int = + items.count { it.locationId == locationId } + + override suspend fun updateLocationId(fromId: Int, toId: Int) { + items.replaceAll { if (it.locationId == fromId) it.copy(locationId = toId) else it } + emit() + } + + override suspend fun getLastUsedLocationId(): Int? = + items.maxByOrNull { it.lastUpdated }?.locationId } private fun buildItem( diff --git a/app/src/test/java/de/krisenvorrat/app/ui/category/CategoryListViewModelTest.kt b/app/src/test/java/de/krisenvorrat/app/ui/category/CategoryListViewModelTest.kt index 16f0607..94d207f 100644 --- a/app/src/test/java/de/krisenvorrat/app/ui/category/CategoryListViewModelTest.kt +++ b/app/src/test/java/de/krisenvorrat/app/ui/category/CategoryListViewModelTest.kt @@ -1,7 +1,9 @@ package de.krisenvorrat.app.ui.category import de.krisenvorrat.app.data.db.entity.CategoryEntity +import de.krisenvorrat.app.data.db.entity.ItemEntity import de.krisenvorrat.app.domain.repository.CategoryRepository +import de.krisenvorrat.app.domain.repository.ItemRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow @@ -25,13 +27,15 @@ class CategoryListViewModelTest { private val testDispatcher = StandardTestDispatcher() private lateinit var fakeRepository: FakeCategoryRepository + private lateinit var fakeItemRepository: FakeItemRepositoryForCategories private lateinit var viewModel: CategoryListViewModel @Before fun setup() { Dispatchers.setMain(testDispatcher) fakeRepository = FakeCategoryRepository() - viewModel = CategoryListViewModel(fakeRepository) + fakeItemRepository = FakeItemRepositoryForCategories() + viewModel = CategoryListViewModel(fakeRepository, fakeItemRepository) } @After @@ -145,6 +149,7 @@ class CategoryListViewModelTest { // When viewModel.showDeleteDialog(category) + advanceUntilIdle() // Then val state = viewModel.uiState.value @@ -197,12 +202,174 @@ class CategoryListViewModelTest { // Then assertTrue(fakeRepository.deletedCategories.isEmpty()) } + + @Test + fun test_showEditDialog_setsEditDialogState() = runTest(testDispatcher) { + // Given + val category = CategoryEntity(id = 1, name = "Lebensmittel") + advanceUntilIdle() + + // When + viewModel.showEditDialog(category) + + // Then + val state = viewModel.uiState.value + assertTrue(state.isEditDialogVisible) + assertEquals(category, state.categoryToEdit) + assertEquals("Lebensmittel", state.editCategoryName) + } + + @Test + fun test_dismissEditDialog_clearsEditDialogState() = runTest(testDispatcher) { + // Given + viewModel.showEditDialog(CategoryEntity(id = 1, name = "Test")) + advanceUntilIdle() + + // When + viewModel.dismissEditDialog() + + // Then + val state = viewModel.uiState.value + assertFalse(state.isEditDialogVisible) + assertNull(state.categoryToEdit) + assertEquals("", state.editCategoryName) + } + + @Test + fun test_saveEditCategory_withValidName_updatesAndClosesDialog() = runTest(testDispatcher) { + // Given + val category = CategoryEntity(id = 1, name = "Alt") + viewModel.showEditDialog(category) + viewModel.updateEditCategoryName("Neu") + advanceUntilIdle() + + // When + viewModel.saveEditCategory() + advanceUntilIdle() + + // Then + assertFalse(viewModel.uiState.value.isEditDialogVisible) + assertNull(viewModel.uiState.value.categoryToEdit) + assertEquals(1, fakeRepository.updatedCategories.size) + assertEquals("Neu", fakeRepository.updatedCategories.first().name) + } + + @Test + fun test_saveEditCategory_withBlankName_doesNotUpdate() = runTest(testDispatcher) { + // Given + val category = CategoryEntity(id = 1, name = "Alt") + viewModel.showEditDialog(category) + viewModel.updateEditCategoryName(" ") + advanceUntilIdle() + + // When + viewModel.saveEditCategory() + advanceUntilIdle() + + // Then + assertTrue(fakeRepository.updatedCategories.isEmpty()) + } + + @Test + fun test_showDeleteDialog_unusedCategory_showsSimpleDeleteDialog() = runTest(testDispatcher) { + // Given – no items in fake item repository + val category = CategoryEntity(id = 1, name = "Lebensmittel") + advanceUntilIdle() + + // When + viewModel.showDeleteDialog(category) + advanceUntilIdle() + + // Then + val state = viewModel.uiState.value + assertTrue(state.isDeleteDialogVisible) + assertFalse(state.isReassignDialogVisible) + assertEquals(category, state.categoryToDelete) + } + + @Test + fun test_showDeleteDialog_usedCategory_showsReassignDialog() = runTest(testDispatcher) { + // Given – item uses category 1 + fakeItemRepository.addItem( + ItemEntity( + id = "i1", name = "Reis", categoryId = 1, quantity = 1.0, + unit = "kg", unitPrice = 0.0, kcalPer100g = null, expiryDate = null, + locationId = 1, minStock = 0.0, notes = "", lastUpdated = 0L + ) + ) + val category = CategoryEntity(id = 1, name = "Lebensmittel") + advanceUntilIdle() + + // When + viewModel.showDeleteDialog(category) + advanceUntilIdle() + + // Then + val state = viewModel.uiState.value + assertFalse(state.isDeleteDialogVisible) + assertTrue(state.isReassignDialogVisible) + assertEquals(1, state.itemCountForDelete) + assertEquals(category, state.categoryToDelete) + } + + @Test + fun test_deleteWithReassignment_reassignsItemsAndDeletesCategory() = runTest(testDispatcher) { + // Given + fakeItemRepository.addItem( + ItemEntity( + id = "i1", name = "Reis", categoryId = 1, quantity = 1.0, + unit = "kg", unitPrice = 0.0, kcalPer100g = null, expiryDate = null, + locationId = 1, minStock = 0.0, notes = "", lastUpdated = 0L + ) + ) + val category = CategoryEntity(id = 1, name = "Lebensmittel") + viewModel.showDeleteDialog(category) + advanceUntilIdle() + viewModel.updateReassignTarget(2) + + // When + viewModel.deleteWithReassignment() + advanceUntilIdle() + + // Then + assertFalse(viewModel.uiState.value.isReassignDialogVisible) + assertNull(viewModel.uiState.value.categoryToDelete) + assertEquals(1, fakeItemRepository.reassignedCategories.size) + assertEquals(1 to 2, fakeItemRepository.reassignedCategories.first()) + assertEquals(1, fakeRepository.deletedCategories.size) + } + + @Test + fun test_dismissReassignDialog_clearsAllReassignState() = runTest(testDispatcher) { + // Given – set up reassign dialog state + fakeItemRepository.addItem( + ItemEntity( + id = "i1", name = "Reis", categoryId = 1, quantity = 1.0, + unit = "kg", unitPrice = 0.0, kcalPer100g = null, expiryDate = null, + locationId = 1, minStock = 0.0, notes = "", lastUpdated = 0L + ) + ) + viewModel.showDeleteDialog(CategoryEntity(id = 1, name = "Lebensmittel")) + advanceUntilIdle() + viewModel.updateReassignTarget(2) + + // When + viewModel.dismissReassignDialog() + + // Then + val state = viewModel.uiState.value + assertFalse(state.isReassignDialogVisible) + assertNull(state.categoryToDelete) + assertEquals(0, state.itemCountForDelete) + assertNull(state.reassignTargetCategoryId) + } } private class FakeCategoryRepository : CategoryRepository { private val flow = MutableStateFlow>(emptyList()) val insertedCategories = mutableListOf() val deletedCategories = mutableListOf() + val updatedCategories = mutableListOf() fun emit(categories: List) { flow.value = categories @@ -216,6 +383,7 @@ private class FakeCategoryRepository : CategoryRepository { } override suspend fun update(category: CategoryEntity) { + updatedCategories.add(category) flow.value = flow.value.map { if (it.id == category.id) category else it } } @@ -224,3 +392,29 @@ private class FakeCategoryRepository : CategoryRepository { flow.value = flow.value.filter { it.id != category.id } } } + +private class FakeItemRepositoryForCategories : ItemRepository { + private val _items = mutableListOf() + val reassignedCategories = 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) { _items.add(item) } + override suspend fun update(item: ItemEntity) { _items.replaceAll { if (it.id == item.id) item else it } } + 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()) + override suspend fun countByCategoryId(categoryId: Int): Int = _items.count { it.categoryId == categoryId } + override suspend fun reassignCategory(fromId: Int, toId: Int) { + reassignedCategories.add(fromId to toId) + _items.replaceAll { if (it.categoryId == fromId) it.copy(categoryId = toId) else it } + } + override suspend fun countByLocationId(locationId: Int): Int = _items.count { it.locationId == locationId } + override suspend fun reassignLocation(fromId: Int, toId: Int) {} + override suspend fun getLastUsedLocationId(): Int? = _items.maxByOrNull { it.lastUpdated }?.locationId +} diff --git a/app/src/test/java/de/krisenvorrat/app/ui/dashboard/DashboardViewModelTest.kt b/app/src/test/java/de/krisenvorrat/app/ui/dashboard/DashboardViewModelTest.kt index 4eb3686..f483b2e 100644 --- a/app/src/test/java/de/krisenvorrat/app/ui/dashboard/DashboardViewModelTest.kt +++ b/app/src/test/java/de/krisenvorrat/app/ui/dashboard/DashboardViewModelTest.kt @@ -333,6 +333,12 @@ private class FakeItemRepository : ItemRepository { override fun getExpiringSoon(daysUntil: Int): Flow> = MutableStateFlow(emptyList()) + + override suspend fun countByCategoryId(categoryId: Int): Int = 0 + override suspend fun reassignCategory(fromId: Int, toId: Int) {} + override suspend fun countByLocationId(locationId: Int): Int = 0 + override suspend fun reassignLocation(fromId: Int, toId: Int) {} + override suspend fun getLastUsedLocationId(): Int? = null } private class FakeCategoryRepository : CategoryRepository { 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 index 4002a6f..2971cd8 100644 --- a/app/src/test/java/de/krisenvorrat/app/ui/item/ItemFormViewModelTest.kt +++ b/app/src/test/java/de/krisenvorrat/app/ui/item/ItemFormViewModelTest.kt @@ -112,6 +112,87 @@ class ItemFormViewModelTest { assertEquals(2, viewModel.uiState.value.locations.size) } + @Test + fun test_init_createMode_singleLocation_autoSelectsLocation() = runTest(testDispatcher) { + // Given + fakeLocationRepository.emit(listOf(LocationEntity(id = 1, name = "Keller"))) + + // When + val viewModel = createViewModel() + advanceUntilIdle() + + // Then + assertEquals(1, viewModel.uiState.value.locationId) + } + + @Test + fun test_init_createMode_multipleLocations_doesNotAutoSelect() = runTest(testDispatcher) { + // Given + fakeLocationRepository.emit( + listOf( + LocationEntity(id = 1, name = "Keller"), + LocationEntity(id = 2, name = "Garage") + ) + ) + + // When + val viewModel = createViewModel() + advanceUntilIdle() + + // Then + assertEquals(null, viewModel.uiState.value.locationId) + } + + @Test + fun test_init_createMode_withLastUsedLocation_preselectsLastLocation() = runTest(testDispatcher) { + // Given – an existing item with locationId = 2 + fakeItemRepository.addItem( + ItemEntity( + id = "prev-1", name = "Alt", categoryId = 1, quantity = 1.0, + unit = "kg", unitPrice = 0.0, kcalPer100g = null, expiryDate = null, + locationId = 2, minStock = 0.0, notes = "", lastUpdated = 1000L + ) + ) + fakeLocationRepository.emit( + listOf( + LocationEntity(id = 1, name = "Keller"), + LocationEntity(id = 2, name = "Küche") + ) + ) + + // When + val viewModel = createViewModel() + advanceUntilIdle() + + // Then – location 2 is preselected (from last used item) + assertEquals(2, viewModel.uiState.value.locationId) + } + + @Test + fun test_init_createMode_staleLastLocationId_doesNotPreselect() = runTest(testDispatcher) { + // Given – last used location is 99, but only locations 1 and 2 exist + fakeItemRepository.addItem( + ItemEntity( + id = "prev-1", name = "Alt", categoryId = 1, quantity = 1.0, + unit = "kg", unitPrice = 0.0, kcalPer100g = null, expiryDate = null, + locationId = 99, minStock = 0.0, notes = "", lastUpdated = 1000L + ) + ) + fakeLocationRepository.emit( + listOf( + LocationEntity(id = 1, name = "Keller"), + LocationEntity(id = 2, name = "Küche") + ) + ) + + // When + val viewModel = createViewModel() + advanceUntilIdle() + + // Then – stale ID 99 is not in locations list → no preselection + assertEquals(null, viewModel.uiState.value.locationId) + } + // --- Edit Mode Tests --- @Test @@ -464,6 +545,12 @@ private class FakeItemFormRepository : ItemRepository { override fun getExpiringSoon(daysUntil: Int): Flow> = MutableStateFlow(emptyList()) + + override suspend fun countByCategoryId(categoryId: Int): Int = 0 + override suspend fun reassignCategory(fromId: Int, toId: Int) {} + override suspend fun countByLocationId(locationId: Int): Int = 0 + override suspend fun reassignLocation(fromId: Int, toId: Int) {} + override suspend fun getLastUsedLocationId(): Int? = items.maxByOrNull { it.lastUpdated }?.locationId } private class FakeFormCategoryRepository : CategoryRepository { diff --git a/app/src/test/java/de/krisenvorrat/app/ui/item/ItemListViewModelTest.kt b/app/src/test/java/de/krisenvorrat/app/ui/item/ItemListViewModelTest.kt index 1163353..555ae14 100644 --- a/app/src/test/java/de/krisenvorrat/app/ui/item/ItemListViewModelTest.kt +++ b/app/src/test/java/de/krisenvorrat/app/ui/item/ItemListViewModelTest.kt @@ -288,6 +288,12 @@ private class FakeItemRepository : ItemRepository { override fun getExpiringSoon(daysUntil: Int): Flow> = MutableStateFlow(emptyList()) + + override suspend fun countByCategoryId(categoryId: Int): Int = 0 + override suspend fun reassignCategory(fromId: Int, toId: Int) {} + override suspend fun countByLocationId(locationId: Int): Int = 0 + override suspend fun reassignLocation(fromId: Int, toId: Int) {} + override suspend fun getLastUsedLocationId(): Int? = null } private class FakeCategoryRepository : CategoryRepository { diff --git a/app/src/test/java/de/krisenvorrat/app/ui/location/LocationListViewModelTest.kt b/app/src/test/java/de/krisenvorrat/app/ui/location/LocationListViewModelTest.kt index 83ce44d..8d99745 100644 --- a/app/src/test/java/de/krisenvorrat/app/ui/location/LocationListViewModelTest.kt +++ b/app/src/test/java/de/krisenvorrat/app/ui/location/LocationListViewModelTest.kt @@ -1,6 +1,8 @@ package de.krisenvorrat.app.ui.location +import de.krisenvorrat.app.data.db.entity.ItemEntity import de.krisenvorrat.app.data.db.entity.LocationEntity +import de.krisenvorrat.app.domain.repository.ItemRepository import de.krisenvorrat.app.domain.repository.LocationRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -24,13 +26,15 @@ class LocationListViewModelTest { private val testDispatcher = StandardTestDispatcher() private lateinit var fakeRepository: FakeLocationRepository + private lateinit var fakeItemRepository: FakeItemRepositoryForLocations private lateinit var viewModel: LocationListViewModel @Before fun setup() { Dispatchers.setMain(testDispatcher) fakeRepository = FakeLocationRepository() - viewModel = LocationListViewModel(fakeRepository) + fakeItemRepository = FakeItemRepositoryForLocations() + viewModel = LocationListViewModel(fakeRepository, fakeItemRepository) } @After @@ -144,6 +148,7 @@ class LocationListViewModelTest { // When viewModel.showDeleteDialog(location) + advanceUntilIdle() // Then val state = viewModel.uiState.value @@ -196,12 +201,174 @@ class LocationListViewModelTest { // Then assertTrue(fakeRepository.deletedLocations.isEmpty()) } + + @Test + fun test_showEditDialog_setsEditDialogState() = runTest(testDispatcher) { + // Given + val location = LocationEntity(id = 1, name = "Keller") + advanceUntilIdle() + + // When + viewModel.showEditDialog(location) + + // Then + val state = viewModel.uiState.value + assertTrue(state.isEditDialogVisible) + assertEquals(location, state.locationToEdit) + assertEquals("Keller", state.editLocationName) + } + + @Test + fun test_dismissEditDialog_clearsEditDialogState() = runTest(testDispatcher) { + // Given + viewModel.showEditDialog(LocationEntity(id = 1, name = "Test")) + advanceUntilIdle() + + // When + viewModel.dismissEditDialog() + + // Then + val state = viewModel.uiState.value + assertFalse(state.isEditDialogVisible) + assertNull(state.locationToEdit) + assertEquals("", state.editLocationName) + } + + @Test + fun test_saveEditLocation_withValidName_updatesAndClosesDialog() = runTest(testDispatcher) { + // Given + val location = LocationEntity(id = 1, name = "Alt") + viewModel.showEditDialog(location) + viewModel.updateEditLocationName("Neu") + advanceUntilIdle() + + // When + viewModel.saveEditLocation() + advanceUntilIdle() + + // Then + assertFalse(viewModel.uiState.value.isEditDialogVisible) + assertNull(viewModel.uiState.value.locationToEdit) + assertEquals(1, fakeRepository.updatedLocations.size) + assertEquals("Neu", fakeRepository.updatedLocations.first().name) + } + + @Test + fun test_saveEditLocation_withBlankName_doesNotUpdate() = runTest(testDispatcher) { + // Given + val location = LocationEntity(id = 1, name = "Alt") + viewModel.showEditDialog(location) + viewModel.updateEditLocationName(" ") + advanceUntilIdle() + + // When + viewModel.saveEditLocation() + advanceUntilIdle() + + // Then + assertTrue(fakeRepository.updatedLocations.isEmpty()) + } + + @Test + fun test_showDeleteDialog_unusedLocation_showsSimpleDeleteDialog() = runTest(testDispatcher) { + // Given – no items in fake item repository + val location = LocationEntity(id = 1, name = "Keller") + advanceUntilIdle() + + // When + viewModel.showDeleteDialog(location) + advanceUntilIdle() + + // Then + val state = viewModel.uiState.value + assertTrue(state.isDeleteDialogVisible) + assertFalse(state.isReassignDialogVisible) + assertEquals(location, state.locationToDelete) + } + + @Test + fun test_showDeleteDialog_usedLocation_showsReassignDialog() = runTest(testDispatcher) { + // Given – item uses location 1 + fakeItemRepository.addItem( + ItemEntity( + id = "i1", name = "Reis", categoryId = 1, quantity = 1.0, + unit = "kg", unitPrice = 0.0, kcalPer100g = null, expiryDate = null, + locationId = 1, minStock = 0.0, notes = "", lastUpdated = 0L + ) + ) + val location = LocationEntity(id = 1, name = "Keller") + advanceUntilIdle() + + // When + viewModel.showDeleteDialog(location) + advanceUntilIdle() + + // Then + val state = viewModel.uiState.value + assertFalse(state.isDeleteDialogVisible) + assertTrue(state.isReassignDialogVisible) + assertEquals(1, state.itemCountForDelete) + assertEquals(location, state.locationToDelete) + } + + @Test + fun test_deleteWithReassignment_reassignsItemsAndDeletesLocation() = runTest(testDispatcher) { + // Given + fakeItemRepository.addItem( + ItemEntity( + id = "i1", name = "Reis", categoryId = 1, quantity = 1.0, + unit = "kg", unitPrice = 0.0, kcalPer100g = null, expiryDate = null, + locationId = 1, minStock = 0.0, notes = "", lastUpdated = 0L + ) + ) + val location = LocationEntity(id = 1, name = "Keller") + viewModel.showDeleteDialog(location) + advanceUntilIdle() + viewModel.updateReassignTarget(2) + + // When + viewModel.deleteWithReassignment() + advanceUntilIdle() + + // Then + assertFalse(viewModel.uiState.value.isReassignDialogVisible) + assertNull(viewModel.uiState.value.locationToDelete) + assertEquals(1, fakeItemRepository.reassignedLocations.size) + assertEquals(1 to 2, fakeItemRepository.reassignedLocations.first()) + assertEquals(1, fakeRepository.deletedLocations.size) + } + + @Test + fun test_dismissReassignDialog_clearsAllReassignState() = runTest(testDispatcher) { + // Given – set up reassign dialog state + fakeItemRepository.addItem( + ItemEntity( + id = "i1", name = "Wasser", categoryId = 1, quantity = 1.0, + unit = "Flasche", unitPrice = 0.0, kcalPer100g = null, expiryDate = null, + locationId = 1, minStock = 0.0, notes = "", lastUpdated = 0L + ) + ) + viewModel.showDeleteDialog(LocationEntity(id = 1, name = "Keller")) + advanceUntilIdle() + viewModel.updateReassignTarget(2) + + // When + viewModel.dismissReassignDialog() + + // Then + val state = viewModel.uiState.value + assertFalse(state.isReassignDialogVisible) + assertNull(state.locationToDelete) + assertEquals(0, state.itemCountForDelete) + assertNull(state.reassignTargetLocationId) + } } private class FakeLocationRepository : LocationRepository { private val flow = MutableStateFlow>(emptyList()) val insertedLocations = mutableListOf() val deletedLocations = mutableListOf() + val updatedLocations = mutableListOf() fun emit(locations: List) { flow.value = locations @@ -215,6 +382,7 @@ private class FakeLocationRepository : LocationRepository { } override suspend fun update(location: LocationEntity) { + updatedLocations.add(location) flow.value = flow.value.map { if (it.id == location.id) location else it } } @@ -223,3 +391,29 @@ private class FakeLocationRepository : LocationRepository { flow.value = flow.value.filter { it.id != location.id } } } + +private class FakeItemRepositoryForLocations : ItemRepository { + private val _items = mutableListOf() + val reassignedLocations = 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) { _items.add(item) } + override suspend fun update(item: ItemEntity) { _items.replaceAll { if (it.id == item.id) item else it } } + 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()) + override suspend fun countByCategoryId(categoryId: Int): Int = _items.count { it.categoryId == categoryId } + override suspend fun reassignCategory(fromId: Int, toId: Int) {} + override suspend fun countByLocationId(locationId: Int): Int = _items.count { it.locationId == locationId } + override suspend fun reassignLocation(fromId: Int, toId: Int) { + reassignedLocations.add(fromId to toId) + _items.replaceAll { if (it.locationId == fromId) it.copy(locationId = toId) else it } + } + override suspend fun getLastUsedLocationId(): Int? = _items.maxByOrNull { it.lastUpdated }?.locationId +} diff --git a/app/src/test/java/de/krisenvorrat/app/ui/warnings/WarningsViewModelTest.kt b/app/src/test/java/de/krisenvorrat/app/ui/warnings/WarningsViewModelTest.kt index ccd5d83..11567bf 100644 --- a/app/src/test/java/de/krisenvorrat/app/ui/warnings/WarningsViewModelTest.kt +++ b/app/src/test/java/de/krisenvorrat/app/ui/warnings/WarningsViewModelTest.kt @@ -234,6 +234,12 @@ private class FakeItemRepository : ItemRepository { override fun getExpiringSoon(daysUntil: Int): Flow> = MutableStateFlow(emptyList()) + + override suspend fun countByCategoryId(categoryId: Int): Int = 0 + override suspend fun reassignCategory(fromId: Int, toId: Int) {} + override suspend fun countByLocationId(locationId: Int): Int = 0 + override suspend fun reassignLocation(fromId: Int, toId: Int) {} + override suspend fun getLastUsedLocationId(): Int? = null } // endregion