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 @Upsert
suspend fun upsertAll(items: List<ItemEntity>) 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>> = override fun getExpiringSoon(daysUntil: Int): Flow<List<ItemEntity>> =
dao.getExpiringSoonByCutoff(LocalDate.now().plusDays(daysUntil.toLong())) 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 android.content.Context
import androidx.room.Room import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.withTransaction import androidx.room.withTransaction
import androidx.sqlite.db.SupportSQLiteDatabase
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@ -23,7 +25,27 @@ internal object DatabaseModule {
@Provides @Provides
@Singleton @Singleton
fun provideDatabase(@ApplicationContext context: Context): KrisenvorratDatabase = 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 @Provides
fun provideItemDao(db: KrisenvorratDatabase): ItemDao = db.itemDao() fun provideItemDao(db: KrisenvorratDatabase): ItemDao = db.itemDao()

View file

@ -12,4 +12,9 @@ internal interface ItemRepository {
fun getByCategory(categoryId: Int): Flow<List<ItemEntity>> fun getByCategory(categoryId: Int): Flow<List<ItemEntity>>
fun getByLocation(locationId: Int): Flow<List<ItemEntity>> fun getByLocation(locationId: Int): Flow<List<ItemEntity>>
fun getExpiringSoon(daysUntil: Int = 30): 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.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
@ -27,11 +32,15 @@ import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.krisenvorrat.app.data.db.entity.CategoryEntity
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -87,7 +96,7 @@ internal fun CategoryListScreen(
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp), .padding(horizontal = 16.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
@ -96,6 +105,12 @@ internal fun CategoryListScreen(
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
IconButton(onClick = { viewModel.showEditDialog(category) }) {
Icon(
imageVector = Icons.Default.Edit,
contentDescription = "Kategorie umbenennen"
)
}
IconButton(onClick = { viewModel.showDeleteDialog(category) }) { IconButton(onClick = { viewModel.showDeleteDialog(category) }) {
Icon( Icon(
imageVector = Icons.Default.Delete, imageVector = Icons.Default.Delete,
@ -119,12 +134,39 @@ internal fun CategoryListScreen(
) )
} }
if (uiState.isDeleteDialogVisible && uiState.categoryToDelete != null) { if (uiState.isEditDialogVisible) {
DeleteCategoryDialog( uiState.categoryToEdit?.let {
categoryName = uiState.categoryToDelete!!.name, EditCategoryDialog(
onConfirm = viewModel::deleteCategory, name = uiState.editCategoryName,
onDismiss = viewModel::dismissDeleteDialog 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 @Composable
private fun DeleteCategoryDialog( private fun DeleteCategoryDialog(
categoryName: String, categoryName: String,
@ -176,8 +255,7 @@ private fun DeleteCategoryDialog(
title = { Text("Kategorie löschen?") }, title = { Text("Kategorie löschen?") },
text = { text = {
Text( Text(
"Möchten Sie die Kategorie \"$categoryName\" wirklich löschen?\n\n" + "Möchten Sie die Kategorie \"$categoryName\" wirklich löschen?"
"Achtung: Alle Artikel in dieser Kategorie werden ebenfalls gelöscht!"
) )
}, },
confirmButton = { 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 dagger.hilt.android.lifecycle.HiltViewModel
import de.krisenvorrat.app.data.db.entity.CategoryEntity import de.krisenvorrat.app.data.db.entity.CategoryEntity
import de.krisenvorrat.app.domain.repository.CategoryRepository import de.krisenvorrat.app.domain.repository.CategoryRepository
import de.krisenvorrat.app.domain.repository.ItemRepository
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@ -15,14 +16,21 @@ import javax.inject.Inject
internal data class CategoryListUiState( internal data class CategoryListUiState(
val categories: List<CategoryEntity> = emptyList(), val categories: List<CategoryEntity> = emptyList(),
val isAddDialogVisible: Boolean = false, val isAddDialogVisible: Boolean = false,
val isEditDialogVisible: Boolean = false,
val isDeleteDialogVisible: Boolean = false, val isDeleteDialogVisible: Boolean = false,
val isReassignDialogVisible: Boolean = false,
val categoryToDelete: CategoryEntity? = null, 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 @HiltViewModel
internal class CategoryListViewModel @Inject constructor( internal class CategoryListViewModel @Inject constructor(
private val repository: CategoryRepository private val repository: CategoryRepository,
private val itemRepository: ItemRepository
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(CategoryListUiState()) private val _uiState = MutableStateFlow(CategoryListUiState())
@ -54,30 +62,110 @@ internal class CategoryListViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
try { try {
repository.insert(CategoryEntity(name = name)) repository.insert(CategoryEntity(name = name))
} catch (_: Exception) { } catch (_: Exception) {}
// Room-Fehler werden still behandelt
}
} }
_uiState.update { it.copy(isAddDialogVisible = false, newCategoryName = "") } _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) { 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() { fun dismissDeleteDialog() {
_uiState.update { it.copy(isDeleteDialogVisible = false, categoryToDelete = null) } _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() { fun deleteCategory() {
val category = _uiState.value.categoryToDelete ?: return val category = _uiState.value.categoryToDelete ?: return
viewModelScope.launch { viewModelScope.launch {
try { try {
repository.delete(category) repository.delete(category)
} catch (_: Exception) { } catch (_: Exception) {}
// Room-Fehler werden still behandelt
}
} }
_uiState.update { it.copy(isDeleteDialogVisible = false, categoryToDelete = null) } _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, isError: Boolean,
errorText: String? errorText: String?
) { ) {
var isExpanded by remember { mutableStateOf(false) }
val selectedName = locations.find { it.id == selectedLocationId }?.name ?: "" 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( ExposedDropdownMenuBox(
expanded = isExpanded, expanded = isExpanded,
onExpandedChange = { isExpanded = it } onExpandedChange = { isExpanded = it }

View file

@ -68,7 +68,28 @@ internal class ItemFormViewModel @Inject constructor(
} }
viewModelScope.launch { viewModelScope.launch {
locationRepository.getAll().collect { locations -> 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.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
@ -27,11 +32,15 @@ import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.krisenvorrat.app.data.db.entity.LocationEntity
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -87,7 +96,7 @@ internal fun LocationListScreen(
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp), .padding(horizontal = 16.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
@ -96,6 +105,12 @@ internal fun LocationListScreen(
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
IconButton(onClick = { viewModel.showEditDialog(location) }) {
Icon(
imageVector = Icons.Default.Edit,
contentDescription = "Lagerort umbenennen"
)
}
IconButton(onClick = { viewModel.showDeleteDialog(location) }) { IconButton(onClick = { viewModel.showDeleteDialog(location) }) {
Icon( Icon(
imageVector = Icons.Default.Delete, imageVector = Icons.Default.Delete,
@ -119,12 +134,39 @@ internal fun LocationListScreen(
) )
} }
if (uiState.isDeleteDialogVisible && uiState.locationToDelete != null) { if (uiState.isEditDialogVisible) {
DeleteLocationDialog( uiState.locationToEdit?.let {
locationName = uiState.locationToDelete!!.name, EditLocationDialog(
onConfirm = viewModel::deleteLocation, name = uiState.editLocationName,
onDismiss = viewModel::dismissDeleteDialog 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 @Composable
private fun DeleteLocationDialog( private fun DeleteLocationDialog(
locationName: String, locationName: String,
@ -176,8 +255,7 @@ private fun DeleteLocationDialog(
title = { Text("Lagerort löschen?") }, title = { Text("Lagerort löschen?") },
text = { text = {
Text( Text(
"Möchten Sie den Lagerort \"$locationName\" wirklich löschen?\n\n" + "Möchten Sie den Lagerort \"$locationName\" wirklich löschen?"
"Achtung: Alle Artikel an diesem Lagerort werden ebenfalls gelöscht!"
) )
}, },
confirmButton = { 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 androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.krisenvorrat.app.data.db.entity.LocationEntity import de.krisenvorrat.app.data.db.entity.LocationEntity
import de.krisenvorrat.app.domain.repository.ItemRepository
import de.krisenvorrat.app.domain.repository.LocationRepository import de.krisenvorrat.app.domain.repository.LocationRepository
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -15,14 +16,21 @@ import javax.inject.Inject
internal data class LocationListUiState( internal data class LocationListUiState(
val locations: List<LocationEntity> = emptyList(), val locations: List<LocationEntity> = emptyList(),
val isAddDialogVisible: Boolean = false, val isAddDialogVisible: Boolean = false,
val isEditDialogVisible: Boolean = false,
val isDeleteDialogVisible: Boolean = false, val isDeleteDialogVisible: Boolean = false,
val isReassignDialogVisible: Boolean = false,
val locationToDelete: LocationEntity? = null, 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 @HiltViewModel
internal class LocationListViewModel @Inject constructor( internal class LocationListViewModel @Inject constructor(
private val repository: LocationRepository private val repository: LocationRepository,
private val itemRepository: ItemRepository
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(LocationListUiState()) private val _uiState = MutableStateFlow(LocationListUiState())
@ -54,30 +62,110 @@ internal class LocationListViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
try { try {
repository.insert(LocationEntity(name = name)) repository.insert(LocationEntity(name = name))
} catch (_: Exception) { } catch (_: Exception) {}
// Room-Fehler werden still behandelt
}
} }
_uiState.update { it.copy(isAddDialogVisible = false, newLocationName = "") } _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) { 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() { fun dismissDeleteDialog() {
_uiState.update { it.copy(isDeleteDialogVisible = false, locationToDelete = null) } _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() { fun deleteLocation() {
val location = _uiState.value.locationToDelete ?: return val location = _uiState.value.locationToDelete ?: return
viewModelScope.launch { viewModelScope.launch {
try { try {
repository.delete(location) repository.delete(location)
} catch (_: Exception) { } catch (_: Exception) {}
// Room-Fehler werden still behandelt
}
} }
_uiState.update { it.copy(isDeleteDialogVisible = false, locationToDelete = null) } _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() 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() fun getItems(): List<ItemEntity> = items.toList()
} }

View file

@ -60,6 +60,25 @@ private class FakeItemDao : ItemDao {
} }
emit() 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( private fun buildItem(

View file

@ -1,7 +1,9 @@
package de.krisenvorrat.app.ui.category package de.krisenvorrat.app.ui.category
import de.krisenvorrat.app.data.db.entity.CategoryEntity 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.CategoryRepository
import de.krisenvorrat.app.domain.repository.ItemRepository
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@ -25,13 +27,15 @@ class CategoryListViewModelTest {
private val testDispatcher = StandardTestDispatcher() private val testDispatcher = StandardTestDispatcher()
private lateinit var fakeRepository: FakeCategoryRepository private lateinit var fakeRepository: FakeCategoryRepository
private lateinit var fakeItemRepository: FakeItemRepositoryForCategories
private lateinit var viewModel: CategoryListViewModel private lateinit var viewModel: CategoryListViewModel
@Before @Before
fun setup() { fun setup() {
Dispatchers.setMain(testDispatcher) Dispatchers.setMain(testDispatcher)
fakeRepository = FakeCategoryRepository() fakeRepository = FakeCategoryRepository()
viewModel = CategoryListViewModel(fakeRepository) fakeItemRepository = FakeItemRepositoryForCategories()
viewModel = CategoryListViewModel(fakeRepository, fakeItemRepository)
} }
@After @After
@ -145,6 +149,7 @@ class CategoryListViewModelTest {
// When // When
viewModel.showDeleteDialog(category) viewModel.showDeleteDialog(category)
advanceUntilIdle()
// Then // Then
val state = viewModel.uiState.value val state = viewModel.uiState.value
@ -197,12 +202,174 @@ class CategoryListViewModelTest {
// Then // Then
assertTrue(fakeRepository.deletedCategories.isEmpty()) 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 class FakeCategoryRepository : CategoryRepository {
private val flow = MutableStateFlow<List<CategoryEntity>>(emptyList()) private val flow = MutableStateFlow<List<CategoryEntity>>(emptyList())
val insertedCategories = mutableListOf<CategoryEntity>() val insertedCategories = mutableListOf<CategoryEntity>()
val deletedCategories = mutableListOf<CategoryEntity>() val deletedCategories = mutableListOf<CategoryEntity>()
val updatedCategories = mutableListOf<CategoryEntity>()
fun emit(categories: List<CategoryEntity>) { fun emit(categories: List<CategoryEntity>) {
flow.value = categories flow.value = categories
@ -216,6 +383,7 @@ private class FakeCategoryRepository : CategoryRepository {
} }
override suspend fun update(category: CategoryEntity) { override suspend fun update(category: CategoryEntity) {
updatedCategories.add(category)
flow.value = flow.value.map { if (it.id == category.id) category else it } 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 } 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>> = override fun getExpiringSoon(daysUntil: Int): Flow<List<ItemEntity>> =
MutableStateFlow(emptyList()) 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 { private class FakeCategoryRepository : CategoryRepository {

View file

@ -112,6 +112,87 @@ class ItemFormViewModelTest {
assertEquals(2, viewModel.uiState.value.locations.size) 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 --- // --- Edit Mode Tests ---
@Test @Test
@ -464,6 +545,12 @@ private class FakeItemFormRepository : ItemRepository {
override fun getExpiringSoon(daysUntil: Int): Flow<List<ItemEntity>> = override fun getExpiringSoon(daysUntil: Int): Flow<List<ItemEntity>> =
MutableStateFlow(emptyList()) 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 { private class FakeFormCategoryRepository : CategoryRepository {

View file

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

View file

@ -1,6 +1,8 @@
package de.krisenvorrat.app.ui.location 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.data.db.entity.LocationEntity
import de.krisenvorrat.app.domain.repository.ItemRepository
import de.krisenvorrat.app.domain.repository.LocationRepository import de.krisenvorrat.app.domain.repository.LocationRepository
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -24,13 +26,15 @@ class LocationListViewModelTest {
private val testDispatcher = StandardTestDispatcher() private val testDispatcher = StandardTestDispatcher()
private lateinit var fakeRepository: FakeLocationRepository private lateinit var fakeRepository: FakeLocationRepository
private lateinit var fakeItemRepository: FakeItemRepositoryForLocations
private lateinit var viewModel: LocationListViewModel private lateinit var viewModel: LocationListViewModel
@Before @Before
fun setup() { fun setup() {
Dispatchers.setMain(testDispatcher) Dispatchers.setMain(testDispatcher)
fakeRepository = FakeLocationRepository() fakeRepository = FakeLocationRepository()
viewModel = LocationListViewModel(fakeRepository) fakeItemRepository = FakeItemRepositoryForLocations()
viewModel = LocationListViewModel(fakeRepository, fakeItemRepository)
} }
@After @After
@ -144,6 +148,7 @@ class LocationListViewModelTest {
// When // When
viewModel.showDeleteDialog(location) viewModel.showDeleteDialog(location)
advanceUntilIdle()
// Then // Then
val state = viewModel.uiState.value val state = viewModel.uiState.value
@ -196,12 +201,174 @@ class LocationListViewModelTest {
// Then // Then
assertTrue(fakeRepository.deletedLocations.isEmpty()) 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 class FakeLocationRepository : LocationRepository {
private val flow = MutableStateFlow<List<LocationEntity>>(emptyList()) private val flow = MutableStateFlow<List<LocationEntity>>(emptyList())
val insertedLocations = mutableListOf<LocationEntity>() val insertedLocations = mutableListOf<LocationEntity>()
val deletedLocations = mutableListOf<LocationEntity>() val deletedLocations = mutableListOf<LocationEntity>()
val updatedLocations = mutableListOf<LocationEntity>()
fun emit(locations: List<LocationEntity>) { fun emit(locations: List<LocationEntity>) {
flow.value = locations flow.value = locations
@ -215,6 +382,7 @@ private class FakeLocationRepository : LocationRepository {
} }
override suspend fun update(location: LocationEntity) { override suspend fun update(location: LocationEntity) {
updatedLocations.add(location)
flow.value = flow.value.map { if (it.id == location.id) location else it } 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 } 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>> = override fun getExpiringSoon(daysUntil: Int): Flow<List<ItemEntity>> =
MutableStateFlow(emptyList()) 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 // endregion