From 1ccd70a18ce4f6b32451c1f5e160c0f3460ad285 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Wed, 14 Oct 2026 16:29:45 +0600 Subject: [PATCH] feat(search): enhance search functionality with recent search suggestions and improved UI components --- .../composeResources/values/strings.xml | 6 + .../component/AdaptiveDropdownBottomSheet.kt | 261 ++++--------- .../spotube/core/ui/theming/AppTheme.kt | 29 +- .../spotube/modules/search/SearchScreen.kt | 361 ++++++++++-------- .../modules/search/SearchScreenViewModel.kt | 9 +- .../spotube/modules/shell/AppSidebar.kt | 32 +- 6 files changed, 336 insertions(+), 362 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 8aee91ce..32787564 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -21,6 +21,12 @@ Filter queue... No queue entries + Search songs, artists, albums... + Clear all history + Search... + + Selected + Settings Plugins diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDropdownBottomSheet.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDropdownBottomSheet.kt index e8646ed2..5c7ec032 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDropdownBottomSheet.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDropdownBottomSheet.kt @@ -19,23 +19,17 @@ package dev.krtirtho.spotube.core.ui.component import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsHoveredAsState -import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -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.lazy.LazyColumn import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.draw.clip import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -45,19 +39,13 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import com.composeunstyled.currentWindowWidthBreakpoint import org.spotube.millennium.adaptive.WindowBreakpoints import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import com.composeunstyled.DropdownMenuPanel import com.composeunstyled.SheetDetent -import com.composeunstyled.Text -import com.composeunstyled.UnstyledDropdownMenu -import com.composeunstyled.UnstyledDropdownMenuItem import com.composeunstyled.rememberModalBottomSheetState import compose.icons.FeatherIcons import compose.icons.feathericons.Check @@ -67,16 +55,19 @@ import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart import dev.krtirtho.spotube.resources.iconsax.IconsaxShare import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash +import org.jetbrains.compose.resources.stringResource +import org.spotube.millennium.composables.MillenniumDropdownMenu import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumIconButton import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumListTile import org.spotube.millennium.composables.MillenniumModalBottomSheet import org.spotube.millennium.composables.MillenniumSeparator +import org.spotube.millennium.composables.MillenniumText import org.spotube.millennium.composables.MillenniumTextField -import org.spotube.millennium.theme.MillenniumTheme -import org.spotube.millennium.theme.gelBrush -import org.spotube.millennium.theme.millenniumPanelBrush -import org.spotube.millennium.theme.millenniumSurface +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.accessibility_selected +import spotube.composeapp.generated.resources.search_filter_placeholder data class AdaptiveMenuItem( val icon: ImageVector? = null, @@ -108,118 +99,17 @@ fun AdaptiveDropdownBottomSheet( Box(modifier = modifier) { if (isLargeScreen) { - UnstyledDropdownMenu( + MillenniumDropdownMenu( expanded = expanded, onExpandedChange = { expanded = it }, - panel = { - DropdownMenuPanel( - Modifier - .width(menuMinWidth) - .millenniumSurface( - background = millenniumPanelBrush(MillenniumTheme.colors), - shape = AppTheme.shapes.medium, - borderColor = AppTheme.colors.outlineVariant, - borderWidth = 1.dp) - .padding(4.dp)) { - if (header != null && (headerDisplayMode == HeaderDisplayMode.Always || headerDisplayMode == HeaderDisplayMode.OnlyInDropdown)) { - header() - } - - val dropdownQueryState = rememberTextFieldState() - val query = dropdownQueryState.text.toString() - if (filter != null) { - MillenniumTextField( - state = dropdownQueryState, - label = "", - placeholder = "Search...", - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp)) - } - - items.forEach { item -> - if (query.isNotBlank() && filter != null && !filter(item, query)) { - return@forEach - } - if (item.dividerBefore) { - MillenniumSeparator(modifier = Modifier.padding(vertical = 4.dp)) - } - val itemInteraction = remember { MutableInteractionSource() } - val itemHovered by itemInteraction.collectIsHoveredAsState() - val itemPressed by itemInteraction.collectIsPressedAsState() - val itemEnabled = item.enabled - val itemHighlighted = itemEnabled && (itemHovered || itemPressed || item.selected) - val itemForeground = when { - !itemEnabled -> AppTheme.colors.onSurface.copy(alpha = 0.38f) - itemHighlighted -> MillenniumTheme.colors.onAccent - else -> AppTheme.colors.onSurface - } - UnstyledDropdownMenuItem( - onClick = { item.onClick() }, - enabled = itemEnabled, - indication = null, - interactionSource = itemInteraction) { - Row( - modifier = Modifier - .fillMaxWidth() - .then( - if (itemHighlighted) { - Modifier.millenniumSurface( - background = gelBrush( - MillenniumTheme.colors.accentTop, - MillenniumTheme.colors.accentBottom), - shape = RoundedCornerShape(8.dp)) - } else { - Modifier - }) - .padding(horizontal = 12.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp)) { - val itemIcon = item.icon - when { - item.leadingContent != null -> item.leadingContent.invoke() - itemIcon != null -> MillenniumIcon( - imageVector = itemIcon, - contentDescription = null, - tint = itemForeground, - modifier = Modifier.size(20.dp)) - - item.selected -> MillenniumIcon( - imageVector = FeatherIcons.Check, - contentDescription = "Selected", - tint = itemForeground, - modifier = Modifier.size(20.dp)) - } - Column(modifier = Modifier.weight(1f)) { - Text( - text = item.label, - style = AppTheme.typography.bodyMedium, - color = itemForeground) - item.subtitle?.let { subtitle -> - Text( - text = subtitle, - style = AppTheme.typography.bodySmall, - color = if (itemHighlighted) { - itemForeground.copy(alpha = 0.85f) - } else { - AppTheme.colors.onSurfaceVariant - }) - } - } - if (item.selected && (item.icon != null || item.leadingContent != null)) { - MillenniumIcon( - imageVector = FeatherIcons.Check, - contentDescription = "Selected", - tint = itemForeground, - modifier = Modifier.size(20.dp)) - } - } - } - } - } - }, - anchor = { trigger { expanded = true } }, - ) + items = items.map { it.label }, + onItemClick = { label -> + items.firstOrNull { it.label == label && it.enabled }?.onClick?.invoke() + expanded = false + }, + minimumWidth = menuMinWidth, + anchor = { trigger { expanded = true } } + ) } else { trigger { expanded = true } if (expanded) { @@ -267,7 +157,7 @@ private fun AdaptiveBottomSheetContent( MillenniumTextField( state = queryState, label = "", - placeholder = "Search...", + placeholder = stringResource(Res.string.search_filter_placeholder), modifier = Modifier .fillMaxWidth() .padding(horizontal = 12.dp, vertical = 8.dp)) @@ -285,72 +175,69 @@ private fun AdaptiveBottomSheetContent( if (item.dividerBefore) { MillenniumSeparator(modifier = Modifier.padding(vertical = 4.dp)) } - Row( - modifier = Modifier - .fillMaxWidth() - .clip(AppTheme.shapes.medium) - .clickable( - enabled = item.enabled, - onClick = { - item.onClick() - onDismiss() - }) - .background( - color = if (item.selected) { - AppTheme.colors.secondary.copy(alpha = 0.12f) - } else { - Color.Transparent - }, - shape = AppTheme.shapes.medium) - .padding(horizontal = 12.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp)) { - when { - item.leadingContent != null -> item.leadingContent.invoke() - item.icon != null -> MillenniumIcon( - imageVector = item.icon, - contentDescription = null, - modifier = Modifier.size(22.dp), - tint = if (item.enabled) { - AppTheme.colors.onSurface - } else { - AppTheme.colors.onSurface.copy(alpha = 0.38f) - }) - - item.selected -> MillenniumIcon( - imageVector = FeatherIcons.Check, - contentDescription = "Selected", - modifier = Modifier.size(22.dp), - tint = AppTheme.colors.onSurfaceVariant) - - hasSelection -> Spacer(modifier = Modifier.size(22.dp)) - } - - Column(modifier = Modifier.weight(1f)) { - Text( - text = item.label, + MillenniumListTile( + title = { + MillenniumText( + item.label, style = AppTheme.typography.bodyLarge, color = if (item.enabled) { AppTheme.colors.onSurface } else { AppTheme.colors.onSurface.copy(alpha = 0.38f) - }) - item.subtitle?.let { subtitle -> - Text( - text = subtitle, + } + ) + }, + subtitle = item.subtitle?.let { subtitle -> + { + MillenniumText( + subtitle, style = AppTheme.typography.bodySmall, - color = AppTheme.colors.onSurfaceVariant) + color = AppTheme.colors.onSurfaceVariant + ) } - } - - if (item.selected && (item.icon != null || item.leadingContent != null)) { - MillenniumIcon( - imageVector = FeatherIcons.Check, - contentDescription = "Selected", - modifier = Modifier.size(22.dp), - tint = AppTheme.colors.onSurfaceVariant) - } - } + }, + leading = when { + item.leadingContent != null -> item.leadingContent + item.icon != null -> { + { + MillenniumIcon( + imageVector = item.icon, + contentDescription = null, + modifier = Modifier.size(22.dp) + ) + } + } + item.selected -> { + { + MillenniumIcon( + imageVector = FeatherIcons.Check, + contentDescription = stringResource(Res.string.accessibility_selected), + modifier = Modifier.size(22.dp) + ) + } + } + hasSelection -> {{ Spacer(modifier = Modifier.size(22.dp)) }} + else -> null + }, + trailing = if (item.selected && (item.icon != null || item.leadingContent != null)) { + { + MillenniumIcon( + imageVector = FeatherIcons.Check, + contentDescription = stringResource(Res.string.accessibility_selected), + modifier = Modifier.size(22.dp) + ) + } + } else { + null + }, + selected = item.selected, + enabled = item.enabled, + onClick = { + item.onClick() + onDismiss() + }, + minimumHeight = 56.dp + ) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/theming/AppTheme.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/theming/AppTheme.kt index 11584429..b20a2ede 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/theming/AppTheme.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/theming/AppTheme.kt @@ -36,7 +36,12 @@ import androidx.compose.ui.unit.sp import dev.krtirtho.spotube.modules.settings.Theme import dev.krtirtho.spotube.modules.settings.UserSettings import org.spotube.millennium.composables.MillenniumTooltipHost +import org.spotube.millennium.composables.MillenniumTopAppBarTheme +import org.spotube.millennium.composables.MillenniumTopAppBarThemeProperties +import org.spotube.millennium.composables.defaultMillenniumTopAppBarTheme import org.spotube.millennium.theme.MillenniumColors +import org.spotube.millennium.theme.MillenniumDarkThemeComponents +import org.spotube.millennium.theme.MillenniumLightThemeComponents import org.spotube.millennium.theme.MillenniumPalette import org.spotube.millennium.theme.MillenniumTheme import org.spotube.millennium.theme.MillenniumThemeConfig @@ -201,7 +206,29 @@ fun SpotubeTheme( } val palette: MillenniumPalette = settings.palette - MillenniumTheme(dark = isDarkTheme, config = MillenniumThemeConfig(palette = palette)) { + val darkTheme = MillenniumDarkThemeComponents + val lightTheme = MillenniumLightThemeComponents + + MillenniumTheme( + dark = isDarkTheme, + config = MillenniumThemeConfig( + palette = palette, + dark = darkTheme.copy( + topAppBarTheme = MillenniumTopAppBarTheme( + properties = darkTheme.topAppBarTheme.properties.copy( + separatorColor = Color.Transparent + ) + ) + ), + light = lightTheme.copy( + topAppBarTheme = MillenniumTopAppBarTheme( + properties = lightTheme.topAppBarTheme.properties.copy( + separatorColor = Color.Transparent + ) + ) + ) + ) + ) { val colors = appColorsFrom(MillenniumTheme.colors) MillenniumTooltipHost { CompositionLocalProvider( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt index f4709cb0..ff720d6c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt @@ -19,13 +19,12 @@ package dev.krtirtho.spotube.modules.search import org.spotube.millennium.composables.MillenniumIcon -import org.spotube.millennium.composables.MillenniumIconButton -import org.spotube.millennium.composables.MillenniumIconButtonStyle import org.spotube.millennium.composables.MillenniumButton import org.spotube.millennium.composables.MillenniumButtonStyle import org.spotube.millennium.composables.MillenniumIndeterminateProgress import org.spotube.millennium.composables.MillenniumListTile import org.spotube.millennium.composables.MillenniumAutocomplete +import org.spotube.millennium.composables.MillenniumText import org.spotube.millennium.composables.MillenniumTextField import org.spotube.millennium.theme.MillenniumTheme import androidx.compose.animation.AnimatedContent @@ -78,8 +77,6 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import compose.icons.FeatherIcons -import compose.icons.feathericons.X import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist @@ -115,6 +112,10 @@ import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxSearchBroken import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash import dev.krtirtho.spotube.resources.iconsax.InconsaxClock +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.search_clear_history +import spotube.composeapp.generated.resources.search_placeholder import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch @@ -145,8 +146,12 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { var tracksToAddToPlaylist by remember { mutableStateOf>(emptyList()) } var currentUserId by remember { mutableStateOf(null) } - val blacklistedTracks by blacklistRepository.blacklistedTracks.collectAsStateWithLifecycle(emptyList()) - val blacklistedArtists by blacklistRepository.blacklistedArtists.collectAsStateWithLifecycle(emptyList()) + val blacklistedTracks by blacklistRepository.blacklistedTracks.collectAsStateWithLifecycle( + emptyList() + ) + val blacklistedArtists by blacklistRepository.blacklistedArtists.collectAsStateWithLifecycle( + emptyList() + ) fun isTrackBlacklisted(track: MetadataTrack): Boolean { val trackIds = blacklistedTracks.map { it.id }.toSet() @@ -172,7 +177,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { entries = listOf(QueueEntry.StreamingTrack(track = track, url = "")), autoPlay = true, startPosition = 0, - collectionEntry = null) + collectionEntry = null + ) } } @@ -188,7 +194,14 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { if (existing != null) { // Already in the local queue: move it to the next position audioPlayerQueue.removeFromQueue(existing) - audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = ""))) + audioPlayerQueue.addAllAfterCurrent( + listOf( + QueueEntry.StreamingTrack( + track = track, + url = "" + ) + ) + ) } else { remotePlaybackController.requestTrackPlayNext(track) } @@ -197,6 +210,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { is TrackOptionsAction.AddToQueue -> { remotePlaybackController.requestTrackAddToQueue(track) } + is TrackOptionsAction.AddToJam -> { remotePlaybackController.addTrackToJam(track) } @@ -217,6 +231,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { shareService.share(uri, track.title) } } + is TrackOptionsAction.AddToPlaylist -> { tracksToAddToPlaylist = listOf(track) showAddToPlaylistPicker = true @@ -236,7 +251,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { isInQueue = queueTrackIds.contains(track.id), isCurrentlyPlaying = track.id == currentTrackId, isFavorite = savedTrackIds.contains(track.id), - isBlacklisted = isTrackBlacklisted(track)) + isBlacklisted = isTrackBlacklisted(track) + ) } val queue by audioPlayerQueue.queueFlow.collectAsStateWithLifecycle(emptyList()) @@ -251,7 +267,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { isInQueue = queueTrackIds.contains(track.id), isCurrentlyPlaying = track.id == currentTrackId, isFavorite = savedTrackIds.contains(track.id), - isBlacklisted = isTrackBlacklisted(track)) + isBlacklisted = isTrackBlacklisted(track) + ) } fun bulkAddToQueue(tracks: List) { @@ -269,7 +286,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { Column( modifier = Modifier .fillMaxSize() - .background(MillenniumTheme.colors.background)) { + .background(MillenniumTheme.colors.background) + ) { ApplicationMainBar(backButton = false) Column( modifier = Modifier @@ -282,12 +300,11 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { SearchBar( query = state.query, onQueryChange = viewModel::onQueryChange, - recentSearches = state.recentSearches, + recentSearchSuggestions = state.recentSearchSuggestions, onRecentSearchClick = { search -> viewModel.applyRecentSearch(search) focusManager.clearFocus() }, - onRecentSearchRemove = viewModel::removeRecentSearch, onClearAllRecentSearches = { viewModel.clearAllRecentSearches() focusManager.clearFocus() @@ -297,7 +314,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { SearchTabs( types = state.supportedSearchTypes, selectedType = selectedType, - onTabSelected = viewModel::onTabSelected) + onTabSelected = viewModel::onTabSelected + ) } } @@ -305,14 +323,16 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { state.isLoadingSearchTypes -> { SearchLoadingIndicator( modifier = Modifier - .fillMaxSize()) + .fillMaxSize() + ) } selectedType == null -> { SearchMessage( message = "No search types available", modifier = Modifier - .fillMaxSize()) + .fillMaxSize() + ) } selectedType == MetadataSupportedSearchType.ALL -> { @@ -348,7 +368,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { // No-op for now, could show a dialog with all artists }, modifier = Modifier - .fillMaxSize()) + .fillMaxSize() + ) } selectedType == MetadataSupportedSearchType.TRACK -> { @@ -379,7 +400,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { // No-op for now, could show a dialog with all artists }, modifier = Modifier - .fillMaxSize()) + .fillMaxSize() + ) } selectedType == MetadataSupportedSearchType.PLAYLIST -> { @@ -388,7 +410,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { playlists = state.playlists, onLoadNextPage = viewModel::loadNextPlaylists, modifier = Modifier - .fillMaxSize()) + .fillMaxSize() + ) } selectedType == MetadataSupportedSearchType.ALBUM -> { @@ -397,7 +420,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { albums = state.albums, onLoadNextPage = viewModel::loadNextAlbums, modifier = Modifier - .fillMaxSize()) + .fillMaxSize() + ) } selectedType == MetadataSupportedSearchType.ARTIST -> { @@ -406,7 +430,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { artists = state.artists, onLoadNextPage = viewModel::loadNextArtists, modifier = Modifier - .fillMaxSize()) + .fillMaxSize() + ) } selectedType == MetadataSupportedSearchType.USER -> { @@ -415,7 +440,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { users = state.users, onLoadNextPage = viewModel::loadNextUsers, modifier = Modifier - .fillMaxSize()) + .fillMaxSize() + ) } } } @@ -439,11 +465,11 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { private fun SearchBar( query: String, onQueryChange: (String) -> Unit, - recentSearches: List, + recentSearchSuggestions: List, onRecentSearchClick: (String) -> Unit, - onRecentSearchRemove: (String) -> Unit, - onClearAllRecentSearches: () -> Unit) { - val showRecentSearches = query.isBlank() && recentSearches.isNotEmpty() + onClearAllRecentSearches: () -> Unit +) { + val hasRecentSearchSuggestions = recentSearchSuggestions.isNotEmpty() val searchState = rememberTextFieldState(query) LaunchedEffect(searchState) { @@ -455,106 +481,61 @@ private fun SearchBar( } } - val suggestions = remember(showRecentSearches, recentSearches) { - buildList { - if (showRecentSearches) { - add(SearchDropdownEntry.ClearAll(onClick = onClearAllRecentSearches)) - recentSearches.forEach { search -> - add( - SearchDropdownEntry.RecentSearch( - query = search, - onClick = { onRecentSearchClick(search) }, - onRemove = { onRecentSearchRemove(search) }) - ) - } - } - } - } - - Box( + MillenniumAutocomplete( + state = searchState, + suggestions = recentSearchSuggestions, + onSuggestionSelected = onRecentSearchClick, + suggestionLabel = { it }, + leadingContent = { + MillenniumIcon( + imageVector = Iconsax.InconsaxClock, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = AppTheme.colors.onSurfaceVariant + ) + }, + placeholder = stringResource(Res.string.search_placeholder), modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp) - .padding(bottom = 8.dp)) { - MillenniumAutocomplete( - state = searchState, - - suggestions = suggestions, - onSuggestionSelected = { entry -> entry.onClick() }, - suggestionLabel = { entry -> - when (entry) { - is SearchDropdownEntry.ClearAll -> "Clear all history" - is SearchDropdownEntry.RecentSearch -> entry.query - } - }, - placeholder = "Search songs, artists, albums...", - modifier = Modifier.fillMaxWidth(), - suggestionContent = { entry, _ -> SearchSuggestionRow(entry) }) - } -} - -private sealed interface SearchDropdownEntry { - val onClick: () -> Unit - - data class ClearAll(override val onClick: () -> Unit) : SearchDropdownEntry - data class RecentSearch( - val query: String, - override val onClick: () -> Unit, - val onRemove: () -> Unit) : SearchDropdownEntry -} - -@Composable -private fun SearchSuggestionRow(entry: SearchDropdownEntry) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp)) { - when (entry) { - is SearchDropdownEntry.ClearAll -> { - MillenniumIcon( - imageVector = Iconsax.IconsaxTrash, - contentDescription = null, - tint = AppTheme.colors.error, - modifier = Modifier.size(16.dp)) - Text( - "Clear all history", - color = AppTheme.colors.error, - style = AppTheme.typography.bodyMedium) - } - - is SearchDropdownEntry.RecentSearch -> { - MillenniumIcon( - imageVector = Iconsax.InconsaxClock, - contentDescription = null, - modifier = Modifier.size(16.dp), - tint = AppTheme.colors.onSurfaceVariant) - Text( - entry.query, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = AppTheme.typography.bodyMedium, - modifier = Modifier.weight(1f)) - MillenniumIconButton( - onClick = entry.onRemove, - style = MillenniumIconButtonStyle.Quiet) { - MillenniumIcon( - imageVector = FeatherIcons.X, - contentDescription = "Remove", - modifier = Modifier.size(14.dp), - tint = AppTheme.colors.onSurfaceVariant.copy(alpha = 0.6f)) - } + .padding(bottom = 8.dp), + menuHeader = if (hasRecentSearchSuggestions) { + { + MillenniumListTile( + title = { + MillenniumText( + text = stringResource(Res.string.search_clear_history), + color = AppTheme.colors.error, + style = AppTheme.typography.bodyMedium + ) + }, + leading = { + MillenniumIcon( + imageVector = Iconsax.IconsaxTrash, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = AppTheme.colors.error + ) + }, + onClick = onClearAllRecentSearches, + minimumHeight = 36.dp, + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp), + leadingSpacing = 8.dp, + shape = AppTheme.shapes.small + ) } + } else { + null } - } + ) } @Composable private fun SearchTabs( types: List, selectedType: MetadataSupportedSearchType?, - onTabSelected: (MetadataSupportedSearchType) -> Unit) { + onTabSelected: (MetadataSupportedSearchType) -> Unit +) { val listState = rememberLazyListState() LazyRow( state = listState, @@ -570,7 +551,8 @@ private fun SearchTabs( MillenniumButtonStyle.Primary } else { MillenniumButtonStyle.Secondary - }) { + } + ) { Text(type.tabTitle()) } } @@ -600,7 +582,8 @@ private fun SearchAllTab( onArtistClick: (MetadataArtist.Basic) -> Unit, onAlbumClick: (MetadataAlbum.Detailed) -> Unit, onArtistsOverflowClick: (MetadataTrack) -> Unit, - modifier: Modifier = Modifier) { + modifier: Modifier = Modifier +) { if (query.isBlank()) { SearchMessage(message = "Start typing to search", modifier = modifier) return @@ -624,7 +607,8 @@ private fun SearchAllTab( ErrorDisplay( errorMessage = hasAnyError, onRetry = { }, - modifier = modifier) + modifier = modifier + ) return } @@ -665,7 +649,8 @@ private fun SearchAllTab( text = "Tracks", style = AppTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(horizontal = 16.dp).padding(bottom = 12.dp)) + modifier = Modifier.padding(horizontal = 16.dp).padding(bottom = 12.dp) + ) } if (MetadataSupportedSearchType.TRACK in supportedSearchTypes) { if (tracks.isLoading && tracks.items.isEmpty()) { @@ -743,7 +728,8 @@ private fun SearchTracksTab( onArtistClick: (MetadataArtist.Basic) -> Unit, onAlbumClick: (MetadataAlbum.Detailed) -> Unit, onArtistsOverflowClick: (MetadataTrack) -> Unit, - modifier: Modifier = Modifier) { + modifier: Modifier = Modifier +) { if (query.isBlank()) { SearchMessage(message = "Start typing to search tracks", modifier = modifier) return @@ -775,7 +761,8 @@ private fun SearchTracksTab( onAlbumClick = onAlbumClick, onArtistsOverflowClick = onArtistsOverflowClick, simplified = true, - modifier = modifier) + modifier = modifier + ) } @Composable @@ -783,7 +770,8 @@ private fun SearchAlbumsTab( query: String, albums: SearchPagedState, onLoadNextPage: () -> Unit, - modifier: Modifier = Modifier) { + modifier: Modifier = Modifier +) { SearchGridTab( query = query, items = albums.items, @@ -793,7 +781,8 @@ private fun SearchAlbumsTab( onLoadNextPage = onLoadNextPage, emptyMessage = "No albums found", loadingMessage = "Loading albums...", - modifier = modifier) { album -> + modifier = modifier + ) { album -> AlbumCard(album = album, modifier = Modifier.fillMaxWidth()) } } @@ -803,7 +792,8 @@ private fun SearchArtistsTab( query: String, artists: SearchPagedState, onLoadNextPage: () -> Unit, - modifier: Modifier = Modifier) { + modifier: Modifier = Modifier +) { SearchGridTab( query = query, items = artists.items, @@ -813,7 +803,8 @@ private fun SearchArtistsTab( onLoadNextPage = onLoadNextPage, emptyMessage = "No artists found", loadingMessage = "Loading artists...", - modifier = modifier) { artist -> + modifier = modifier + ) { artist -> ArtistCard(artist = artist, modifier = Modifier.fillMaxWidth()) } } @@ -823,7 +814,8 @@ private fun SearchPlaylistsTab( query: String, playlists: SearchPagedState, onLoadNextPage: () -> Unit, - modifier: Modifier = Modifier) { + modifier: Modifier = Modifier +) { SearchGridTab( query = query, items = playlists.items, @@ -833,7 +825,8 @@ private fun SearchPlaylistsTab( onLoadNextPage = onLoadNextPage, emptyMessage = "No playlists found", loadingMessage = "Loading playlists...", - modifier = modifier) { playlist -> + modifier = modifier + ) { playlist -> PlaylistCard(playlist = playlist, modifier = Modifier.fillMaxWidth()) } } @@ -843,7 +836,8 @@ private fun SearchUsersTab( query: String, users: SearchPagedState, onLoadNextPage: () -> Unit, - modifier: Modifier = Modifier) { + modifier: Modifier = Modifier +) { SearchGridTab( query = query, items = users.items, @@ -853,7 +847,8 @@ private fun SearchUsersTab( onLoadNextPage = onLoadNextPage, emptyMessage = "No users found", loadingMessage = "Loading users...", - modifier = modifier) { user -> + modifier = modifier + ) { user -> UserCard(user = user, modifier = Modifier.fillMaxWidth()) } } @@ -870,7 +865,8 @@ private fun SearchGridTab( emptyMessage: String, loadingMessage: String, modifier: Modifier = Modifier, - itemContent: @Composable (T) -> Unit) { + itemContent: @Composable (T) -> Unit +) { if (query.isBlank()) { SearchMessage(message = "Start typing to search", modifier = modifier) return @@ -897,14 +893,16 @@ private fun SearchGridTab( isLoading && items.isEmpty() -> { SearchLoadingIndicator( message = loadingMessage, - modifier = modifier) + modifier = modifier + ) } error != null && items.isEmpty() -> { ErrorDisplay( errorMessage = error, onRetry = onLoadNextPage, - modifier = modifier) + modifier = modifier + ) } !isLoading && items.isEmpty() -> { @@ -918,7 +916,8 @@ private fun SearchGridTab( modifier = modifier.padding(horizontal = 12.dp), contentPadding = PaddingValues(top = 12.dp, bottom = 16.dp + bottomInset), horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalArrangement = Arrangement.spacedBy(12.dp)) { + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { items(items) { item -> itemContent(item) } @@ -929,7 +928,8 @@ private fun SearchGridTab( PlayableCard( title = "Sample Title", subtitle = "Sample Subtitle", - imageURL = "https://placehold.co/600x400") + imageURL = "https://placehold.co/600x400" + ) } } } @@ -942,32 +942,38 @@ private fun SearchGridTab( private fun SearchHorizontalSection( title: String, onSeeAll: () -> Unit, - content: androidx.compose.foundation.lazy.LazyListScope.() -> Unit) { + content: androidx.compose.foundation.lazy.LazyListScope.() -> Unit +) { Column( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(12.dp)) { + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween) { + horizontalArrangement = Arrangement.SpaceBetween + ) { Text( text = title, style = AppTheme.typography.titleLarge, - fontWeight = FontWeight.SemiBold) + fontWeight = FontWeight.SemiBold + ) Text( text = "See all", style = AppTheme.typography.labelLarge, color = AppTheme.colors.primary, - modifier = Modifier.clickable(onClick = onSeeAll)) + modifier = Modifier.clickable(onClick = onSeeAll) + ) } LazyRow( contentPadding = PaddingValues(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), - content = content) + content = content + ) } } @@ -975,31 +981,37 @@ private fun SearchHorizontalSection( private fun SearchTrackRow( index: Int, track: MetadataTrack, - modifier: Modifier = Modifier) { + modifier: Modifier = Modifier +) { Row( modifier = modifier .fillMaxWidth() .padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp)) { + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { Text( text = (index + 1).toString(), style = AppTheme.typography.labelMedium, color = AppTheme.colors.onSurfaceVariant, - textAlign = TextAlign.End) + textAlign = TextAlign.End + ) Column( modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(2.dp)) { + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { Text( text = track.title, style = AppTheme.typography.bodyLarge, - maxLines = 1) + maxLines = 1 + ) Text( text = track.artists.joinToString { it.name }, style = AppTheme.typography.bodySmall, color = AppTheme.colors.onSurfaceVariant, - maxLines = 1) + maxLines = 1 + ) } if (track.album != null) Text( @@ -1007,7 +1019,8 @@ private fun SearchTrackRow( style = AppTheme.typography.bodyMedium, color = AppTheme.colors.onSurfaceVariant, maxLines = 1, - modifier = Modifier.weight(1f)) + modifier = Modifier.weight(1f) + ) } } @@ -1018,27 +1031,33 @@ private val WarmSearchMessages = listOf( "Dusting off the vinyl...", "Consulting the music gods...", "Tuning the antennas...", - "Flipping through the records...") + "Flipping through the records..." +) @Composable private fun SearchLoadingIndicator( message: String? = null, - modifier: Modifier = Modifier) { + modifier: Modifier = Modifier +) { val infiniteTransition = rememberInfiniteTransition(label = "search-loading") val rotation by infiniteTransition.animateFloat( initialValue = 0f, targetValue = 360f, animationSpec = infiniteRepeatable( animation = tween(durationMillis = 1500, easing = LinearEasing), - repeatMode = RepeatMode.Restart), - label = "icon-rotation") + repeatMode = RepeatMode.Restart + ), + label = "icon-rotation" + ) val bounce by infiniteTransition.animateFloat( initialValue = -8f, targetValue = 8f, animationSpec = infiniteRepeatable( animation = tween(durationMillis = 800, easing = LinearEasing), - repeatMode = RepeatMode.Reverse), - label = "icon-bounce") + repeatMode = RepeatMode.Reverse + ), + label = "icon-bounce" + ) val messageIndex by infiniteTransition.animateFloat( initialValue = 0f, targetValue = WarmSearchMessages.size.toFloat(), @@ -1047,18 +1066,22 @@ private fun SearchLoadingIndicator( durationMillis = WarmSearchMessages.size * 2000, easing = LinearEasing ), - repeatMode = RepeatMode.Restart), - label = "message-cycle") + repeatMode = RepeatMode.Restart + ), + label = "message-cycle" + ) val displayMessage = message ?: WarmSearchMessages[messageIndex.toInt() % WarmSearchMessages.size] Column( modifier = modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center) { + verticalArrangement = Arrangement.Center + ) { Box( modifier = Modifier.size(64.dp), - contentAlignment = Alignment.Center) { + contentAlignment = Alignment.Center + ) { MillenniumIcon( imageVector = Iconsax.IconsaxSearchBroken, contentDescription = "Searching", @@ -1073,18 +1096,21 @@ private fun SearchLoadingIndicator( MillenniumIndeterminateProgress( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 32.dp, vertical = 16.dp)) + .padding(horizontal = 32.dp, vertical = 16.dp) + ) AnimatedContent( targetState = displayMessage, transitionSpec = { fadeIn(tween(300)) togetherWith fadeOut(tween(300)) }, - label = "message-transition") { msg -> + label = "message-transition" + ) { msg -> Text( text = msg, color = AppTheme.colors.onSurfaceVariant, style = AppTheme.typography.bodyMedium, - textAlign = TextAlign.Center) + textAlign = TextAlign.Center + ) } } } @@ -1093,30 +1119,35 @@ private fun SearchLoadingIndicator( private fun SearchMessage( message: String, modifier: Modifier = Modifier, - isError: Boolean = false) { + isError: Boolean = false +) { Box( modifier = modifier, - contentAlignment = Alignment.Center) { + contentAlignment = Alignment.Center + ) { Text( text = message, color = if (isError) AppTheme.colors.error else AppTheme.colors.onSurfaceVariant, style = AppTheme.typography.bodyMedium, textAlign = TextAlign.Center, - modifier = Modifier.padding(16.dp)) + modifier = Modifier.padding(16.dp) + ) } } @Composable private fun SearchMessageInline( message: String, - isError: Boolean = false) { + isError: Boolean = false +) { Text( text = message, color = if (isError) AppTheme.colors.error else AppTheme.colors.onSurfaceVariant, style = AppTheme.typography.bodyMedium, modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp)) + .padding(horizontal = 16.dp) + ) } @Composable diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreenViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreenViewModel.kt index 5134106a..44b0b425 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreenViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreenViewModel.kt @@ -59,7 +59,14 @@ data class SearchScreenState( val albums: SearchPagedState = SearchPagedState(), val artists: SearchPagedState = SearchPagedState(), val playlists: SearchPagedState = SearchPagedState(), - val users: SearchPagedState = SearchPagedState()) + val users: SearchPagedState = SearchPagedState()) { + val recentSearchSuggestions: List + get() = if (query.isBlank()) { + recentSearches + } else { + recentSearches.filter { it.contains(query, ignoreCase = true) } + } +} @OptIn(ExperimentalCoroutinesApi::class) class SearchScreenViewModel( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt index 2a5d0ed8..38428743 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt @@ -50,10 +50,14 @@ import dev.krtirtho.spotube.modules.support.LocalSupportPrompt import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart2 import dev.krtirtho.spotube.resources.iconsax.IconsaxSetting2 +import dev.krtirtho.spotube.resources.iconsax.IconsaxSidebarLeftBroken +import dev.krtirtho.spotube.resources.iconsax.IconsaxSidebarRightBroken import dev.krtirtho.spotube.sidebarTabs import org.jetbrains.compose.resources.Font import org.jetbrains.compose.resources.stringResource import org.koin.compose.koinInject +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle import org.spotube.millennium.composables.MillenniumSidebar import org.spotube.millennium.composables.MillenniumSidebarContent import org.spotube.millennium.composables.MillenniumSidebarFooter @@ -89,7 +93,7 @@ fun AppSidebar( ) { MillenniumSidebarHeader { Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 8.dp), + modifier = Modifier.fillMaxWidth(), horizontalArrangement = if (expanded) Arrangement.SpaceBetween else Arrangement.Center, verticalAlignment = Alignment.CenterVertically ) { @@ -104,7 +108,14 @@ fun AppSidebar( color = AppTheme.colors.onSurface ) } - MillenniumSidebarTrigger() + MillenniumSidebarTrigger { + MillenniumIconButton( + if (it.collapsed) Iconsax.IconsaxSidebarRightBroken else Iconsax.IconsaxSidebarLeftBroken, + contentDescription = "Toggle Sidebar", + style = MillenniumIconButtonStyle.Quiet, + onClick = { it.toggle() } + ) + } } } MillenniumSidebarContent { @@ -120,8 +131,9 @@ fun AppSidebar( MillenniumSidebarGroupContent { MillenniumSidebarMenu { entry.items.forEach { item -> - val selected = navigationState.topLevelRoute == item.route && - (item.route != Routes.Library || currentLibraryTab == item.data) + val selected = + navigationState.topLevelRoute == item.route && + (item.route != Routes.Library || currentLibraryTab == item.data) SidebarNavItem( item = item, selected = selected, @@ -154,13 +166,15 @@ fun AppSidebar( label = supportLabel, onClick = { openSupport() }, contentDescription = supportLabel, - icon = Iconsax.IconsaxHeart2) + icon = Iconsax.IconsaxHeart2 + ) MillenniumSidebarItem( label = stringResource(Res.string.settings_screen_title), onClick = { navigator.navigate(Routes.Settings) }, selected = navigationState.topLevelRoute == Routes.Settings, contentDescription = stringResource(Res.string.settings_screen_title), - icon = Iconsax.IconsaxSetting2) + icon = Iconsax.IconsaxSetting2 + ) } } } @@ -169,11 +183,13 @@ fun AppSidebar( private fun SidebarNavItem( item: NavigationItem.Tab<*>, selected: Boolean, - onClick: () -> Unit) { + onClick: () -> Unit +) { MillenniumSidebarItem( label = item.title, onClick = onClick, selected = selected, contentDescription = item.title, - icon = item.icon) + icon = item.icon + ) }