feat(resources): compact cards, BottomSheet filter/sort (tags+format+language)

- Cards: title + author + overflow row (format, size, tags)
- Description removed from card (detail-only)
- SearchBar + FilterList icon (like Inventar)
- BottomSheet: Tags, Format, Sprache filter + Sortierung
- Crash-fix: catch exceptions in refresh()
This commit is contained in:
Jens Reinemann 2026-05-18 23:53:47 +02:00
parent c24a32b033
commit fd2eae227b
2 changed files with 308 additions and 105 deletions

View file

@ -18,20 +18,28 @@ 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.Close
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
@ -39,6 +47,7 @@ import androidx.compose.material3.Surface
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.LaunchedEffect
import androidx.compose.runtime.getValue
@ -49,6 +58,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.core.content.FileProvider
@ -64,7 +74,7 @@ internal fun ResourceListScreen(
viewModel: ResourceListViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var isSortMenuExpanded by remember { mutableStateOf(false) }
var isBottomSheetVisible by remember { mutableStateOf(false) }
val snackbarHostState = remember { SnackbarHostState() }
val context = LocalContext.current
@ -72,14 +82,21 @@ internal fun ResourceListScreen(
uiState.resources,
uiState.searchQuery,
uiState.selectedTags,
uiState.selectedFormat,
uiState.selectedLanguage,
uiState.sortMode
) {
uiState.resources
.filter { resource ->
(uiState.searchQuery.isBlank() ||
resource.title.contains(uiState.searchQuery, ignoreCase = true)) &&
resource.title.contains(uiState.searchQuery, ignoreCase = true) ||
resource.author?.contains(uiState.searchQuery, ignoreCase = true) == true) &&
(uiState.selectedTags.isEmpty() ||
resource.tags.any { it in uiState.selectedTags })
resource.tags.any { it in uiState.selectedTags }) &&
(uiState.selectedFormat == null ||
resource.fileFormat == uiState.selectedFormat) &&
(uiState.selectedLanguage == null ||
resource.language == uiState.selectedLanguage)
}
.let { list ->
when (uiState.sortMode) {
@ -90,7 +107,7 @@ internal fun ResourceListScreen(
}
}
// Handle download state changes (open file on success, show error snackbar)
// Handle download state changes
uiState.downloadStates.forEach { (guid, state) ->
when (state) {
is DownloadState.Success -> {
@ -137,65 +154,47 @@ internal fun ResourceListScreen(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 16.dp)
) {
// Search bar + filter button (like Inventar)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 8.dp, top = 8.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
value = uiState.searchQuery,
onValueChange = { viewModel.setSearchQuery(it) },
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("Suche nach Titel…") },
modifier = Modifier.weight(1f),
placeholder = { Text("Suchen") },
leadingIcon = {
Icon(Icons.Filled.Search, contentDescription = null)
},
trailingIcon = {
if (uiState.searchQuery.isNotEmpty()) {
IconButton(onClick = { viewModel.setSearchQuery("") }) {
Icon(Icons.Filled.Close, contentDescription = "Suche löschen")
}
}
},
singleLine = true
)
Spacer(modifier = Modifier.height(8.dp))
if (uiState.allTags.isNotEmpty()) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
uiState.allTags.forEach { tag ->
FilterChip(
selected = tag in uiState.selectedTags,
onClick = { viewModel.toggleTag(tag) },
label = { Text(tag) }
)
}
}
Spacer(modifier = Modifier.height(8.dp))
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "Sortierung:",
style = MaterialTheme.typography.labelMedium
)
Spacer(modifier = Modifier.width(4.dp))
Box {
TextButton(onClick = { isSortMenuExpanded = true }) {
Text(uiState.sortMode.label)
BadgedBox(
badge = {
if (uiState.activeFilterSortCount > 0) {
Badge { Text("${uiState.activeFilterSortCount}") }
}
}
DropdownMenu(
expanded = isSortMenuExpanded,
onDismissRequest = { isSortMenuExpanded = false }
) {
SortMode.entries.forEach { mode ->
DropdownMenuItem(
text = { Text(mode.label) },
onClick = {
viewModel.setSortMode(mode)
isSortMenuExpanded = false
}
IconButton(onClick = { isBottomSheetVisible = true }) {
Icon(
imageVector = Icons.Filled.FilterList,
contentDescription = "Filter & Sortierung"
)
}
}
}
}
Spacer(modifier = Modifier.height(8.dp))
when {
uiState.isLoading && filteredResources.isEmpty() -> {
@ -220,7 +219,8 @@ internal fun ResourceListScreen(
}
else -> {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp)
modifier = Modifier.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
items(filteredResources, key = { it.guid }) { resource ->
val downloadState = uiState.downloadStates[resource.guid] ?: DownloadState.Idle
@ -243,6 +243,21 @@ internal fun ResourceListScreen(
}
}
}
if (isBottomSheetVisible) {
FilterSortBottomSheet(
uiState = uiState,
onTagToggled = viewModel::toggleTag,
onFormatSelected = viewModel::setFormat,
onLanguageSelected = viewModel::setLanguage,
onSortSelected = viewModel::setSortMode,
onClearAll = {
viewModel.clearAllFilters()
isBottomSheetVisible = false
},
onDismiss = { isBottomSheetVisible = false }
)
}
}
@OptIn(ExperimentalLayoutApi::class)
@ -257,25 +272,62 @@ private fun ResourceCard(
modifier = Modifier.fillMaxWidth(),
onClick = onClick
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
// Title
Text(
text = resource.title,
style = MaterialTheme.typography.titleMedium,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f)
maxLines = 1
)
// Author
resource.author?.let { author ->
Text(
text = author,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1
)
}
// Overflow row: format badge + size + tags
Spacer(modifier = Modifier.height(4.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
FormatBadge(format = resource.fileFormat)
Text(
text = formatFileSize(resource.fileSize),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.align(Alignment.CenterVertically)
)
resource.tags.forEach { tag ->
Surface(
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.secondaryContainer
) {
Text(
text = tag,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 1.dp),
style = MaterialTheme.typography.labelSmall
)
}
}
}
}
// Download button
IconButton(
onClick = onDownloadClick,
enabled = downloadState !is DownloadState.Loading
) {
if (downloadState is DownloadState.Loading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp
)
} else {
@ -286,37 +338,142 @@ private fun ResourceCard(
}
}
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
FormatBadge(format = resource.fileFormat)
Text(
text = formatFileSize(resource.fileSize),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
if (resource.tags.isNotEmpty()) {
Spacer(modifier = Modifier.height(6.dp))
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
private fun FilterSortBottomSheet(
uiState: ResourceListUiState,
onTagToggled: (String) -> Unit,
onFormatSelected: (String?) -> Unit,
onLanguageSelected: (String?) -> Unit,
onSortSelected: (SortMode) -> 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)
) {
// Tags
if (uiState.allTags.isNotEmpty()) {
Text(
text = "Tags",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(bottom = 8.dp)
)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
resource.tags.forEach { tag ->
Surface(
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.secondaryContainer
) {
Text(
text = tag,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp),
style = MaterialTheme.typography.labelSmall
uiState.allTags.forEach { tag ->
FilterChip(
selected = tag in uiState.selectedTags,
onClick = { onTagToggled(tag) },
label = { Text(tag) }
)
}
}
Spacer(modifier = Modifier.height(12.dp))
}
// Format filter
if (uiState.allFormats.size > 1) {
Text(
text = "Format",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(bottom = 8.dp)
)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
uiState.allFormats.forEach { format ->
FilterChip(
selected = uiState.selectedFormat == format,
onClick = {
onFormatSelected(if (uiState.selectedFormat == format) null else format)
},
label = { Text(format.uppercase()) }
)
}
}
Spacer(modifier = Modifier.height(12.dp))
}
// Language filter
if (uiState.allLanguages.size > 1) {
Text(
text = "Sprache",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(bottom = 8.dp)
)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
uiState.allLanguages.forEach { lang ->
FilterChip(
selected = uiState.selectedLanguage == lang,
onClick = {
onLanguageSelected(if (uiState.selectedLanguage == lang) null else lang)
},
label = { Text(languageLabel(lang)) }
)
}
}
Spacer(modifier = Modifier.height(12.dp))
}
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
// Sort
Text(
text = "Sortierung",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(bottom = 8.dp)
)
SortMode.entries.forEach { mode ->
Row(
modifier = Modifier
.fillMaxWidth()
.selectable(
selected = uiState.sortMode == mode,
onClick = { onSortSelected(mode) },
role = Role.RadioButton
)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = uiState.sortMode == mode,
onClick = null
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = mode.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")
}
}
}
@ -338,7 +495,7 @@ private fun FormatBadge(format: String) {
) {
Text(
text = format.uppercase(),
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp),
modifier = Modifier.padding(horizontal = 6.dp, vertical = 1.dp),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = color
@ -346,6 +503,14 @@ private fun FormatBadge(format: String) {
}
}
private fun languageLabel(code: String): String = when (code) {
"de" -> "Deutsch"
"en" -> "English"
"fr" -> "Français"
"es" -> "Español"
else -> code.uppercase()
}
private fun formatFileSize(bytes: Long): String {
return if (bytes >= 1024 * 1024) {
String.format("%.1f MB", bytes / (1024.0 * 1024.0))

View file

@ -33,12 +33,22 @@ internal data class ResourceListUiState(
val resources: List<ResourceDto> = emptyList(),
val searchQuery: String = "",
val selectedTags: Set<String> = emptySet(),
val selectedFormat: String? = null,
val selectedLanguage: String? = null,
val sortMode: SortMode = SortMode.TITLE_ASC,
val isLoading: Boolean = false,
val allTags: List<String> = emptyList(),
val allFormats: List<String> = emptyList(),
val allLanguages: List<String> = emptyList(),
val downloadStates: Map<String, DownloadState> = emptyMap(),
val errorMessage: String? = null
)
) {
val activeFilterSortCount: Int
get() = selectedTags.size +
(if (selectedFormat != null) 1 else 0) +
(if (selectedLanguage != null) 1 else 0) +
(if (sortMode != SortMode.TITLE_ASC) 1 else 0)
}
@HiltViewModel
internal class ResourceListViewModel @Inject constructor(
@ -52,7 +62,16 @@ internal class ResourceListViewModel @Inject constructor(
viewModelScope.launch {
resourceRepository.getAll().collect { resources ->
val allTags = resources.flatMap { it.tags }.distinct().sorted()
_uiState.update { it.copy(resources = resources, allTags = allTags) }
val allFormats = resources.map { it.fileFormat }.distinct().sorted()
val allLanguages = resources.mapNotNull { it.language }.distinct().sorted()
_uiState.update {
it.copy(
resources = resources,
allTags = allTags,
allFormats = allFormats,
allLanguages = allLanguages
)
}
}
}
refresh()
@ -90,6 +109,25 @@ internal class ResourceListViewModel @Inject constructor(
_uiState.update { it.copy(sortMode = mode) }
}
fun setFormat(format: String?) {
_uiState.update { it.copy(selectedFormat = format) }
}
fun setLanguage(language: String?) {
_uiState.update { it.copy(selectedLanguage = language) }
}
fun clearAllFilters() {
_uiState.update {
it.copy(
selectedTags = emptySet(),
selectedFormat = null,
selectedLanguage = null,
sortMode = SortMode.TITLE_ASC
)
}
}
fun downloadAndOpen(guid: String, mimeType: String, fileFormat: String, context: Context) {
viewModelScope.launch {
_uiState.update { state ->