From ad0945ec3c826fe6b1ae03644ec5e04e17b8b6fb Mon Sep 17 00:00:00 2001 From: Jens Reinemann Date: Mon, 18 May 2026 12:24:41 +0200 Subject: [PATCH] feat(ui): replace filter chips with BottomSheet + sort options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Filter button with badge next to search bar - BottomSheet with filter dropdowns (Kategorie, Lagerort, Ablauf) - Sort options: Name, Ablaufdatum, Menge (asc/desc) - 'Alle zurücksetzen' button to clear filters + sort - docs(genome): Konzept nach .github/genome/ verschoben --- .../genome/Concept Genome Engine.md | 0 .github/prompts/genome.prompt.md | 2 +- .github/skills/genome/SKILL.md | 2 +- .../de/bollwerk/app/ui/item/ItemListScreen.kt | 294 ++++++++++++------ .../bollwerk/app/ui/item/ItemListViewModel.kt | 43 ++- 5 files changed, 240 insertions(+), 101 deletions(-) rename docs/genome-engine.md => .github/genome/Concept Genome Engine.md (100%) diff --git a/docs/genome-engine.md b/.github/genome/Concept Genome Engine.md similarity index 100% rename from docs/genome-engine.md rename to .github/genome/Concept Genome Engine.md diff --git a/.github/prompts/genome.prompt.md b/.github/prompts/genome.prompt.md index c064149..8322e34 100644 --- a/.github/prompts/genome.prompt.md +++ b/.github/prompts/genome.prompt.md @@ -6,7 +6,7 @@ tools: [read, edit, search, execute] # Genome Engine -> **Konzept-Dokument:** `docs/genome-engine.md` · **Skill-Doku:** `.github/skills/genome/SKILL.md` +> **Konzept-Dokument:** `.github/genome/Concept Genome Engine.md` · **Skill-Doku:** `.github/skills/genome/SKILL.md` Du orchestrierst die 3 Phasen der Genome Engine: **Extraction → Distillation → Propagation**. diff --git a/.github/skills/genome/SKILL.md b/.github/skills/genome/SKILL.md index 84ffec9..29b726f 100644 --- a/.github/skills/genome/SKILL.md +++ b/.github/skills/genome/SKILL.md @@ -43,7 +43,7 @@ Der Router-Prompt fragt nach Quell-Repo und Zeitspanne, dann orchestriert er all | `genome.prompt.md` | `.github/prompts/` | Orchestrator (Router für alle 3 Phasen) | | `genome-distill.prompt.md` | `.github/prompts/` | Phase 2: Klassifizierung + Scoring | | `genome-propagate.prompt.md` | `.github/prompts/` | Phase 3: Patch-Generierung für Ziel | -| `genome-engine.md` | `docs/` | Vollständiges Konzept-Dokument | +| `Concept Genome Engine.md` | `.github/genome/` | Vollständiges Konzept-Dokument | ## Trait-Erkennung diff --git a/app/src/main/java/de/bollwerk/app/ui/item/ItemListScreen.kt b/app/src/main/java/de/bollwerk/app/ui/item/ItemListScreen.kt index 06cf74e..5b2ed46 100644 --- a/app/src/main/java/de/bollwerk/app/ui/item/ItemListScreen.kt +++ b/app/src/main/java/de/bollwerk/app/ui/item/ItemListScreen.kt @@ -1,7 +1,6 @@ package de.bollwerk.app.ui.item import androidx.compose.foundation.clickable -import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -11,33 +10,43 @@ 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.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.FilterList import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.PhotoCamera import androidx.compose.material.icons.filled.Search import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Badge +import androidx.compose.material3.BadgedBox import androidx.compose.material3.Card import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilterChip +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults 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.MenuAnchorType +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -45,6 +54,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -63,6 +73,7 @@ internal fun ItemListScreen( ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() var isMenuExpanded by remember { mutableStateOf(false) } + var isBottomSheetVisible by remember { mutableStateOf(false) } Scaffold( topBar = { @@ -119,17 +130,12 @@ internal fun ItemListScreen( .fillMaxSize() .padding(innerPadding) ) { - ItemSearchBar( + SearchBarWithFilterButton( query = uiState.searchQuery, onQueryChanged = viewModel::onSearchQueryChanged, - onClearClick = viewModel::clearSearch - ) - - FilterChipRow( - uiState = uiState, - onCategorySelected = viewModel::onCategoryFilterChanged, - onLocationSelected = viewModel::onLocationFilterChanged, - onExpirySelected = viewModel::onExpiryFilterChanged + onClearClick = viewModel::clearSearch, + activeFilterCount = uiState.activeFilterSortCount, + onFilterClick = { isBottomSheetVisible = true } ) when { @@ -162,6 +168,18 @@ internal fun ItemListScreen( onDismiss = viewModel::dismissDeleteDialog ) } + + if (isBottomSheetVisible) { + FilterSortBottomSheet( + uiState = uiState, + onCategorySelected = viewModel::onCategoryFilterChanged, + onLocationSelected = viewModel::onLocationFilterChanged, + onExpirySelected = viewModel::onExpiryFilterChanged, + onSortSelected = viewModel::onSortOptionChanged, + onClearAll = viewModel::clearAllFilters, + onDismiss = { isBottomSheetVisible = false } + ) + } } @Composable @@ -288,80 +306,165 @@ private fun ExpiryDateText(item: ItemUiModel) { } @Composable -private fun ItemSearchBar( +private fun SearchBarWithFilterButton( query: String, onQueryChanged: (String) -> Unit, onClearClick: () -> Unit, - modifier: Modifier = Modifier -) { - OutlinedTextField( - value = query, - onValueChange = onQueryChanged, - modifier = modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - placeholder = { Text("Suchen...") }, - leadingIcon = { - Icon(Icons.Default.Search, contentDescription = "Suchen") - }, - trailingIcon = { - if (query.isNotEmpty()) { - IconButton(onClick = onClearClick) { - Icon(Icons.Default.Close, contentDescription = "Suche löschen") - } - } - }, - singleLine = true - ) -} - -@Composable -private fun FilterChipRow( - uiState: ItemListUiState, - onCategorySelected: (Int?) -> Unit, - onLocationSelected: (Int?) -> Unit, - onExpirySelected: (ExpiryFilter?) -> Unit, + activeFilterCount: Int, + onFilterClick: () -> Unit, modifier: Modifier = Modifier ) { Row( modifier = modifier .fillMaxWidth() - .horizontalScroll(rememberScrollState()) - .padding(horizontal = 16.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) + .padding(start = 16.dp, end = 8.dp, top = 8.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically ) { - FilterDropdownChip( - label = "Kategorie", - selectedLabel = uiState.availableCategories - .find { it.first == uiState.selectedCategoryId }?.second, - options = uiState.availableCategories, - onOptionSelected = { onCategorySelected(it) }, - onClear = { onCategorySelected(null) } - ) - - FilterDropdownChip( - label = "Lagerort", - selectedLabel = uiState.availableLocations - .find { it.first == uiState.selectedLocationId }?.second, - options = uiState.availableLocations, - onOptionSelected = { onLocationSelected(it) }, - onClear = { onLocationSelected(null) } - ) - - FilterDropdownChip( - label = "Ablauf", - selectedLabel = uiState.selectedExpiryFilter?.label, - options = ExpiryFilter.entries.map { it.ordinal to it.label }, - onOptionSelected = { ordinal -> - onExpirySelected(ExpiryFilter.entries[ordinal]) + OutlinedTextField( + value = query, + onValueChange = onQueryChanged, + modifier = Modifier.weight(1f), + placeholder = { Text("Suchen...") }, + leadingIcon = { + Icon(Icons.Default.Search, contentDescription = "Suchen") }, - onClear = { onExpirySelected(null) } + trailingIcon = { + if (query.isNotEmpty()) { + IconButton(onClick = onClearClick) { + Icon(Icons.Default.Close, contentDescription = "Suche löschen") + } + } + }, + singleLine = true ) + Spacer(modifier = Modifier.width(4.dp)) + BadgedBox( + badge = { + if (activeFilterCount > 0) { + Badge { Text("$activeFilterCount") } + } + } + ) { + IconButton(onClick = onFilterClick) { + Icon( + imageVector = Icons.Default.FilterList, + contentDescription = "Filter & Sortierung" + ) + } + } } } +@OptIn(ExperimentalMaterial3Api::class) @Composable -private fun FilterDropdownChip( +private fun FilterSortBottomSheet( + uiState: ItemListUiState, + onCategorySelected: (Int?) -> Unit, + onLocationSelected: (Int?) -> Unit, + onExpirySelected: (ExpiryFilter?) -> Unit, + onSortSelected: (SortOption) -> Unit, + onClearAll: () -> Unit, + onDismiss: () -> Unit +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp) + .padding(bottom = 32.dp) + ) { + Text( + text = "Filter", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(bottom = 12.dp) + ) + + FilterDropdown( + label = "Kategorie", + selectedLabel = uiState.availableCategories + .find { it.first == uiState.selectedCategoryId }?.second, + options = uiState.availableCategories, + onOptionSelected = { onCategorySelected(it) }, + onClear = { onCategorySelected(null) } + ) + + Spacer(modifier = Modifier.height(8.dp)) + + FilterDropdown( + label = "Lagerort", + selectedLabel = uiState.availableLocations + .find { it.first == uiState.selectedLocationId }?.second, + options = uiState.availableLocations, + onOptionSelected = { onLocationSelected(it) }, + onClear = { onLocationSelected(null) } + ) + + Spacer(modifier = Modifier.height(8.dp)) + + FilterDropdown( + label = "Ablauf", + selectedLabel = uiState.selectedExpiryFilter?.label, + options = ExpiryFilter.entries.map { it.ordinal to it.label }, + onOptionSelected = { ordinal -> + onExpirySelected(ExpiryFilter.entries[ordinal]) + }, + onClear = { onExpirySelected(null) } + ) + + HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp)) + + Text( + text = "Sortierung", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(bottom = 8.dp) + ) + + SortOption.entries.forEach { option -> + Row( + modifier = Modifier + .fillMaxWidth() + .selectable( + selected = uiState.selectedSortOption == option, + onClick = { onSortSelected(option) }, + role = Role.RadioButton + ) + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = uiState.selectedSortOption == option, + onClick = null + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = option.label, + style = MaterialTheme.typography.bodyLarge + ) + } + } + + if (uiState.activeFilterSortCount > 0) { + Spacer(modifier = Modifier.height(16.dp)) + TextButton( + onClick = onClearAll, + modifier = Modifier.fillMaxWidth() + ) { + Text("Alle zurücksetzen") + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun FilterDropdown( label: String, selectedLabel: String?, options: List>, @@ -369,36 +472,33 @@ private fun FilterDropdownChip( onClear: () -> Unit ) { var expanded by remember { mutableStateOf(false) } - val isSelected = selectedLabel != null + val displayValue = selectedLabel ?: "Alle" - Box { - FilterChip( - selected = isSelected, - onClick = { expanded = true }, - label = { - Text(selectedLabel ?: label) - }, - trailingIcon = { - Icon( - imageVector = Icons.Default.ArrowDropDown, - contentDescription = null, - modifier = Modifier.size(18.dp) - ) - } + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it } + ) { + OutlinedTextField( + value = displayValue, + onValueChange = {}, + readOnly = true, + label = { Text(label) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(MenuAnchorType.PrimaryNotEditable) ) - DropdownMenu( + ExposedDropdownMenu( expanded = expanded, onDismissRequest = { expanded = false } ) { - if (isSelected) { - DropdownMenuItem( - text = { Text("Alle") }, - onClick = { - expanded = false - onClear() - } - ) - } + DropdownMenuItem( + text = { Text("Alle") }, + onClick = { + expanded = false + onClear() + } + ) options.forEach { (id, name) -> DropdownMenuItem( text = { Text(name) }, diff --git a/app/src/main/java/de/bollwerk/app/ui/item/ItemListViewModel.kt b/app/src/main/java/de/bollwerk/app/ui/item/ItemListViewModel.kt index 82b1816..349eca9 100644 --- a/app/src/main/java/de/bollwerk/app/ui/item/ItemListViewModel.kt +++ b/app/src/main/java/de/bollwerk/app/ui/item/ItemListViewModel.kt @@ -22,11 +22,21 @@ internal enum class ExpiryFilter(val label: String) { OK("OK") } +internal enum class SortOption(val label: String) { + NAME_ASC("Name (A–Z)"), + NAME_DESC("Name (Z–A)"), + EXPIRY_ASC("Ablauf (nächstes zuerst)"), + EXPIRY_DESC("Ablauf (spätestes zuerst)"), + QUANTITY_ASC("Menge (aufsteigend)"), + QUANTITY_DESC("Menge (absteigend)") +} + private data class FilterState( val searchQuery: String = "", val categoryId: Int? = null, val locationId: Int? = null, - val expiryFilter: ExpiryFilter? = null + val expiryFilter: ExpiryFilter? = null, + val sortOption: SortOption = SortOption.NAME_ASC ) internal data class ItemListUiState( @@ -36,6 +46,7 @@ internal data class ItemListUiState( val selectedCategoryId: Int? = null, val selectedLocationId: Int? = null, val selectedExpiryFilter: ExpiryFilter? = null, + val selectedSortOption: SortOption = SortOption.NAME_ASC, val availableCategories: List> = emptyList(), val availableLocations: List> = emptyList(), val isDeleteDialogVisible: Boolean = false, @@ -49,6 +60,9 @@ internal data class ItemListUiState( selectedExpiryFilter != null val hasNoFilterResults: Boolean get() = isFiltering && groupedItems.isEmpty() && !isEmpty + val activeFilterSortCount: Int + get() = listOfNotNull(selectedCategoryId, selectedLocationId, selectedExpiryFilter).size + + if (selectedSortOption != SortOption.NAME_ASC) 1 else 0 } @HiltViewModel @@ -114,7 +128,16 @@ internal class ItemListViewModel @Inject constructor( } } - val grouped = filteredItems + val sortedItems = when (filters.sortOption) { + SortOption.NAME_ASC -> filteredItems.sortedBy { it.name.lowercase() } + SortOption.NAME_DESC -> filteredItems.sortedByDescending { it.name.lowercase() } + SortOption.EXPIRY_ASC -> filteredItems.sortedWith(compareBy(nullsLast()) { it.expiryDate }) + SortOption.EXPIRY_DESC -> filteredItems.sortedWith(compareByDescending(nullsFirst()) { it.expiryDate }) + SortOption.QUANTITY_ASC -> filteredItems.sortedBy { it.quantity } + SortOption.QUANTITY_DESC -> filteredItems.sortedByDescending { it.quantity } + } + + val grouped = sortedItems .groupBy { it.categoryName } .toSortedMap() @@ -125,6 +148,7 @@ internal class ItemListViewModel @Inject constructor( selectedCategoryId = filters.categoryId, selectedLocationId = filters.locationId, selectedExpiryFilter = filters.expiryFilter, + selectedSortOption = filters.sortOption, availableCategories = categories.map { it.id to it.name } .sortedBy { it.second }, availableLocations = locations.map { it.id to it.name } @@ -157,6 +181,21 @@ internal class ItemListViewModel @Inject constructor( _filterState.update { it.copy(expiryFilter = expiryFilter) } } + fun onSortOptionChanged(sortOption: SortOption) { + _filterState.update { it.copy(sortOption = sortOption) } + } + + fun clearAllFilters() { + _filterState.update { + it.copy( + categoryId = null, + locationId = null, + expiryFilter = null, + sortOption = SortOption.NAME_ASC + ) + } + } + fun clearSearch() { _filterState.update { it.copy(searchQuery = "") } }