diff --git a/app/src/main/java/de/bollwerk/app/ui/resources/ResourceListScreen.kt b/app/src/main/java/de/bollwerk/app/ui/resources/ResourceListScreen.kt index 944ef56..bb36aef 100644 --- a/app/src/main/java/de/bollwerk/app/ui/resources/ResourceListScreen.kt +++ b/app/src/main/java/de/bollwerk/app/ui/resources/ResourceListScreen.kt @@ -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,66 +154,48 @@ internal fun ResourceListScreen( modifier = Modifier .fillMaxSize() .padding(padding) - .padding(horizontal = 16.dp) ) { - OutlinedTextField( - value = uiState.searchQuery, - onValueChange = { viewModel.setSearchQuery(it) }, - modifier = Modifier.fillMaxWidth(), - placeholder = { Text("Suche nach Titel…") }, - leadingIcon = { - Icon(Icons.Filled.Search, contentDescription = null) - }, - singleLine = true - ) - - Spacer(modifier = Modifier.height(8.dp)) - - if (uiState.allTags.isNotEmpty()) { - FlowRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(4.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.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.width(4.dp)) + BadgedBox( + badge = { + if (uiState.activeFilterSortCount > 0) { + Badge { Text("${uiState.activeFilterSortCount}") } + } + } ) { - uiState.allTags.forEach { tag -> - FilterChip( - selected = tag in uiState.selectedTags, - onClick = { viewModel.toggleTag(tag) }, - label = { Text(tag) } + IconButton(onClick = { isBottomSheetVisible = true }) { + Icon( + imageVector = Icons.Filled.FilterList, + contentDescription = "Filter & Sortierung" ) } } - 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 { uiState.isLoading && filteredResources.isEmpty() -> { Box( @@ -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,54 +272,40 @@ private fun ResourceCard( modifier = Modifier.fillMaxWidth(), onClick = onClick ) { - Column(modifier = Modifier.padding(12.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { + Row( + 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 ) - IconButton( - onClick = onDownloadClick, - enabled = downloadState !is DownloadState.Loading - ) { - if (downloadState is DownloadState.Loading) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - strokeWidth = 2.dp - ) - } else { - Icon( - imageVector = Icons.Filled.Download, - contentDescription = "Herunterladen" - ) - } + // Author + resource.author?.let { author -> + Text( + text = author, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1 + ) } - } - - 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)) + // Overflow row: format badge + size + tags + Spacer(modifier = Modifier.height(4.dp)) FlowRow( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) + 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, @@ -312,13 +313,169 @@ private fun ResourceCard( ) { Text( text = tag, - modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp), + 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(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 = 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)) diff --git a/app/src/main/java/de/bollwerk/app/ui/resources/ResourceListViewModel.kt b/app/src/main/java/de/bollwerk/app/ui/resources/ResourceListViewModel.kt index 88efae9..a826c52 100644 --- a/app/src/main/java/de/bollwerk/app/ui/resources/ResourceListViewModel.kt +++ b/app/src/main/java/de/bollwerk/app/ui/resources/ResourceListViewModel.kt @@ -33,12 +33,22 @@ internal data class ResourceListUiState( val resources: List = emptyList(), val searchQuery: String = "", val selectedTags: Set = emptySet(), + val selectedFormat: String? = null, + val selectedLanguage: String? = null, val sortMode: SortMode = SortMode.TITLE_ASC, val isLoading: Boolean = false, val allTags: List = emptyList(), + val allFormats: List = emptyList(), + val allLanguages: List = emptyList(), val downloadStates: Map = 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 ->