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:
parent
caf7002406
commit
395939a4ec
18 changed files with 1129 additions and 41 deletions
|
|
@ -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?
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,13 +134,40 @@ 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
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,13 +134,40 @@ 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
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue