diff --git a/app/src/main/java/de/krisenvorrat/app/ui/item/ItemListScreen.kt b/app/src/main/java/de/krisenvorrat/app/ui/item/ItemListScreen.kt new file mode 100644 index 0000000..13ec00c --- /dev/null +++ b/app/src/main/java/de/krisenvorrat/app/ui/item/ItemListScreen.kt @@ -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>, + 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") + } + } + ) +} diff --git a/app/src/main/java/de/krisenvorrat/app/ui/item/ItemListViewModel.kt b/app/src/main/java/de/krisenvorrat/app/ui/item/ItemListViewModel.kt new file mode 100644 index 0000000..e9bf7ac --- /dev/null +++ b/app/src/main/java/de/krisenvorrat/app/ui/item/ItemListViewModel.kt @@ -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> = 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 = _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) } + } +} diff --git a/app/src/main/java/de/krisenvorrat/app/ui/item/ItemUiModel.kt b/app/src/main/java/de/krisenvorrat/app/ui/item/ItemUiModel.kt new file mode 100644 index 0000000..51c0d20 --- /dev/null +++ b/app/src/main/java/de/krisenvorrat/app/ui/item/ItemUiModel.kt @@ -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 + } +} diff --git a/app/src/test/java/de/krisenvorrat/app/ui/item/ItemListViewModelTest.kt b/app/src/test/java/de/krisenvorrat/app/ui/item/ItemListViewModelTest.kt new file mode 100644 index 0000000..1163353 --- /dev/null +++ b/app/src/test/java/de/krisenvorrat/app/ui/item/ItemListViewModelTest.kt @@ -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>(emptyList()) + val deletedItems = mutableListOf() + + fun emit(items: List) { + flow.value = items + } + + override fun getAll(): Flow> = 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> = + MutableStateFlow(flow.value.filter { it.categoryId == categoryId }) + + override fun getByLocation(locationId: Int): Flow> = + MutableStateFlow(flow.value.filter { it.locationId == locationId }) + + override fun getExpiringSoon(daysUntil: Int): Flow> = + MutableStateFlow(emptyList()) +} + +private class FakeCategoryRepository : CategoryRepository { + private val flow = MutableStateFlow>(emptyList()) + + fun emit(categories: List) { + flow.value = categories + } + + override fun getAll(): Flow> = 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>(emptyList()) + + fun emit(locations: List) { + flow.value = locations + } + + override fun getAll(): Flow> = 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