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
This commit is contained in:
parent
0c1e06afca
commit
a27660fd4a
9 changed files with 1016 additions and 0 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -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<CategoryEntity> = 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<CategoryListUiState> = _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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -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<LocationEntity> = 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<LocationListUiState> = _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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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<List<CategoryEntity>>(emptyList())
|
||||
val insertedCategories = mutableListOf<CategoryEntity>()
|
||||
val deletedCategories = mutableListOf<CategoryEntity>()
|
||||
|
||||
fun emit(categories: List<CategoryEntity>) {
|
||||
flow.value = categories
|
||||
}
|
||||
|
||||
override fun getAll(): Flow<List<CategoryEntity>> = 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 }
|
||||
}
|
||||
}
|
||||
|
|
@ -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<List<LocationEntity>>(emptyList())
|
||||
val insertedLocations = mutableListOf<LocationEntity>()
|
||||
val deletedLocations = mutableListOf<LocationEntity>()
|
||||
|
||||
fun emit(locations: List<LocationEntity>) {
|
||||
flow.value = locations
|
||||
}
|
||||
|
||||
override fun getAll(): Flow<List<LocationEntity>> = 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 }
|
||||
}
|
||||
}
|
||||
|
|
@ -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" }
|
||||
|
|
|
|||
Loading…
Reference in a new issue