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.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.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.Close
import androidx.compose.material.icons.filled.Download 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.Refresh
import androidx.compose.material.icons.filled.Search 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.Card
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
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.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.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
@ -39,6 +47,7 @@ import androidx.compose.material3.Surface
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.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -49,6 +58,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
@ -64,7 +74,7 @@ internal fun ResourceListScreen(
viewModel: ResourceListViewModel = hiltViewModel() viewModel: ResourceListViewModel = hiltViewModel()
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var isSortMenuExpanded by remember { mutableStateOf(false) } var isBottomSheetVisible by remember { mutableStateOf(false) }
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val context = LocalContext.current val context = LocalContext.current
@ -72,14 +82,21 @@ internal fun ResourceListScreen(
uiState.resources, uiState.resources,
uiState.searchQuery, uiState.searchQuery,
uiState.selectedTags, uiState.selectedTags,
uiState.selectedFormat,
uiState.selectedLanguage,
uiState.sortMode uiState.sortMode
) { ) {
uiState.resources uiState.resources
.filter { resource -> .filter { resource ->
(uiState.searchQuery.isBlank() || (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() || (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 -> .let { list ->
when (uiState.sortMode) { 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) -> uiState.downloadStates.forEach { (guid, state) ->
when (state) { when (state) {
is DownloadState.Success -> { is DownloadState.Success -> {
@ -137,66 +154,48 @@ internal fun ResourceListScreen(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(padding) .padding(padding)
.padding(horizontal = 16.dp)
) { ) {
OutlinedTextField( // Search bar + filter button (like Inventar)
value = uiState.searchQuery, Row(
onValueChange = { viewModel.setSearchQuery(it) }, modifier = Modifier
modifier = Modifier.fillMaxWidth(), .fillMaxWidth()
placeholder = { Text("Suche nach Titel…") }, .padding(start = 16.dp, end = 8.dp, top = 8.dp, bottom = 8.dp),
leadingIcon = { verticalAlignment = Alignment.CenterVertically
Icon(Icons.Filled.Search, contentDescription = null) ) {
}, OutlinedTextField(
singleLine = true value = uiState.searchQuery,
) onValueChange = { viewModel.setSearchQuery(it) },
modifier = Modifier.weight(1f),
Spacer(modifier = Modifier.height(8.dp)) placeholder = { Text("Suchen…") },
leadingIcon = {
if (uiState.allTags.isNotEmpty()) { Icon(Icons.Filled.Search, contentDescription = null)
FlowRow( },
horizontalArrangement = Arrangement.spacedBy(8.dp), trailingIcon = {
verticalArrangement = Arrangement.spacedBy(4.dp) if (uiState.searchQuery.isNotEmpty()) {
IconButton(onClick = { viewModel.setSearchQuery("") }) {
Icon(Icons.Filled.Close, contentDescription = "Suche löschen")
}
}
},
singleLine = true
)
Spacer(modifier = Modifier.width(4.dp))
BadgedBox(
badge = {
if (uiState.activeFilterSortCount > 0) {
Badge { Text("${uiState.activeFilterSortCount}") }
}
}
) { ) {
uiState.allTags.forEach { tag -> IconButton(onClick = { isBottomSheetVisible = true }) {
FilterChip( Icon(
selected = tag in uiState.selectedTags, imageVector = Icons.Filled.FilterList,
onClick = { viewModel.toggleTag(tag) }, contentDescription = "Filter & Sortierung"
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)
}
DropdownMenu(
expanded = isSortMenuExpanded,
onDismissRequest = { isSortMenuExpanded = false }
) {
SortMode.entries.forEach { mode ->
DropdownMenuItem(
text = { Text(mode.label) },
onClick = {
viewModel.setSortMode(mode)
isSortMenuExpanded = false
}
)
}
}
}
}
Spacer(modifier = Modifier.height(8.dp))
when { when {
uiState.isLoading && filteredResources.isEmpty() -> { uiState.isLoading && filteredResources.isEmpty() -> {
Box( Box(
@ -220,7 +219,8 @@ internal fun ResourceListScreen(
} }
else -> { else -> {
LazyColumn( LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp) modifier = Modifier.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) { ) {
items(filteredResources, key = { it.guid }) { resource -> items(filteredResources, key = { it.guid }) { resource ->
val downloadState = uiState.downloadStates[resource.guid] ?: DownloadState.Idle 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) @OptIn(ExperimentalLayoutApi::class)
@ -257,54 +272,40 @@ private fun ResourceCard(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
onClick = onClick onClick = onClick
) { ) {
Column(modifier = Modifier.padding(12.dp)) { Row(
Row( modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically
horizontalArrangement = Arrangement.SpaceBetween, ) {
verticalAlignment = Alignment.CenterVertically Column(modifier = Modifier.weight(1f)) {
) { // Title
Text( Text(
text = resource.title, text = resource.title,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f) maxLines = 1
) )
IconButton( // Author
onClick = onDownloadClick, resource.author?.let { author ->
enabled = downloadState !is DownloadState.Loading Text(
) { text = author,
if (downloadState is DownloadState.Loading) { style = MaterialTheme.typography.bodySmall,
CircularProgressIndicator( color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp), maxLines = 1
strokeWidth = 2.dp )
)
} else {
Icon(
imageVector = Icons.Filled.Download,
contentDescription = "Herunterladen"
)
}
} }
} // Overflow row: format badge + size + tags
Spacer(modifier = Modifier.height(4.dp))
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))
FlowRow( FlowRow(
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(4.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 -> resource.tags.forEach { tag ->
Surface( Surface(
shape = MaterialTheme.shapes.small, shape = MaterialTheme.shapes.small,
@ -312,13 +313,169 @@ private fun ResourceCard(
) { ) {
Text( Text(
text = tag, text = tag,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp), modifier = Modifier.padding(horizontal = 6.dp, vertical = 1.dp),
style = MaterialTheme.typography.labelSmall style = MaterialTheme.typography.labelSmall
) )
} }
} }
} }
} }
// Download button
IconButton(
onClick = onDownloadClick,
enabled = downloadState !is DownloadState.Loading
) {
if (downloadState is DownloadState.Loading) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp
)
} else {
Icon(
imageVector = Icons.Filled.Download,
contentDescription = "Herunterladen"
)
}
}
}
}
}
@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(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
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(
text = format.uppercase(), 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, style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = color 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 { private fun formatFileSize(bytes: Long): String {
return if (bytes >= 1024 * 1024) { return if (bytes >= 1024 * 1024) {
String.format("%.1f MB", bytes / (1024.0 * 1024.0)) 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 resources: List<ResourceDto> = emptyList(),
val searchQuery: String = "", val searchQuery: String = "",
val selectedTags: Set<String> = emptySet(), val selectedTags: Set<String> = emptySet(),
val selectedFormat: String? = null,
val selectedLanguage: String? = null,
val sortMode: SortMode = SortMode.TITLE_ASC, val sortMode: SortMode = SortMode.TITLE_ASC,
val isLoading: Boolean = false, val isLoading: Boolean = false,
val allTags: List<String> = emptyList(), val allTags: List<String> = emptyList(),
val allFormats: List<String> = emptyList(),
val allLanguages: List<String> = emptyList(),
val downloadStates: Map<String, DownloadState> = emptyMap(), val downloadStates: Map<String, DownloadState> = emptyMap(),
val errorMessage: String? = null 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 @HiltViewModel
internal class ResourceListViewModel @Inject constructor( internal class ResourceListViewModel @Inject constructor(
@ -52,7 +62,16 @@ internal class ResourceListViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
resourceRepository.getAll().collect { resources -> resourceRepository.getAll().collect { resources ->
val allTags = resources.flatMap { it.tags }.distinct().sorted() 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() refresh()
@ -90,6 +109,25 @@ internal class ResourceListViewModel @Inject constructor(
_uiState.update { it.copy(sortMode = mode) } _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) { fun downloadAndOpen(guid: String, mimeType: String, fileFormat: String, context: Context) {
viewModelScope.launch { viewModelScope.launch {
_uiState.update { state -> _uiState.update { state ->