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 {
|
dependencies {
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
implementation(platform(libs.androidx.compose.bom))
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
implementation(libs.androidx.ui)
|
implementation(libs.androidx.ui)
|
||||||
|
|
@ -70,6 +71,7 @@ dependencies {
|
||||||
implementation(libs.kotlinx.serialization.json)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
|
testImplementation(libs.kotlinx.coroutines.test)
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
androidTestImplementation(libs.androidx.room.testing)
|
androidTestImplementation(libs.androidx.room.testing)
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
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"
|
room = "2.6.1"
|
||||||
navigationCompose = "2.8.5"
|
navigationCompose = "2.8.5"
|
||||||
kotlinxSerialization = "1.7.3"
|
kotlinxSerialization = "1.7.3"
|
||||||
|
kotlinxCoroutines = "1.9.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
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-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
||||||
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
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-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-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||||
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||||
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
|
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" }
|
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" }
|
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" }
|
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]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue