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,