feat(categories): Umbenennen, Löschen mit Umzuweisungs-Dialog, letzter Lagerort vorauswählen (#51)

- 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)
This commit is contained in:
Jens Reinemann 2026-05-16 14:05:45 +02:00
parent caf7002406
commit 395939a4ec
18 changed files with 1129 additions and 41 deletions

View file

@ -39,4 +39,19 @@ internal interface ItemDao {
@Upsert
suspend fun upsertAll(items: List<ItemEntity>)
@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?
}

View file

@ -35,4 +35,19 @@ internal class ItemRepositoryImpl @Inject constructor(
override fun getExpiringSoon(daysUntil: Int): Flow<List<ItemEntity>> =
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() }
}

View file

@ -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()

View file

@ -12,4 +12,9 @@ internal interface ItemRepository {
fun getByCategory(categoryId: Int): Flow<List<ItemEntity>>
fun getByLocation(locationId: Int): Flow<List<ItemEntity>>
fun getExpiringSoon(daysUntil: Int = 30): Flow<List<ItemEntity>>
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?
}

View file

@ -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,15 +134,42 @@ internal fun CategoryListScreen(
)
}
if (uiState.isDeleteDialogVisible && uiState.categoryToDelete != null) {
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 = uiState.categoryToDelete!!.name,
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
)
}
}
}
@Composable
private fun AddCategoryDialog(
name: String,
@ -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<CategoryEntity>,
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")
}
}
)
}

View file

@ -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<CategoryEntity> = 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
)
}
}
}

View file

@ -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 }

View file

@ -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) {}
}
}
}

View file

@ -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,15 +134,42 @@ internal fun LocationListScreen(
)
}
if (uiState.isDeleteDialogVisible && uiState.locationToDelete != null) {
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 = uiState.locationToDelete!!.name,
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
)
}
}
}
@Composable
private fun AddLocationDialog(
name: String,
@ -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<LocationEntity>,
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")
}
}
)
}

View file

@ -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<LocationEntity> = 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
)
}
}
}

View file

@ -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<ItemEntity> = items.toList()
}

View file

@ -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(

View file

@ -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<List<CategoryEntity>>(emptyList())
val insertedCategories = mutableListOf<CategoryEntity>()
val deletedCategories = mutableListOf<CategoryEntity>()
val updatedCategories = mutableListOf<CategoryEntity>()
fun emit(categories: List<CategoryEntity>) {
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<ItemEntity>()
val reassignedCategories = mutableListOf<Pair<Int, Int>>()
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) { _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<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())
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
}

View file

@ -333,6 +333,12 @@ private class FakeItemRepository : ItemRepository {
override fun getExpiringSoon(daysUntil: Int): Flow<List<ItemEntity>> =
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 {

View file

@ -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<List<ItemEntity>> =
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 {

View file

@ -288,6 +288,12 @@ private class FakeItemRepository : ItemRepository {
override fun getExpiringSoon(daysUntil: Int): Flow<List<ItemEntity>> =
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 {

View file

@ -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<List<LocationEntity>>(emptyList())
val insertedLocations = mutableListOf<LocationEntity>()
val deletedLocations = mutableListOf<LocationEntity>()
val updatedLocations = mutableListOf<LocationEntity>()
fun emit(locations: List<LocationEntity>) {
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<ItemEntity>()
val reassignedLocations = mutableListOf<Pair<Int, Int>>()
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) { _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<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())
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
}

View file

@ -234,6 +234,12 @@ private class FakeItemRepository : ItemRepository {
override fun getExpiringSoon(daysUntil: Int): Flow<List<ItemEntity>> =
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