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:
Jens Reinemann 2026-05-14 01:03:37 +02:00
parent a27660fd4a
commit a1cd7e5199
4 changed files with 699 additions and 0 deletions

View 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")
}
}
)
}

View file

@ -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) }
}
}

View 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
}
}

View file

@ -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