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
|
@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?
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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() }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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?
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -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) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue