From a27660fd4a5b7cf33216a600e1f628a7971eb7be Mon Sep 17 00:00:00 2001 From: Jens Reinemann Date: Thu, 14 May 2026 00:56:36 +0200 Subject: [PATCH] feat(ui): add category and location management screens Closes #25 ui/category/: - CategoryListViewModel: StateFlow-based ViewModel with add/delete dialog state management, backed by CategoryRepository - CategoryListScreen: Material 3 Scaffold with LazyColumn, FAB for adding, delete confirmation dialog with CASCADE warning ui/location/: - LocationListViewModel: same pattern for LocationRepository - LocationListScreen: same UI pattern for location management Tests: - CategoryListViewModelTest: 11 tests covering init, add, delete, dialog state, blank name rejection - LocationListViewModelTest: 11 tests (same coverage) Dependencies: - Added lifecycle-runtime-compose for collectAsStateWithLifecycle - Added kotlinx-coroutines-test for ViewModel unit tests --- ...topilot.prompt.md => autonomous.prompt.md} | 0 app/build.gradle.kts | 2 + .../app/ui/category/CategoryListScreen.kt | 197 +++++++++++++++ .../app/ui/category/CategoryListViewModel.kt | 83 +++++++ .../app/ui/location/LocationListScreen.kt | 197 +++++++++++++++ .../app/ui/location/LocationListViewModel.kt | 83 +++++++ .../ui/category/CategoryListViewModelTest.kt | 226 ++++++++++++++++++ .../ui/location/LocationListViewModelTest.kt | 225 +++++++++++++++++ gradle/libs.versions.toml | 3 + 9 files changed, 1016 insertions(+) rename .github/prompts/{autopilot.prompt.md => autonomous.prompt.md} (100%) create mode 100644 app/src/main/java/de/krisenvorrat/app/ui/category/CategoryListScreen.kt create mode 100644 app/src/main/java/de/krisenvorrat/app/ui/category/CategoryListViewModel.kt create mode 100644 app/src/main/java/de/krisenvorrat/app/ui/location/LocationListScreen.kt create mode 100644 app/src/main/java/de/krisenvorrat/app/ui/location/LocationListViewModel.kt create mode 100644 app/src/test/java/de/krisenvorrat/app/ui/category/CategoryListViewModelTest.kt create mode 100644 app/src/test/java/de/krisenvorrat/app/ui/location/LocationListViewModelTest.kt diff --git a/.github/prompts/autopilot.prompt.md b/.github/prompts/autonomous.prompt.md similarity index 100% rename from .github/prompts/autopilot.prompt.md rename to .github/prompts/autonomous.prompt.md diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 101e6a9..b2a04b2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -46,6 +46,7 @@ android { dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.activity.compose) implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.ui) @@ -70,6 +71,7 @@ dependencies { implementation(libs.kotlinx.serialization.json) testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.room.testing) androidTestImplementation(libs.androidx.espresso.core) diff --git a/app/src/main/java/de/krisenvorrat/app/ui/category/CategoryListScreen.kt b/app/src/main/java/de/krisenvorrat/app/ui/category/CategoryListScreen.kt new file mode 100644 index 0000000..025bdad --- /dev/null +++ b/app/src/main/java/de/krisenvorrat/app/ui/category/CategoryListScreen.kt @@ -0,0 +1,197 @@ +package de.krisenvorrat.app.ui.category + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +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.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +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.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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 + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun CategoryListScreen( + onNavigateBack: () -> Unit, + viewModel: CategoryListViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Kategorien") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Zurück" + ) + } + } + ) + }, + floatingActionButton = { + FloatingActionButton(onClick = viewModel::showAddDialog) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = "Kategorie hinzufügen" + ) + } + } + ) { innerPadding -> + if (uiState.categories.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentAlignment = Alignment.Center + ) { + Text( + text = "Noch keine Kategorien vorhanden", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + ) { + items(uiState.categories, key = { it.id }) { category -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = category.name, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f) + ) + IconButton(onClick = { viewModel.showDeleteDialog(category) }) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "Kategorie löschen", + tint = MaterialTheme.colorScheme.error + ) + } + } + HorizontalDivider() + } + } + } + } + + if (uiState.isAddDialogVisible) { + AddCategoryDialog( + name = uiState.newCategoryName, + onNameChange = viewModel::updateNewCategoryName, + onConfirm = viewModel::addCategory, + onDismiss = viewModel::dismissAddDialog + ) + } + + if (uiState.isDeleteDialogVisible && uiState.categoryToDelete != null) { + DeleteCategoryDialog( + categoryName = uiState.categoryToDelete!!.name, + onConfirm = viewModel::deleteCategory, + onDismiss = viewModel::dismissDeleteDialog + ) + } +} + +@Composable +private fun AddCategoryDialog( + name: String, + onNameChange: (String) -> Unit, + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Kategorie hinzufügen") }, + text = { + Column { + OutlinedTextField( + value = name, + onValueChange = onNameChange, + label = { Text("Name") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + } + }, + confirmButton = { + TextButton( + onClick = onConfirm, + enabled = name.isNotBlank() + ) { + Text("Hinzufügen") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Abbrechen") + } + } + ) +} + +@Composable +private fun DeleteCategoryDialog( + categoryName: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + 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!" + ) + }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text( + "Löschen", + color = MaterialTheme.colorScheme.error + ) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Abbrechen") + } + } + ) +} diff --git a/app/src/main/java/de/krisenvorrat/app/ui/category/CategoryListViewModel.kt b/app/src/main/java/de/krisenvorrat/app/ui/category/CategoryListViewModel.kt new file mode 100644 index 0000000..ad04a36 --- /dev/null +++ b/app/src/main/java/de/krisenvorrat/app/ui/category/CategoryListViewModel.kt @@ -0,0 +1,83 @@ +package de.krisenvorrat.app.ui.category + +import androidx.lifecycle.ViewModel +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 kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +internal data class CategoryListUiState( + val categories: List = emptyList(), + val isAddDialogVisible: Boolean = false, + val isDeleteDialogVisible: Boolean = false, + val categoryToDelete: CategoryEntity? = null, + val newCategoryName: String = "" +) + +@HiltViewModel +internal class CategoryListViewModel @Inject constructor( + private val repository: CategoryRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(CategoryListUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + viewModelScope.launch { + repository.getAll().collect { categories -> + _uiState.update { it.copy(categories = categories) } + } + } + } + + fun showAddDialog() { + _uiState.update { it.copy(isAddDialogVisible = true, newCategoryName = "") } + } + + fun dismissAddDialog() { + _uiState.update { it.copy(isAddDialogVisible = false, newCategoryName = "") } + } + + fun updateNewCategoryName(name: String) { + _uiState.update { it.copy(newCategoryName = name) } + } + + fun addCategory() { + val name = _uiState.value.newCategoryName.trim() + if (name.isBlank()) return + viewModelScope.launch { + try { + repository.insert(CategoryEntity(name = name)) + } catch (_: Exception) { + // Room-Fehler werden still behandelt + } + } + _uiState.update { it.copy(isAddDialogVisible = false, newCategoryName = "") } + } + + fun showDeleteDialog(category: CategoryEntity) { + _uiState.update { it.copy(isDeleteDialogVisible = true, categoryToDelete = category) } + } + + fun dismissDeleteDialog() { + _uiState.update { it.copy(isDeleteDialogVisible = false, categoryToDelete = null) } + } + + fun deleteCategory() { + val category = _uiState.value.categoryToDelete ?: return + viewModelScope.launch { + try { + repository.delete(category) + } catch (_: Exception) { + // Room-Fehler werden still behandelt + } + } + _uiState.update { it.copy(isDeleteDialogVisible = false, categoryToDelete = null) } + } +} diff --git a/app/src/main/java/de/krisenvorrat/app/ui/location/LocationListScreen.kt b/app/src/main/java/de/krisenvorrat/app/ui/location/LocationListScreen.kt new file mode 100644 index 0000000..7bf7269 --- /dev/null +++ b/app/src/main/java/de/krisenvorrat/app/ui/location/LocationListScreen.kt @@ -0,0 +1,197 @@ +package de.krisenvorrat.app.ui.location + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +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.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +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.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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 + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun LocationListScreen( + onNavigateBack: () -> Unit, + viewModel: LocationListViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Lagerorte") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Zurück" + ) + } + } + ) + }, + floatingActionButton = { + FloatingActionButton(onClick = viewModel::showAddDialog) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = "Lagerort hinzufügen" + ) + } + } + ) { innerPadding -> + if (uiState.locations.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentAlignment = Alignment.Center + ) { + Text( + text = "Noch keine Lagerorte vorhanden", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + ) { + items(uiState.locations, key = { it.id }) { location -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = location.name, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f) + ) + IconButton(onClick = { viewModel.showDeleteDialog(location) }) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "Lagerort löschen", + tint = MaterialTheme.colorScheme.error + ) + } + } + HorizontalDivider() + } + } + } + } + + if (uiState.isAddDialogVisible) { + AddLocationDialog( + name = uiState.newLocationName, + onNameChange = viewModel::updateNewLocationName, + onConfirm = viewModel::addLocation, + onDismiss = viewModel::dismissAddDialog + ) + } + + if (uiState.isDeleteDialogVisible && uiState.locationToDelete != null) { + DeleteLocationDialog( + locationName = uiState.locationToDelete!!.name, + onConfirm = viewModel::deleteLocation, + onDismiss = viewModel::dismissDeleteDialog + ) + } +} + +@Composable +private fun AddLocationDialog( + name: String, + onNameChange: (String) -> Unit, + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Lagerort hinzufügen") }, + text = { + Column { + OutlinedTextField( + value = name, + onValueChange = onNameChange, + label = { Text("Name") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + } + }, + confirmButton = { + TextButton( + onClick = onConfirm, + enabled = name.isNotBlank() + ) { + Text("Hinzufügen") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Abbrechen") + } + } + ) +} + +@Composable +private fun DeleteLocationDialog( + locationName: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + 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!" + ) + }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text( + "Löschen", + color = MaterialTheme.colorScheme.error + ) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Abbrechen") + } + } + ) +} diff --git a/app/src/main/java/de/krisenvorrat/app/ui/location/LocationListViewModel.kt b/app/src/main/java/de/krisenvorrat/app/ui/location/LocationListViewModel.kt new file mode 100644 index 0000000..1f80d2e --- /dev/null +++ b/app/src/main/java/de/krisenvorrat/app/ui/location/LocationListViewModel.kt @@ -0,0 +1,83 @@ +package de.krisenvorrat.app.ui.location + +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.LocationRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +internal data class LocationListUiState( + val locations: List = emptyList(), + val isAddDialogVisible: Boolean = false, + val isDeleteDialogVisible: Boolean = false, + val locationToDelete: LocationEntity? = null, + val newLocationName: String = "" +) + +@HiltViewModel +internal class LocationListViewModel @Inject constructor( + private val repository: LocationRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(LocationListUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + viewModelScope.launch { + repository.getAll().collect { locations -> + _uiState.update { it.copy(locations = locations) } + } + } + } + + fun showAddDialog() { + _uiState.update { it.copy(isAddDialogVisible = true, newLocationName = "") } + } + + fun dismissAddDialog() { + _uiState.update { it.copy(isAddDialogVisible = false, newLocationName = "") } + } + + fun updateNewLocationName(name: String) { + _uiState.update { it.copy(newLocationName = name) } + } + + fun addLocation() { + val name = _uiState.value.newLocationName.trim() + if (name.isBlank()) return + viewModelScope.launch { + try { + repository.insert(LocationEntity(name = name)) + } catch (_: Exception) { + // Room-Fehler werden still behandelt + } + } + _uiState.update { it.copy(isAddDialogVisible = false, newLocationName = "") } + } + + fun showDeleteDialog(location: LocationEntity) { + _uiState.update { it.copy(isDeleteDialogVisible = true, locationToDelete = location) } + } + + fun dismissDeleteDialog() { + _uiState.update { it.copy(isDeleteDialogVisible = false, locationToDelete = null) } + } + + fun deleteLocation() { + val location = _uiState.value.locationToDelete ?: return + viewModelScope.launch { + try { + repository.delete(location) + } catch (_: Exception) { + // Room-Fehler werden still behandelt + } + } + _uiState.update { it.copy(isDeleteDialogVisible = false, locationToDelete = null) } + } +} diff --git a/app/src/test/java/de/krisenvorrat/app/ui/category/CategoryListViewModelTest.kt b/app/src/test/java/de/krisenvorrat/app/ui/category/CategoryListViewModelTest.kt new file mode 100644 index 0000000..16f0607 --- /dev/null +++ b/app/src/test/java/de/krisenvorrat/app/ui/category/CategoryListViewModelTest.kt @@ -0,0 +1,226 @@ +package de.krisenvorrat.app.ui.category + +import de.krisenvorrat.app.data.db.entity.CategoryEntity +import de.krisenvorrat.app.domain.repository.CategoryRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class CategoryListViewModelTest { + + private val testDispatcher = StandardTestDispatcher() + private lateinit var fakeRepository: FakeCategoryRepository + private lateinit var viewModel: CategoryListViewModel + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + fakeRepository = FakeCategoryRepository() + viewModel = CategoryListViewModel(fakeRepository) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun test_init_withEmptyRepository_categoriesListIsEmpty() = runTest(testDispatcher) { + // Given – fresh ViewModel + + // When + advanceUntilIdle() + + // Then + val state = viewModel.uiState.value + assertTrue(state.categories.isEmpty()) + } + + @Test + fun test_init_withExistingCategories_categoriesAreLoaded() = runTest(testDispatcher) { + // Given + fakeRepository.emit(listOf(CategoryEntity(id = 1, name = "Lebensmittel"))) + + // When + advanceUntilIdle() + + // Then + val state = viewModel.uiState.value + assertEquals(1, state.categories.size) + assertEquals("Lebensmittel", state.categories.first().name) + } + + @Test + fun test_showAddDialog_setsIsAddDialogVisibleToTrue() = runTest(testDispatcher) { + // Given + advanceUntilIdle() + + // When + viewModel.showAddDialog() + + // Then + assertTrue(viewModel.uiState.value.isAddDialogVisible) + assertEquals("", viewModel.uiState.value.newCategoryName) + } + + @Test + fun test_dismissAddDialog_setsIsAddDialogVisibleToFalse() = runTest(testDispatcher) { + // Given + viewModel.showAddDialog() + advanceUntilIdle() + + // When + viewModel.dismissAddDialog() + + // Then + assertFalse(viewModel.uiState.value.isAddDialogVisible) + } + + @Test + fun test_updateNewCategoryName_updatesStateCorrectly() = runTest(testDispatcher) { + // Given + viewModel.showAddDialog() + advanceUntilIdle() + + // When + viewModel.updateNewCategoryName("Hygiene") + + // Then + assertEquals("Hygiene", viewModel.uiState.value.newCategoryName) + } + + @Test + fun test_addCategory_withValidName_insertsAndClosesDialog() = runTest(testDispatcher) { + // Given + viewModel.showAddDialog() + viewModel.updateNewCategoryName("Hygiene") + advanceUntilIdle() + + // When + viewModel.addCategory() + advanceUntilIdle() + + // Then + assertFalse(viewModel.uiState.value.isAddDialogVisible) + assertEquals("", viewModel.uiState.value.newCategoryName) + assertEquals(1, fakeRepository.insertedCategories.size) + assertEquals("Hygiene", fakeRepository.insertedCategories.first().name) + } + + @Test + fun test_addCategory_withBlankName_doesNotInsert() = runTest(testDispatcher) { + // Given + viewModel.showAddDialog() + viewModel.updateNewCategoryName(" ") + advanceUntilIdle() + + // When + viewModel.addCategory() + advanceUntilIdle() + + // Then + assertTrue(fakeRepository.insertedCategories.isEmpty()) + } + + @Test + fun test_showDeleteDialog_setsDialogStateCorrectly() = runTest(testDispatcher) { + // Given + val category = CategoryEntity(id = 1, name = "Lebensmittel") + advanceUntilIdle() + + // When + viewModel.showDeleteDialog(category) + + // Then + val state = viewModel.uiState.value + assertTrue(state.isDeleteDialogVisible) + assertEquals(category, state.categoryToDelete) + } + + @Test + fun test_dismissDeleteDialog_clearsDialogState() = runTest(testDispatcher) { + // Given + viewModel.showDeleteDialog(CategoryEntity(id = 1, name = "Test")) + advanceUntilIdle() + + // When + viewModel.dismissDeleteDialog() + + // Then + val state = viewModel.uiState.value + assertFalse(state.isDeleteDialogVisible) + assertNull(state.categoryToDelete) + } + + @Test + fun test_deleteCategory_withSelectedCategory_deletesAndClosesDialog() = runTest(testDispatcher) { + // Given + val category = CategoryEntity(id = 1, name = "Lebensmittel") + viewModel.showDeleteDialog(category) + advanceUntilIdle() + + // When + viewModel.deleteCategory() + advanceUntilIdle() + + // Then + assertFalse(viewModel.uiState.value.isDeleteDialogVisible) + assertNull(viewModel.uiState.value.categoryToDelete) + assertEquals(1, fakeRepository.deletedCategories.size) + assertEquals(category, fakeRepository.deletedCategories.first()) + } + + @Test + fun test_deleteCategory_withNoSelection_doesNothing() = runTest(testDispatcher) { + // Given – no category selected + advanceUntilIdle() + + // When + viewModel.deleteCategory() + advanceUntilIdle() + + // Then + assertTrue(fakeRepository.deletedCategories.isEmpty()) + } +} + +private class FakeCategoryRepository : CategoryRepository { + private val flow = MutableStateFlow>(emptyList()) + val insertedCategories = mutableListOf() + val deletedCategories = mutableListOf() + + fun emit(categories: List) { + flow.value = categories + } + + override fun getAll(): Flow> = flow + + override suspend fun insert(category: CategoryEntity) { + insertedCategories.add(category) + flow.value = flow.value + category + } + + override suspend fun update(category: CategoryEntity) { + flow.value = flow.value.map { if (it.id == category.id) category else it } + } + + override suspend fun delete(category: CategoryEntity) { + deletedCategories.add(category) + flow.value = flow.value.filter { it.id != category.id } + } +} diff --git a/app/src/test/java/de/krisenvorrat/app/ui/location/LocationListViewModelTest.kt b/app/src/test/java/de/krisenvorrat/app/ui/location/LocationListViewModelTest.kt new file mode 100644 index 0000000..83ce44d --- /dev/null +++ b/app/src/test/java/de/krisenvorrat/app/ui/location/LocationListViewModelTest.kt @@ -0,0 +1,225 @@ +package de.krisenvorrat.app.ui.location + +import de.krisenvorrat.app.data.db.entity.LocationEntity +import de.krisenvorrat.app.domain.repository.LocationRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class LocationListViewModelTest { + + private val testDispatcher = StandardTestDispatcher() + private lateinit var fakeRepository: FakeLocationRepository + private lateinit var viewModel: LocationListViewModel + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + fakeRepository = FakeLocationRepository() + viewModel = LocationListViewModel(fakeRepository) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun test_init_withEmptyRepository_locationsListIsEmpty() = runTest(testDispatcher) { + // Given – fresh ViewModel + + // When + advanceUntilIdle() + + // Then + val state = viewModel.uiState.value + assertTrue(state.locations.isEmpty()) + } + + @Test + fun test_init_withExistingLocations_locationsAreLoaded() = runTest(testDispatcher) { + // Given + fakeRepository.emit(listOf(LocationEntity(id = 1, name = "Keller"))) + + // When + advanceUntilIdle() + + // Then + val state = viewModel.uiState.value + assertEquals(1, state.locations.size) + assertEquals("Keller", state.locations.first().name) + } + + @Test + fun test_showAddDialog_setsIsAddDialogVisibleToTrue() = runTest(testDispatcher) { + // Given + advanceUntilIdle() + + // When + viewModel.showAddDialog() + + // Then + assertTrue(viewModel.uiState.value.isAddDialogVisible) + assertEquals("", viewModel.uiState.value.newLocationName) + } + + @Test + fun test_dismissAddDialog_setsIsAddDialogVisibleToFalse() = runTest(testDispatcher) { + // Given + viewModel.showAddDialog() + advanceUntilIdle() + + // When + viewModel.dismissAddDialog() + + // Then + assertFalse(viewModel.uiState.value.isAddDialogVisible) + } + + @Test + fun test_updateNewLocationName_updatesStateCorrectly() = runTest(testDispatcher) { + // Given + viewModel.showAddDialog() + advanceUntilIdle() + + // When + viewModel.updateNewLocationName("Dachboden") + + // Then + assertEquals("Dachboden", viewModel.uiState.value.newLocationName) + } + + @Test + fun test_addLocation_withValidName_insertsAndClosesDialog() = runTest(testDispatcher) { + // Given + viewModel.showAddDialog() + viewModel.updateNewLocationName("Dachboden") + advanceUntilIdle() + + // When + viewModel.addLocation() + advanceUntilIdle() + + // Then + assertFalse(viewModel.uiState.value.isAddDialogVisible) + assertEquals("", viewModel.uiState.value.newLocationName) + assertEquals(1, fakeRepository.insertedLocations.size) + assertEquals("Dachboden", fakeRepository.insertedLocations.first().name) + } + + @Test + fun test_addLocation_withBlankName_doesNotInsert() = runTest(testDispatcher) { + // Given + viewModel.showAddDialog() + viewModel.updateNewLocationName(" ") + advanceUntilIdle() + + // When + viewModel.addLocation() + advanceUntilIdle() + + // Then + assertTrue(fakeRepository.insertedLocations.isEmpty()) + } + + @Test + fun test_showDeleteDialog_setsDialogStateCorrectly() = runTest(testDispatcher) { + // Given + val location = LocationEntity(id = 1, name = "Keller") + advanceUntilIdle() + + // When + viewModel.showDeleteDialog(location) + + // Then + val state = viewModel.uiState.value + assertTrue(state.isDeleteDialogVisible) + assertEquals(location, state.locationToDelete) + } + + @Test + fun test_dismissDeleteDialog_clearsDialogState() = runTest(testDispatcher) { + // Given + viewModel.showDeleteDialog(LocationEntity(id = 1, name = "Test")) + advanceUntilIdle() + + // When + viewModel.dismissDeleteDialog() + + // Then + val state = viewModel.uiState.value + assertFalse(state.isDeleteDialogVisible) + assertNull(state.locationToDelete) + } + + @Test + fun test_deleteLocation_withSelectedLocation_deletesAndClosesDialog() = runTest(testDispatcher) { + // Given + val location = LocationEntity(id = 1, name = "Keller") + viewModel.showDeleteDialog(location) + advanceUntilIdle() + + // When + viewModel.deleteLocation() + advanceUntilIdle() + + // Then + assertFalse(viewModel.uiState.value.isDeleteDialogVisible) + assertNull(viewModel.uiState.value.locationToDelete) + assertEquals(1, fakeRepository.deletedLocations.size) + assertEquals(location, fakeRepository.deletedLocations.first()) + } + + @Test + fun test_deleteLocation_withNoSelection_doesNothing() = runTest(testDispatcher) { + // Given – no location selected + advanceUntilIdle() + + // When + viewModel.deleteLocation() + advanceUntilIdle() + + // Then + assertTrue(fakeRepository.deletedLocations.isEmpty()) + } +} + +private class FakeLocationRepository : LocationRepository { + private val flow = MutableStateFlow>(emptyList()) + val insertedLocations = mutableListOf() + val deletedLocations = mutableListOf() + + fun emit(locations: List) { + flow.value = locations + } + + override fun getAll(): Flow> = flow + + override suspend fun insert(location: LocationEntity) { + insertedLocations.add(location) + flow.value = flow.value + location + } + + override suspend fun update(location: LocationEntity) { + flow.value = flow.value.map { if (it.id == location.id) location else it } + } + + override suspend fun delete(location: LocationEntity) { + deletedLocations.add(location) + flow.value = flow.value.filter { it.id != location.id } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e059a15..f574afc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,6 +14,7 @@ hiltNavigationCompose = "1.2.0" room = "2.6.1" navigationCompose = "2.8.5" kotlinxSerialization = "1.7.3" +kotlinxCoroutines = "1.9.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -21,6 +22,7 @@ junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } +androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } androidx-ui = { group = "androidx.compose.ui", name = "ui" } @@ -39,6 +41,7 @@ androidx-room-compiler = { group = "androidx.room", name = "room-compiler", vers androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" } androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" } +kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }