feat(item): add ItemListScreen with grouped display and delete function
ui/item/ItemUiModel.kt: - UI data class combining entity data with resolved category/location names - Computed properties isExpired and isExpiringSoon for MHD color coding ui/item/ItemListViewModel.kt: - Combines ItemRepository, CategoryRepository, LocationRepository via Flow.combine - Groups items by category name (sorted alphabetically) - Delete flow with confirmation dialog state management ui/item/ItemListScreen.kt: - LazyColumn with category headers and Material 3 Cards per item - Shows name, quantity+unit, location, and color-coded expiry date - Delete via IconButton with AlertDialog confirmation - Empty state when no items exist - FAB with onAddItem navigation callback ui/item/ItemListViewModelTest.kt: - 9 unit tests covering init, grouping, name resolution, delete dialog flow, and alphabetical sorting Closes #26
This commit is contained in:
parent
a27660fd4a
commit
a1cd7e5199
4 changed files with 699 additions and 0 deletions
236
app/src/main/java/de/krisenvorrat/app/ui/item/ItemListScreen.kt
Normal file
236
app/src/main/java/de/krisenvorrat/app/ui/item/ItemListScreen.kt
Normal file
|
|
@ -0,0 +1,236 @@
|
||||||
|
package de.krisenvorrat.app.ui.item
|
||||||
|
|
||||||
|
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.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
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.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FloatingActionButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
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
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
internal fun ItemListScreen(
|
||||||
|
onAddItem: () -> Unit,
|
||||||
|
viewModel: ItemListViewModel = hiltViewModel()
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Artikel") }
|
||||||
|
)
|
||||||
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
FloatingActionButton(onClick = onAddItem) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Add,
|
||||||
|
contentDescription = "Artikel hinzufügen"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { innerPadding ->
|
||||||
|
if (uiState.isEmpty) {
|
||||||
|
EmptyState(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ItemList(
|
||||||
|
groupedItems = uiState.groupedItems,
|
||||||
|
onDeleteClick = viewModel::showDeleteDialog,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uiState.isDeleteDialogVisible && uiState.itemToDelete != null) {
|
||||||
|
DeleteItemDialog(
|
||||||
|
itemName = uiState.itemToDelete?.name.orEmpty(),
|
||||||
|
onConfirm = viewModel::deleteItem,
|
||||||
|
onDismiss = viewModel::dismissDeleteDialog
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EmptyState(modifier: Modifier = Modifier) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier,
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text(
|
||||||
|
text = "Noch keine Artikel vorhanden",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "Tippen Sie auf +, um einen Artikel hinzuzufügen",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ItemList(
|
||||||
|
groupedItems: Map<String, List<ItemUiModel>>,
|
||||||
|
onDeleteClick: (ItemUiModel) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
groupedItems.forEach { (categoryName, items) ->
|
||||||
|
item(key = "header_$categoryName") {
|
||||||
|
CategoryHeader(categoryName = categoryName)
|
||||||
|
}
|
||||||
|
items(items, key = { it.id }) { item ->
|
||||||
|
ItemCard(
|
||||||
|
item = item,
|
||||||
|
onDeleteClick = { onDeleteClick(item) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CategoryHeader(categoryName: String) {
|
||||||
|
Text(
|
||||||
|
text = categoryName,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ItemCard(
|
||||||
|
item: ItemUiModel,
|
||||||
|
onDeleteClick: () -> Unit
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 2.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(12.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = item.name,
|
||||||
|
style = MaterialTheme.typography.bodyLarge
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = "${item.quantity} ${item.unit} · ${item.locationName}",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
if (item.expiryDate != null) {
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
ExpiryDateText(item = item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IconButton(onClick = onDeleteClick) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Delete,
|
||||||
|
contentDescription = "Artikel löschen",
|
||||||
|
tint = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ExpiryDateText(item: ItemUiModel) {
|
||||||
|
val formatter = DateTimeFormatter.ofPattern("dd.MM.yyyy")
|
||||||
|
val formattedDate = item.expiryDate?.format(formatter).orEmpty()
|
||||||
|
val color = when {
|
||||||
|
item.isExpired -> MaterialTheme.colorScheme.error
|
||||||
|
item.isExpiringSoon -> MaterialTheme.colorScheme.tertiary
|
||||||
|
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
}
|
||||||
|
val prefix = when {
|
||||||
|
item.isExpired -> "Abgelaufen: "
|
||||||
|
item.isExpiringSoon -> "MHD: "
|
||||||
|
else -> "MHD: "
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = "$prefix$formattedDate",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = color
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DeleteItemDialog(
|
||||||
|
itemName: String,
|
||||||
|
onConfirm: () -> Unit,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Artikel löschen?") },
|
||||||
|
text = {
|
||||||
|
Text("Möchten Sie den Artikel \"$itemName\" wirklich löschen?")
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onConfirm) {
|
||||||
|
Text(
|
||||||
|
"Löschen",
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text("Abbrechen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
package de.krisenvorrat.app.ui.item
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import de.krisenvorrat.app.data.db.entity.ItemEntity
|
||||||
|
import de.krisenvorrat.app.domain.repository.CategoryRepository
|
||||||
|
import de.krisenvorrat.app.domain.repository.ItemRepository
|
||||||
|
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.combine
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
internal data class ItemListUiState(
|
||||||
|
val groupedItems: Map<String, List<ItemUiModel>> = emptyMap(),
|
||||||
|
val isDeleteDialogVisible: Boolean = false,
|
||||||
|
val itemToDelete: ItemUiModel? = null
|
||||||
|
) {
|
||||||
|
val isEmpty: Boolean get() = groupedItems.isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
internal class ItemListViewModel @Inject constructor(
|
||||||
|
private val itemRepository: ItemRepository,
|
||||||
|
private val categoryRepository: CategoryRepository,
|
||||||
|
private val locationRepository: LocationRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(ItemListUiState())
|
||||||
|
val uiState: StateFlow<ItemListUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
combine(
|
||||||
|
itemRepository.getAll(),
|
||||||
|
categoryRepository.getAll(),
|
||||||
|
locationRepository.getAll()
|
||||||
|
) { items, categories, locations ->
|
||||||
|
val categoryMap = categories.associate { it.id to it.name }
|
||||||
|
val locationMap = locations.associate { it.id to it.name }
|
||||||
|
|
||||||
|
items.map { item ->
|
||||||
|
ItemUiModel(
|
||||||
|
id = item.id,
|
||||||
|
name = item.name,
|
||||||
|
categoryName = categoryMap[item.categoryId] ?: "Unbekannt",
|
||||||
|
quantity = item.quantity,
|
||||||
|
unit = item.unit,
|
||||||
|
locationName = locationMap[item.locationId] ?: "Unbekannt",
|
||||||
|
expiryDate = item.expiryDate,
|
||||||
|
categoryId = item.categoryId
|
||||||
|
)
|
||||||
|
}.groupBy { it.categoryName }
|
||||||
|
.toSortedMap()
|
||||||
|
}.collect { grouped ->
|
||||||
|
_uiState.update { it.copy(groupedItems = grouped) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showDeleteDialog(item: ItemUiModel) {
|
||||||
|
_uiState.update { it.copy(isDeleteDialogVisible = true, itemToDelete = item) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissDeleteDialog() {
|
||||||
|
_uiState.update { it.copy(isDeleteDialogVisible = false, itemToDelete = null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteItem() {
|
||||||
|
val item = _uiState.value.itemToDelete ?: return
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
itemRepository.delete(
|
||||||
|
ItemEntity(
|
||||||
|
id = item.id,
|
||||||
|
name = item.name,
|
||||||
|
categoryId = item.categoryId,
|
||||||
|
quantity = item.quantity,
|
||||||
|
unit = item.unit,
|
||||||
|
unitPrice = 0.0,
|
||||||
|
kcalPer100g = null,
|
||||||
|
expiryDate = item.expiryDate,
|
||||||
|
locationId = 0,
|
||||||
|
minStock = 0.0,
|
||||||
|
notes = "",
|
||||||
|
lastUpdated = 0L
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// Room-Fehler werden still behandelt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_uiState.update { it.copy(isDeleteDialogVisible = false, itemToDelete = null) }
|
||||||
|
}
|
||||||
|
}
|
||||||
27
app/src/main/java/de/krisenvorrat/app/ui/item/ItemUiModel.kt
Normal file
27
app/src/main/java/de/krisenvorrat/app/ui/item/ItemUiModel.kt
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
package de.krisenvorrat.app.ui.item
|
||||||
|
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
internal data class ItemUiModel(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val categoryName: String,
|
||||||
|
val quantity: Double,
|
||||||
|
val unit: String,
|
||||||
|
val locationName: String,
|
||||||
|
val expiryDate: LocalDate?,
|
||||||
|
val categoryId: Int
|
||||||
|
) {
|
||||||
|
|
||||||
|
val isExpired: Boolean
|
||||||
|
get() = expiryDate != null && expiryDate.isBefore(LocalDate.now())
|
||||||
|
|
||||||
|
val isExpiringSoon: Boolean
|
||||||
|
get() = expiryDate != null &&
|
||||||
|
!isExpired &&
|
||||||
|
expiryDate.isBefore(LocalDate.now().plusDays(EXPIRY_WARNING_DAYS))
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val EXPIRY_WARNING_DAYS = 30L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,337 @@
|
||||||
|
package de.krisenvorrat.app.ui.item
|
||||||
|
|
||||||
|
import de.krisenvorrat.app.data.db.entity.CategoryEntity
|
||||||
|
import de.krisenvorrat.app.data.db.entity.ItemEntity
|
||||||
|
import de.krisenvorrat.app.data.db.entity.LocationEntity
|
||||||
|
import de.krisenvorrat.app.domain.repository.CategoryRepository
|
||||||
|
import de.krisenvorrat.app.domain.repository.ItemRepository
|
||||||
|
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
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class ItemListViewModelTest {
|
||||||
|
|
||||||
|
private val testDispatcher = StandardTestDispatcher()
|
||||||
|
private lateinit var fakeItemRepository: FakeItemRepository
|
||||||
|
private lateinit var fakeCategoryRepository: FakeCategoryRepository
|
||||||
|
private lateinit var fakeLocationRepository: FakeLocationRepository
|
||||||
|
private lateinit var viewModel: ItemListViewModel
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setup() {
|
||||||
|
Dispatchers.setMain(testDispatcher)
|
||||||
|
fakeItemRepository = FakeItemRepository()
|
||||||
|
fakeCategoryRepository = FakeCategoryRepository()
|
||||||
|
fakeLocationRepository = FakeLocationRepository()
|
||||||
|
viewModel = ItemListViewModel(
|
||||||
|
itemRepository = fakeItemRepository,
|
||||||
|
categoryRepository = fakeCategoryRepository,
|
||||||
|
locationRepository = fakeLocationRepository
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
Dispatchers.resetMain()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun test_init_withNoItems_stateIsEmpty() = runTest(testDispatcher) {
|
||||||
|
// Given – empty repositories
|
||||||
|
|
||||||
|
// When
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
// Then
|
||||||
|
val state = viewModel.uiState.value
|
||||||
|
assertTrue(state.isEmpty)
|
||||||
|
assertTrue(state.groupedItems.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun test_init_withItems_itemsAreGroupedByCategory() = runTest(testDispatcher) {
|
||||||
|
// Given
|
||||||
|
fakeCategoryRepository.emit(
|
||||||
|
listOf(
|
||||||
|
CategoryEntity(id = 1, name = "Lebensmittel"),
|
||||||
|
CategoryEntity(id = 2, name = "Hygiene")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
fakeLocationRepository.emit(listOf(LocationEntity(id = 1, name = "Keller")))
|
||||||
|
fakeItemRepository.emit(
|
||||||
|
listOf(
|
||||||
|
buildItemEntity(id = "a", name = "Konserve", categoryId = 1),
|
||||||
|
buildItemEntity(id = "b", name = "Seife", categoryId = 2),
|
||||||
|
buildItemEntity(id = "c", name = "Reis", categoryId = 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// When
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
// Then
|
||||||
|
val state = viewModel.uiState.value
|
||||||
|
assertFalse(state.isEmpty)
|
||||||
|
assertEquals(2, state.groupedItems.size)
|
||||||
|
assertEquals(2, state.groupedItems["Lebensmittel"]?.size)
|
||||||
|
assertEquals(1, state.groupedItems["Hygiene"]?.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun test_init_withItems_categoryAndLocationNamesAreResolved() = runTest(testDispatcher) {
|
||||||
|
// Given
|
||||||
|
fakeCategoryRepository.emit(listOf(CategoryEntity(id = 1, name = "Lebensmittel")))
|
||||||
|
fakeLocationRepository.emit(listOf(LocationEntity(id = 1, name = "Keller")))
|
||||||
|
fakeItemRepository.emit(
|
||||||
|
listOf(buildItemEntity(id = "a", name = "Konserve", categoryId = 1, locationId = 1))
|
||||||
|
)
|
||||||
|
|
||||||
|
// When
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
// Then
|
||||||
|
val item = viewModel.uiState.value.groupedItems["Lebensmittel"]?.first()
|
||||||
|
assertEquals("Lebensmittel", item?.categoryName)
|
||||||
|
assertEquals("Keller", item?.locationName)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun test_init_withUnknownCategoryId_showsUnbekannt() = runTest(testDispatcher) {
|
||||||
|
// Given
|
||||||
|
fakeCategoryRepository.emit(emptyList())
|
||||||
|
fakeLocationRepository.emit(emptyList())
|
||||||
|
fakeItemRepository.emit(listOf(buildItemEntity(id = "a", categoryId = 99, locationId = 99)))
|
||||||
|
|
||||||
|
// When
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
// Then
|
||||||
|
val item = viewModel.uiState.value.groupedItems["Unbekannt"]?.first()
|
||||||
|
assertEquals("Unbekannt", item?.categoryName)
|
||||||
|
assertEquals("Unbekannt", item?.locationName)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun test_showDeleteDialog_setsDialogStateCorrectly() = runTest(testDispatcher) {
|
||||||
|
// Given
|
||||||
|
advanceUntilIdle()
|
||||||
|
val itemUi = buildItemUiModel(id = "a", name = "Konserve")
|
||||||
|
|
||||||
|
// When
|
||||||
|
viewModel.showDeleteDialog(itemUi)
|
||||||
|
|
||||||
|
// Then
|
||||||
|
val state = viewModel.uiState.value
|
||||||
|
assertTrue(state.isDeleteDialogVisible)
|
||||||
|
assertEquals(itemUi, state.itemToDelete)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun test_dismissDeleteDialog_clearsDialogState() = runTest(testDispatcher) {
|
||||||
|
// Given
|
||||||
|
viewModel.showDeleteDialog(buildItemUiModel(id = "a"))
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
// When
|
||||||
|
viewModel.dismissDeleteDialog()
|
||||||
|
|
||||||
|
// Then
|
||||||
|
val state = viewModel.uiState.value
|
||||||
|
assertFalse(state.isDeleteDialogVisible)
|
||||||
|
assertNull(state.itemToDelete)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun test_deleteItem_withSelectedItem_deletesAndClosesDialog() = runTest(testDispatcher) {
|
||||||
|
// Given
|
||||||
|
val itemUi = buildItemUiModel(id = "del1", name = "Konserve")
|
||||||
|
viewModel.showDeleteDialog(itemUi)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
// When
|
||||||
|
viewModel.deleteItem()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertFalse(viewModel.uiState.value.isDeleteDialogVisible)
|
||||||
|
assertNull(viewModel.uiState.value.itemToDelete)
|
||||||
|
assertEquals(1, fakeItemRepository.deletedItems.size)
|
||||||
|
assertEquals("del1", fakeItemRepository.deletedItems.first().id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun test_deleteItem_withNoSelection_doesNothing() = runTest(testDispatcher) {
|
||||||
|
// Given – no item selected
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
// When
|
||||||
|
viewModel.deleteItem()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertTrue(fakeItemRepository.deletedItems.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun test_init_groupsAreSortedAlphabetically() = runTest(testDispatcher) {
|
||||||
|
// Given
|
||||||
|
fakeCategoryRepository.emit(
|
||||||
|
listOf(
|
||||||
|
CategoryEntity(id = 1, name = "Wasser"),
|
||||||
|
CategoryEntity(id = 2, name = "Arzneimittel"),
|
||||||
|
CategoryEntity(id = 3, name = "Lebensmittel")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
fakeLocationRepository.emit(listOf(LocationEntity(id = 1, name = "Keller")))
|
||||||
|
fakeItemRepository.emit(
|
||||||
|
listOf(
|
||||||
|
buildItemEntity(id = "a", categoryId = 1),
|
||||||
|
buildItemEntity(id = "b", categoryId = 2),
|
||||||
|
buildItemEntity(id = "c", categoryId = 3)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// When
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
// Then
|
||||||
|
val keys = viewModel.uiState.value.groupedItems.keys.toList()
|
||||||
|
assertEquals(listOf("Arzneimittel", "Lebensmittel", "Wasser"), keys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// region Test Helpers
|
||||||
|
|
||||||
|
private fun buildItemEntity(
|
||||||
|
id: String = "id1",
|
||||||
|
name: String = "Konserve",
|
||||||
|
categoryId: Int = 1,
|
||||||
|
locationId: Int = 1
|
||||||
|
) = ItemEntity(
|
||||||
|
id = id,
|
||||||
|
name = name,
|
||||||
|
categoryId = categoryId,
|
||||||
|
quantity = 2.0,
|
||||||
|
unit = "Stk",
|
||||||
|
unitPrice = 1.5,
|
||||||
|
kcalPer100g = null,
|
||||||
|
expiryDate = null,
|
||||||
|
locationId = locationId,
|
||||||
|
minStock = 1.0,
|
||||||
|
notes = "",
|
||||||
|
lastUpdated = 0L
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun buildItemUiModel(
|
||||||
|
id: String = "id1",
|
||||||
|
name: String = "Konserve",
|
||||||
|
categoryName: String = "Lebensmittel",
|
||||||
|
locationName: String = "Keller"
|
||||||
|
) = ItemUiModel(
|
||||||
|
id = id,
|
||||||
|
name = name,
|
||||||
|
categoryName = categoryName,
|
||||||
|
quantity = 2.0,
|
||||||
|
unit = "Stk",
|
||||||
|
locationName = locationName,
|
||||||
|
expiryDate = null,
|
||||||
|
categoryId = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
private class FakeItemRepository : ItemRepository {
|
||||||
|
private val flow = MutableStateFlow<List<ItemEntity>>(emptyList())
|
||||||
|
val deletedItems = mutableListOf<ItemEntity>()
|
||||||
|
|
||||||
|
fun emit(items: List<ItemEntity>) {
|
||||||
|
flow.value = items
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getAll(): Flow<List<ItemEntity>> = flow
|
||||||
|
|
||||||
|
override suspend fun getById(id: String): ItemEntity? =
|
||||||
|
flow.value.find { it.id == id }
|
||||||
|
|
||||||
|
override suspend fun insert(item: ItemEntity) {
|
||||||
|
flow.value = flow.value + item
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun update(item: ItemEntity) {
|
||||||
|
flow.value = flow.value.map { if (it.id == item.id) item else it }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun delete(item: ItemEntity) {
|
||||||
|
deletedItems.add(item)
|
||||||
|
flow.value = flow.value.filter { it.id != item.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getByCategory(categoryId: Int): Flow<List<ItemEntity>> =
|
||||||
|
MutableStateFlow(flow.value.filter { it.categoryId == categoryId })
|
||||||
|
|
||||||
|
override fun getByLocation(locationId: Int): Flow<List<ItemEntity>> =
|
||||||
|
MutableStateFlow(flow.value.filter { it.locationId == locationId })
|
||||||
|
|
||||||
|
override fun getExpiringSoon(daysUntil: Int): Flow<List<ItemEntity>> =
|
||||||
|
MutableStateFlow(emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeCategoryRepository : CategoryRepository {
|
||||||
|
private val flow = MutableStateFlow<List<CategoryEntity>>(emptyList())
|
||||||
|
|
||||||
|
fun emit(categories: List<CategoryEntity>) {
|
||||||
|
flow.value = categories
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getAll(): Flow<List<CategoryEntity>> = flow
|
||||||
|
|
||||||
|
override suspend fun insert(category: CategoryEntity) {
|
||||||
|
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) {
|
||||||
|
flow.value = flow.value.filter { it.id != category.id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeLocationRepository : LocationRepository {
|
||||||
|
private val flow = MutableStateFlow<List<LocationEntity>>(emptyList())
|
||||||
|
|
||||||
|
fun emit(locations: List<LocationEntity>) {
|
||||||
|
flow.value = locations
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getAll(): Flow<List<LocationEntity>> = flow
|
||||||
|
|
||||||
|
override suspend fun insert(location: LocationEntity) {
|
||||||
|
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) {
|
||||||
|
flow.value = flow.value.filter { it.id != location.id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// endregion
|
||||||
Loading…
Reference in a new issue