feat(ui): replace filter chips with BottomSheet + sort options

- 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
This commit is contained in:
Jens Reinemann 2026-05-18 12:24:41 +02:00
parent 6a3009569b
commit ad0945ec3c
5 changed files with 240 additions and 101 deletions

View file

@ -6,7 +6,7 @@ tools: [read, edit, search, execute]
# Genome Engine # 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**. Du orchestrierst die 3 Phasen der Genome Engine: **Extraction → Distillation → Propagation**.

View file

@ -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.prompt.md` | `.github/prompts/` | Orchestrator (Router für alle 3 Phasen) |
| `genome-distill.prompt.md` | `.github/prompts/` | Phase 2: Klassifizierung + Scoring | | `genome-distill.prompt.md` | `.github/prompts/` | Phase 2: Klassifizierung + Scoring |
| `genome-propagate.prompt.md` | `.github/prompts/` | Phase 3: Patch-Generierung für Ziel | | `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 ## Trait-Erkennung

View file

@ -1,7 +1,6 @@
package de.bollwerk.app.ui.item package de.bollwerk.app.ui.item
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState 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.Icons
import androidx.compose.material.icons.filled.Add 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.Close
import androidx.compose.material.icons.filled.Delete 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.MoreVert
import androidx.compose.material.icons.filled.PhotoCamera import androidx.compose.material.icons.filled.PhotoCamera
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api 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.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -45,6 +54,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@ -63,6 +73,7 @@ internal fun ItemListScreen(
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var isMenuExpanded by remember { mutableStateOf(false) } var isMenuExpanded by remember { mutableStateOf(false) }
var isBottomSheetVisible by remember { mutableStateOf(false) }
Scaffold( Scaffold(
topBar = { topBar = {
@ -119,17 +130,12 @@ internal fun ItemListScreen(
.fillMaxSize() .fillMaxSize()
.padding(innerPadding) .padding(innerPadding)
) { ) {
ItemSearchBar( SearchBarWithFilterButton(
query = uiState.searchQuery, query = uiState.searchQuery,
onQueryChanged = viewModel::onSearchQueryChanged, onQueryChanged = viewModel::onSearchQueryChanged,
onClearClick = viewModel::clearSearch onClearClick = viewModel::clearSearch,
) activeFilterCount = uiState.activeFilterSortCount,
onFilterClick = { isBottomSheetVisible = true }
FilterChipRow(
uiState = uiState,
onCategorySelected = viewModel::onCategoryFilterChanged,
onLocationSelected = viewModel::onLocationFilterChanged,
onExpirySelected = viewModel::onExpiryFilterChanged
) )
when { when {
@ -162,6 +168,18 @@ internal fun ItemListScreen(
onDismiss = viewModel::dismissDeleteDialog 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 @Composable
@ -288,80 +306,165 @@ private fun ExpiryDateText(item: ItemUiModel) {
} }
@Composable @Composable
private fun ItemSearchBar( private fun SearchBarWithFilterButton(
query: String, query: String,
onQueryChanged: (String) -> Unit, onQueryChanged: (String) -> Unit,
onClearClick: () -> Unit, onClearClick: () -> Unit,
modifier: Modifier = Modifier activeFilterCount: Int,
) { onFilterClick: () -> Unit,
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,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Row( Row(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.horizontalScroll(rememberScrollState()) .padding(start = 16.dp, end = 8.dp, top = 8.dp, bottom = 8.dp),
.padding(horizontal = 16.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically
horizontalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
FilterDropdownChip( OutlinedTextField(
label = "Kategorie", value = query,
selectedLabel = uiState.availableCategories onValueChange = onQueryChanged,
.find { it.first == uiState.selectedCategoryId }?.second, modifier = Modifier.weight(1f),
options = uiState.availableCategories, placeholder = { Text("Suchen...") },
onOptionSelected = { onCategorySelected(it) }, leadingIcon = {
onClear = { onCategorySelected(null) } Icon(Icons.Default.Search, contentDescription = "Suchen")
)
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])
}, },
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 @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, label: String,
selectedLabel: String?, selectedLabel: String?,
options: List<Pair<Int, String>>, options: List<Pair<Int, String>>,
@ -369,36 +472,33 @@ private fun FilterDropdownChip(
onClear: () -> Unit onClear: () -> Unit
) { ) {
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
val isSelected = selectedLabel != null val displayValue = selectedLabel ?: "Alle"
Box { ExposedDropdownMenuBox(
FilterChip( expanded = expanded,
selected = isSelected, onExpandedChange = { expanded = it }
onClick = { expanded = true }, ) {
label = { OutlinedTextField(
Text(selectedLabel ?: label) value = displayValue,
}, onValueChange = {},
trailingIcon = { readOnly = true,
Icon( label = { Text(label) },
imageVector = Icons.Default.ArrowDropDown, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
contentDescription = null, modifier = Modifier
modifier = Modifier.size(18.dp) .fillMaxWidth()
) .menuAnchor(MenuAnchorType.PrimaryNotEditable)
}
) )
DropdownMenu( ExposedDropdownMenu(
expanded = expanded, expanded = expanded,
onDismissRequest = { expanded = false } onDismissRequest = { expanded = false }
) { ) {
if (isSelected) { DropdownMenuItem(
DropdownMenuItem( text = { Text("Alle") },
text = { Text("Alle") }, onClick = {
onClick = { expanded = false
expanded = false onClear()
onClear() }
} )
)
}
options.forEach { (id, name) -> options.forEach { (id, name) ->
DropdownMenuItem( DropdownMenuItem(
text = { Text(name) }, text = { Text(name) },

View file

@ -22,11 +22,21 @@ internal enum class ExpiryFilter(val label: String) {
OK("OK") OK("OK")
} }
internal enum class SortOption(val label: String) {
NAME_ASC("Name (AZ)"),
NAME_DESC("Name (ZA)"),
EXPIRY_ASC("Ablauf (nächstes zuerst)"),
EXPIRY_DESC("Ablauf (spätestes zuerst)"),
QUANTITY_ASC("Menge (aufsteigend)"),
QUANTITY_DESC("Menge (absteigend)")
}
private data class FilterState( private data class FilterState(
val searchQuery: String = "", val searchQuery: String = "",
val categoryId: Int? = null, val categoryId: Int? = null,
val locationId: Int? = null, val locationId: Int? = null,
val expiryFilter: ExpiryFilter? = null val expiryFilter: ExpiryFilter? = null,
val sortOption: SortOption = SortOption.NAME_ASC
) )
internal data class ItemListUiState( internal data class ItemListUiState(
@ -36,6 +46,7 @@ internal data class ItemListUiState(
val selectedCategoryId: Int? = null, val selectedCategoryId: Int? = null,
val selectedLocationId: Int? = null, val selectedLocationId: Int? = null,
val selectedExpiryFilter: ExpiryFilter? = null, val selectedExpiryFilter: ExpiryFilter? = null,
val selectedSortOption: SortOption = SortOption.NAME_ASC,
val availableCategories: List<Pair<Int, String>> = emptyList(), val availableCategories: List<Pair<Int, String>> = emptyList(),
val availableLocations: List<Pair<Int, String>> = emptyList(), val availableLocations: List<Pair<Int, String>> = emptyList(),
val isDeleteDialogVisible: Boolean = false, val isDeleteDialogVisible: Boolean = false,
@ -49,6 +60,9 @@ internal data class ItemListUiState(
selectedExpiryFilter != null selectedExpiryFilter != null
val hasNoFilterResults: Boolean val hasNoFilterResults: Boolean
get() = isFiltering && groupedItems.isEmpty() && !isEmpty get() = isFiltering && groupedItems.isEmpty() && !isEmpty
val activeFilterSortCount: Int
get() = listOfNotNull(selectedCategoryId, selectedLocationId, selectedExpiryFilter).size +
if (selectedSortOption != SortOption.NAME_ASC) 1 else 0
} }
@HiltViewModel @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 } .groupBy { it.categoryName }
.toSortedMap() .toSortedMap()
@ -125,6 +148,7 @@ internal class ItemListViewModel @Inject constructor(
selectedCategoryId = filters.categoryId, selectedCategoryId = filters.categoryId,
selectedLocationId = filters.locationId, selectedLocationId = filters.locationId,
selectedExpiryFilter = filters.expiryFilter, selectedExpiryFilter = filters.expiryFilter,
selectedSortOption = filters.sortOption,
availableCategories = categories.map { it.id to it.name } availableCategories = categories.map { it.id to it.name }
.sortedBy { it.second }, .sortedBy { it.second },
availableLocations = locations.map { it.id to it.name } availableLocations = locations.map { it.id to it.name }
@ -157,6 +181,21 @@ internal class ItemListViewModel @Inject constructor(
_filterState.update { it.copy(expiryFilter = expiryFilter) } _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() { fun clearSearch() {
_filterState.update { it.copy(searchQuery = "") } _filterState.update { it.copy(searchQuery = "") }
} }