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