From 3bef469acc2783d32df4b64510961ada00b5c922 Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:59:18 +0000 Subject: [PATCH] search: add a type filter bar and share the search field Add a filter chip bar above the keyboard to toggle songs, albums, artists and playlists independently, all on by default and scrolling when it overflows. Extract one compact 48dp search field and use it in both the cross-library screen and the library tabs. --- .../subtracks/ui/components/SearchField.kt | 95 +++++++++++++++ .../com/subtracks/ui/library/LibraryScreen.kt | 37 +----- .../com/subtracks/ui/search/SearchScreen.kt | 110 +++++++++++------- .../subtracks/ui/search/SearchScreenTest.kt | 48 +++++++- 4 files changed, 210 insertions(+), 80 deletions(-) create mode 100644 app/src/main/kotlin/com/subtracks/ui/components/SearchField.kt diff --git a/app/src/main/kotlin/com/subtracks/ui/components/SearchField.kt b/app/src/main/kotlin/com/subtracks/ui/components/SearchField.kt new file mode 100644 index 00000000..2a3b35d1 --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/ui/components/SearchField.kt @@ -0,0 +1,95 @@ +package com.subtracks.ui.components + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.Search +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.subtracks.R + +@Composable +fun SearchField( + value: String, + onValueChange: (String) -> Unit, + onClose: () -> Unit, + focusRequester: FocusRequester, + modifier: Modifier = Modifier, + autoFocus: Boolean = true, +) { + val hint = stringResource(R.string.search_input_placeholder) + val closeLabel = stringResource(R.string.close_search) + LaunchedEffect(autoFocus) { if (autoFocus) focusRequester.requestFocus() } + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + modifier = modifier.height(48.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 4.dp), + ) { + Icon( + imageVector = Icons.Rounded.Search, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + Spacer(Modifier.width(8.dp)) + BasicTextField( + value = value, + onValueChange = onValueChange, + singleLine = true, + textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + modifier = + Modifier + .weight(1f) + .focusRequester(focusRequester) + .semantics { contentDescription = hint }, + decorationBox = { inner -> + if (value.isEmpty()) { + Text( + text = hint, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + inner() + }, + ) + IconButton(onClick = onClose) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = closeLabel, + modifier = Modifier.size(20.dp), + ) + } + } + } +} diff --git a/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt index cc7b7d70..9f908c06 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt @@ -32,7 +32,6 @@ import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.Album import androidx.compose.material.icons.rounded.ArrowDownward import androidx.compose.material.icons.rounded.ArrowUpward -import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.CloudOff import androidx.compose.material.icons.rounded.DownloadDone import androidx.compose.material.icons.rounded.FilterAltOff @@ -53,8 +52,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState @@ -77,7 +74,6 @@ import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect @@ -126,6 +122,7 @@ import com.subtracks.ui.components.DeleteDownloadsDialog import com.subtracks.ui.components.ItemActions import com.subtracks.ui.components.MenuTarget import com.subtracks.ui.components.PendingDownloadDelete +import com.subtracks.ui.components.SearchField import com.subtracks.ui.components.bulkRef import com.subtracks.ui.components.statusBarScrim import com.subtracks.ui.home.HomeRoute @@ -807,38 +804,6 @@ private val TOUCH_TARGET = 48.dp private const val LIST_OPTIONS_WEIGHT = 0.11f private val LIST_OPTIONS_BAR_WIDTHS = listOf(1f, 0.62f, 0.34f) -@Composable -private fun SearchField( - value: String, - onValueChange: (String) -> Unit, - onClose: () -> Unit, - focusRequester: FocusRequester, - modifier: Modifier = Modifier, -) { - LaunchedEffect(Unit) { focusRequester.requestFocus() } - TextField( - value = value, - onValueChange = onValueChange, - singleLine = true, - placeholder = { Text(stringResource(R.string.search_input_placeholder)) }, - leadingIcon = { Icon(Icons.Rounded.Search, contentDescription = null) }, - trailingIcon = { - IconButton(onClick = onClose) { - Icon(Icons.Rounded.Close, contentDescription = stringResource(R.string.close_search)) - } - }, - shape = RoundedCornerShape(4.dp), - colors = - TextFieldDefaults.colors( - focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - ), - modifier = modifier.focusRequester(focusRequester), - ) -} - @Composable private fun ListOptionsGlyph(modifier: Modifier = Modifier) { val color = LocalContentColor.current diff --git a/app/src/main/kotlin/com/subtracks/ui/search/SearchScreen.kt b/app/src/main/kotlin/com/subtracks/ui/search/SearchScreen.kt index 34d55c57..79f3e19e 100644 --- a/app/src/main/kotlin/com/subtracks/ui/search/SearchScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/search/SearchScreen.kt @@ -1,37 +1,37 @@ package com.subtracks.ui.search +import androidx.annotation.StringRes import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.rememberScrollState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack -import androidx.compose.material.icons.rounded.Close import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.subtracks.R @@ -49,6 +49,7 @@ import com.subtracks.ui.components.EmptyState import com.subtracks.ui.components.ItemActions import com.subtracks.ui.components.LoadingState import com.subtracks.ui.components.MenuTarget +import com.subtracks.ui.components.SearchField import com.subtracks.ui.home.AlbumListRow import com.subtracks.ui.home.ArtistListRow import com.subtracks.ui.library.PlaylistRow @@ -56,6 +57,17 @@ import com.subtracks.ui.library.SongRow import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel +enum class SearchFilter( + @StringRes val label: Int, +) { + Songs(R.string.search_songs), + Albums(R.string.resources_album_name), + Artists(R.string.resources_artist_name), + Playlists(R.string.resources_playlist_name), +} + +private val ALL_FILTERS = SearchFilter.entries.toSet() + @Composable fun SearchRoute( onBack: () -> Unit, @@ -72,6 +84,7 @@ fun SearchRoute( val query by viewModel.query.collectAsStateWithLifecycle() val results by viewModel.results.collectAsStateWithLifecycle() val playback by playbackController.state.collectAsStateWithLifecycle() + var filterMask by rememberSaveable { mutableIntStateOf(allFilterMask()) } val actions = ItemActions( playNext = { playbackController.playNext(it.sourceId, it.kind, it.refId) }, @@ -84,6 +97,8 @@ fun SearchRoute( query = query, onQueryChange = viewModel::setQuery, results = results, + filters = maskToFilters(filterMask), + onToggleFilter = { filterMask = filterMask xor (1 shl it.ordinal) }, coverArt = viewModel::coverArt, playingSongId = playback.item?.id, onBack = onBack, @@ -95,12 +110,18 @@ fun SearchRoute( ) } +private fun allFilterMask(): Int = SearchFilter.entries.fold(0) { mask, filter -> mask or (1 shl filter.ordinal) } + +private fun maskToFilters(mask: Int): Set = SearchFilter.entries.filterTo(mutableSetOf()) { mask and (1 shl it.ordinal) != 0 } + @OptIn(ExperimentalMaterial3Api::class) @Composable fun SearchScreen( query: String, onQueryChange: (String) -> Unit, results: SearchResults, + filters: Set = ALL_FILTERS, + onToggleFilter: (SearchFilter) -> Unit = {}, coverArt: (String?, Boolean) -> CoverArtRef? = { _, _ -> null }, playingSongId: String? = null, onBack: () -> Unit = {}, @@ -112,15 +133,16 @@ fun SearchScreen( modifier: Modifier = Modifier, ) { val focusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { focusRequester.requestFocus() } val backLabel = stringResource(R.string.navigation_back) - val clearLabel = stringResource(R.string.close_search) - val hint = stringResource(R.string.search_input_placeholder) val minLength = stringResource(R.string.search_min_length, SEARCH_MIN_LENGTH) val songsLabel = stringResource(R.string.search_songs) val albumsLabel = stringResource(R.string.resources_album_name) val artistsLabel = stringResource(R.string.resources_artist_name) val playlistsLabel = stringResource(R.string.resources_playlist_name) + val showSongs = SearchFilter.Songs in filters && results.songs.isNotEmpty() + val showAlbums = SearchFilter.Albums in filters && results.albums.isNotEmpty() + val showArtists = SearchFilter.Artists in filters && results.artists.isNotEmpty() + val showPlaylists = SearchFilter.Playlists in filters && results.playlists.isNotEmpty() Scaffold( modifier = modifier, topBar = { @@ -131,35 +153,19 @@ fun SearchScreen( } }, title = { - TextField( + SearchField( value = query, onValueChange = onQueryChange, - singleLine = true, - placeholder = { Text(hint) }, - trailingIcon = { - if (query.isNotEmpty()) { - IconButton(onClick = { onQueryChange("") }) { - Icon(Icons.Rounded.Close, contentDescription = clearLabel) - } - } - }, - shape = RoundedCornerShape(8.dp), - colors = - TextFieldDefaults.colors( - focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - ), - modifier = - Modifier - .fillMaxWidth() - .focusRequester(focusRequester) - .semantics { contentDescription = hint }, + onClose = { onQueryChange("") }, + focusRequester = focusRequester, + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), ) }, ) }, + bottomBar = { + SearchFilterBar(filters = filters, onToggle = onToggleFilter) + }, ) { padding -> when { !searchReady(query) -> { @@ -173,7 +179,7 @@ fun SearchScreen( LoadingState(Modifier.padding(padding)) } - results.isEmpty -> { + !showSongs && !showAlbums && !showArtists && !showPlaylists -> { EmptyState( text = stringResource(R.string.search_no_results, query), modifier = Modifier.padding(padding), @@ -185,16 +191,40 @@ fun SearchScreen( modifier = Modifier.fillMaxSize().padding(padding), contentPadding = PaddingValues(bottom = 24.dp), ) { - songResults(results.songs, songsLabel, coverArt, playingSongId, onSongClick, onLongClick) - albumResults(results.albums, albumsLabel, coverArt, onAlbumClick, onLongClick) - artistResults(results.artists, artistsLabel, coverArt, onArtistClick, onLongClick) - playlistResults(results.playlists, playlistsLabel, coverArt, onPlaylistClick, onLongClick) + if (showSongs) songResults(results.songs, songsLabel, coverArt, playingSongId, onSongClick, onLongClick) + if (showAlbums) albumResults(results.albums, albumsLabel, coverArt, onAlbumClick, onLongClick) + if (showArtists) artistResults(results.artists, artistsLabel, coverArt, onArtistClick, onLongClick) + if (showPlaylists) playlistResults(results.playlists, playlistsLabel, coverArt, onPlaylistClick, onLongClick) } } } } } +@Composable +private fun SearchFilterBar( + filters: Set, + onToggle: (SearchFilter) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = + modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + SearchFilter.entries.forEach { filter -> + FilterChip( + selected = filter in filters, + onClick = { onToggle(filter) }, + label = { Text(stringResource(filter.label)) }, + ) + } + } +} + private fun LazyListScope.resultHeader(title: String) { item(key = "search-header-$title") { Text( diff --git a/app/src/test/kotlin/com/subtracks/ui/search/SearchScreenTest.kt b/app/src/test/kotlin/com/subtracks/ui/search/SearchScreenTest.kt index fdbf3f37..870b6aae 100644 --- a/app/src/test/kotlin/com/subtracks/ui/search/SearchScreenTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/search/SearchScreenTest.kt @@ -49,10 +49,10 @@ class SearchScreenTest { } } - composeRule.onNodeWithText("Songs").assertIsDisplayed() - composeRule.onNodeWithText("Albums").assertIsDisplayed() - composeRule.onNodeWithText("Artists").assertIsDisplayed() - composeRule.onNodeWithText("Playlists").assertIsDisplayed() + composeRule.onNodeWithText("Road Song").assertIsDisplayed() + composeRule.onNodeWithText("Road Album").assertIsDisplayed() + composeRule.onNodeWithText("Road Artist").assertIsDisplayed() + composeRule.onNodeWithText("Road Trip").assertIsDisplayed() composeRule.onNodeWithText("Road Song").performClick() composeRule.onNodeWithText("Road Album").performClick() @@ -107,6 +107,46 @@ class SearchScreenTest { composeRule.onNodeWithText("Road Trip").assertIsDisplayed() } + @Test + fun disablingATypeHidesItsSection() { + composeRule.setContent { + SubtracksTheme { + SearchScreen( + query = "road", + onQueryChange = {}, + results = + SearchResults( + albums = listOf(album("al-1", "Road Album")), + songs = listOf(AlbumSongItem(song("s1", "Road Song"), coverArt = null)), + ), + filters = setOf(SearchFilter.Albums), + ) + } + } + + composeRule.onNodeWithText("Road Album").assertIsDisplayed() + composeRule.onNodeWithText("Road Song").assertDoesNotExist() + } + + @Test + fun tappingAFilterChipTogglesIt() { + var toggled: SearchFilter? = null + composeRule.setContent { + SubtracksTheme { + SearchScreen( + query = "ro", + onQueryChange = {}, + results = SearchResults(), + onToggleFilter = { toggled = it }, + ) + } + } + + composeRule.onNodeWithText("Playlists").performClick() + + assertEquals(SearchFilter.Playlists, toggled) + } + private fun album( id: String, name: String,