diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/AutocompleteTextField.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/AutocompleteTextField.kt
new file mode 100644
index 00000000..43c3cce7
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/AutocompleteTextField.kt
@@ -0,0 +1,420 @@
+/*
+ * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package dev.krtirtho.spotube.core.ui.base
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.hoverable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.collectIsFocusedAsState
+import androidx.compose.foundation.interaction.collectIsHoveredAsState
+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.defaultMinSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+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.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.LocalTextStyle
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+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.draw.drawWithCache
+import androidx.compose.ui.draw.shadow
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.KeyEvent
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.onKeyEvent
+import androidx.compose.ui.layout.onSizeChanged
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.input.VisualTransformation
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Popup
+import androidx.compose.ui.window.PopupProperties
+import dev.krtirtho.spotube.resources.iconsax.Iconsax
+import dev.krtirtho.spotube.resources.iconsax.IconsaxSearchBroken
+
+private val AutocompleteTextFieldShape = RoundedCornerShape(14.dp)
+private val AutocompleteMenuShape = RoundedCornerShape(12.dp)
+private val AutocompleteTextFieldMinHeight = 44.dp
+private val AutocompleteMenuDefaultMaxHeight = 300.dp
+private val AutocompleteMenuDefaultOffset = 4.dp
+
+@Composable
+fun AutocompleteTextField(
+ value: String,
+ onValueChange: (String) -> Unit,
+ items: List,
+ itemContent: @Composable (item: T, isSelected: Boolean) -> Unit,
+ modifier: Modifier = Modifier,
+ expanded: Boolean = false,
+ onExpandedChange: (Boolean) -> Unit = {},
+ onItemSelected: (T) -> Unit = {},
+ placeholder: @Composable (() -> Unit)? = null,
+ leadingIcon: @Composable (() -> Unit)? = null,
+ trailingIcon: @Composable (() -> Unit)? = null,
+ label: @Composable (() -> Unit)? = null,
+ singleLine: Boolean = true,
+ maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
+ enabled: Boolean = true,
+ readOnly: Boolean = false,
+ isError: Boolean = false,
+ keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
+ keyboardActions: KeyboardActions = KeyboardActions.Default,
+ visualTransformation: VisualTransformation = VisualTransformation.None,
+ interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
+ textStyle: TextStyle = TextStyle.Default,
+ cursorBrush: Color = MaterialTheme.colorScheme.primary,
+ onKeyEvent: ((KeyEvent) -> Boolean)? = null,
+ menuMaxHeight: Dp = AutocompleteMenuDefaultMaxHeight,
+ menuOffset: Dp = AutocompleteMenuDefaultOffset,
+ menuShape: Shape = AutocompleteMenuShape,
+ menuShadowElevation: Dp = 8.dp,
+ menuContainerColor: Color = MaterialTheme.colorScheme.surfaceContainerHigh,
+) {
+ val density = LocalDensity.current
+ val colors = rememberButtonColors()
+ val isFocused by interactionSource.collectIsFocusedAsState()
+ val isHovered by interactionSource.collectIsHoveredAsState()
+
+ var selectedIndex by remember { mutableIntStateOf(-1) }
+ var textFieldSize by remember { mutableStateOf(IntSize.Zero) }
+ val menuListState = rememberLazyListState()
+
+ val effectiveExpanded = expanded && items.isNotEmpty()
+
+ LaunchedEffect(value) {
+ if (selectedIndex != -1) selectedIndex = -1
+ }
+
+ LaunchedEffect(selectedIndex) {
+ if (selectedIndex >= 0 && effectiveExpanded) {
+ menuListState.scrollToItem(selectedIndex)
+ }
+ }
+
+ val wrappedKeyboardActions = remember(keyboardActions, effectiveExpanded, selectedIndex, items) {
+ KeyboardActions(
+ onSearch = {
+ if (effectiveExpanded && selectedIndex in items.indices) {
+ onItemSelected(items[selectedIndex])
+ selectedIndex = -1
+ onExpandedChange(false)
+ } else {
+ keyboardActions.onSearch?.invoke(this)
+ }
+ },
+ onDone = keyboardActions.onDone,
+ onGo = keyboardActions.onGo,
+ onNext = keyboardActions.onNext,
+ onPrevious = keyboardActions.onPrevious,
+ onSend = keyboardActions.onSend,
+ )
+ }
+
+ val gradient = outlinedGradient(colors, false)
+ val border = when {
+ isError -> MaterialTheme.colorScheme.error
+ isFocused -> MaterialTheme.colorScheme.primary
+ isHovered -> colors.border.copy(alpha = 0.85f)
+ else -> colors.border
+ }
+
+ val contentColor = when {
+ !enabled -> colors.onContainer.copy(alpha = 0.38f)
+ else -> colors.onContainer
+ }
+
+ fun navigate(delta: Int) {
+ if (items.isEmpty()) return
+ selectedIndex = ((selectedIndex + delta) % items.size + items.size) % items.size
+ }
+
+ Box(modifier = modifier) {
+ Column {
+ AnimatedVisibility(
+ visible = label != null,
+ enter = fadeIn(),
+ exit = fadeOut(),
+ ) {
+ label?.invoke()
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .onSizeChanged { textFieldSize = it }
+ .defaultMinSize(minHeight = AutocompleteTextFieldMinHeight)
+ .then(textFieldShadow(AutocompleteTextFieldShape, isFocused, colors))
+ .clip(AutocompleteTextFieldShape)
+ .background(gradient, AutocompleteTextFieldShape)
+ .border(BorderStroke(1.5.dp, border), AutocompleteTextFieldShape)
+ .drawWithCache {
+ val highlightBrush = Brush.verticalGradient(
+ colors = listOf(colors.highlight, Color.Transparent),
+ startY = 0f,
+ endY = size.height * 0.5f,
+ )
+ onDrawWithContent {
+ drawContent()
+ drawRect(
+ brush = highlightBrush,
+ topLeft = androidx.compose.ui.geometry.Offset.Zero,
+ size = size,
+ )
+ }
+ }
+ .padding(horizontal = 14.dp, vertical = 10.dp),
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ if (leadingIcon != null) {
+ leadingIcon()
+ }
+ Box(modifier = Modifier.weight(1f)) {
+ BasicTextField(
+ value = value,
+ onValueChange = onValueChange,
+ modifier = Modifier
+ .fillMaxWidth()
+ .hoverable(
+ interactionSource = interactionSource,
+ enabled = enabled,
+ )
+ .onKeyEvent { event ->
+ if (onKeyEvent?.invoke(event) == true) {
+ return@onKeyEvent true
+ }
+ if (!effectiveExpanded) return@onKeyEvent false
+ when (event.key) {
+ Key.DirectionDown -> {
+ navigate(1); true
+ }
+
+ Key.DirectionUp -> {
+ navigate(-1); true
+ }
+
+ Key.Enter -> {
+ if (selectedIndex in items.indices) {
+ onItemSelected(items[selectedIndex])
+ selectedIndex = -1
+ onExpandedChange(false)
+ return@onKeyEvent true
+ }
+ false
+ }
+
+ Key.Escape -> {
+ selectedIndex = -1
+ onExpandedChange(false)
+ true
+ }
+
+ else -> false
+ }
+ },
+ enabled = enabled,
+ readOnly = readOnly,
+ textStyle = textStyle.copy(color = contentColor),
+ cursorBrush = SolidColor(cursorBrush),
+ keyboardOptions = keyboardOptions,
+ keyboardActions = wrappedKeyboardActions,
+ singleLine = singleLine,
+ maxLines = maxLines,
+ visualTransformation = visualTransformation,
+ interactionSource = interactionSource,
+ decorationBox = { innerTextField ->
+ if (value.isEmpty() && placeholder != null && !isFocused) {
+ CompositionLocalProvider(LocalTextStyle provides textStyle) {
+ Box(contentAlignment = Alignment.CenterStart) {
+ placeholder()
+ }
+ }
+ } else {
+ innerTextField()
+ }
+ },
+ )
+ }
+
+ if (trailingIcon != null) {
+ trailingIcon()
+ }
+ }
+ }
+ }
+
+ if (effectiveExpanded && textFieldSize.width > 0) {
+ val textFieldHeightPx = textFieldSize.height
+ val menuOffsetPx = with(density) { menuOffset.roundToPx() }
+ val textFieldWidthDp = with(density) { textFieldSize.width.toDp() }
+
+ Popup(
+ alignment = Alignment.TopStart,
+ offset = IntOffset(0, textFieldHeightPx + menuOffsetPx),
+ properties = PopupProperties(focusable = true),
+ onDismissRequest = {
+ selectedIndex = -1
+ onExpandedChange(false)
+ },
+ ) {
+ Box(
+ modifier = Modifier
+ .width(textFieldWidthDp)
+ .heightIn(max = menuMaxHeight)
+ .shadow(menuShadowElevation, menuShape)
+ .background(menuContainerColor, menuShape)
+ .clip(menuShape),
+ ) {
+ LazyColumn(
+ state = menuListState,
+ modifier = Modifier
+ .heightIn(max = menuMaxHeight),
+ ) {
+ items(items.size) { index ->
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable {
+ onItemSelected(items[index])
+ selectedIndex = -1
+ onExpandedChange(false)
+ },
+ ) {
+ itemContent(items[index], selectedIndex == index)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun AutocompleteTextFieldPreview() {
+ MaterialTheme {
+ androidx.compose.material3.Surface(
+ color = MaterialTheme.colorScheme.background,
+ modifier = Modifier.padding(24.dp),
+ ) {
+ val artists = remember {
+ listOf(
+ "Twenty One Pilots",
+ "The Beatles",
+ "Adele",
+ "Drake",
+ "Taylor Swift",
+ "Arctic Monkeys",
+ "Billie Eilish",
+ )
+ }
+ var value by remember { mutableStateOf("") }
+ var expanded by remember { mutableStateOf(false) }
+
+ AutocompleteTextField(
+ value = value,
+ onValueChange = {
+ value = it
+ expanded = it.isNotEmpty()
+ },
+ items = artists.filter {
+ it.contains(value, ignoreCase = true) && value.isNotEmpty()
+ },
+ onItemSelected = { selected ->
+ value = selected
+ expanded = false
+ },
+ expanded = expanded,
+ onExpandedChange = { expanded = it },
+ placeholder = { Text("Search artists...") },
+ leadingIcon = {
+ androidx.compose.material3.Icon(
+ imageVector = Iconsax.IconsaxSearchBroken,
+ contentDescription = "Search",
+ )
+ },
+ modifier = Modifier.fillMaxWidth(),
+ itemContent = { item, isSelected ->
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(
+ color = if (isSelected) {
+ MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f)
+ } else {
+ Color.Transparent
+ },
+ )
+ .padding(horizontal = 14.dp, vertical = 10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ androidx.compose.material3.Icon(
+ imageVector = Iconsax.IconsaxSearchBroken,
+ contentDescription = null,
+ modifier = Modifier.size(16.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Text(
+ text = item,
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ }
+ },
+ )
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/TextField.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/TextField.kt
index de3e727e..44b01808 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/TextField.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/TextField.kt
@@ -65,7 +65,7 @@ private val TextFieldShape = RoundedCornerShape(14.dp)
private val TextFieldMinHeight = 44.dp
@Composable
-private fun textFieldShadow(
+internal fun textFieldShadow(
shape: androidx.compose.ui.graphics.Shape,
focused: Boolean,
colors: ButtonColors,
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 496286ea..9cdbc658 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
@@ -37,7 +37,6 @@ 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.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.LinearProgressIndicator
@@ -50,8 +49,6 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
-import androidx.compose.material3.DropdownMenu
-import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -60,7 +57,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -69,21 +65,17 @@ import androidx.compose.runtime.snapshotFlow
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.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.graphicsLayer
-import androidx.compose.ui.input.key.Key
-import androidx.compose.ui.input.key.key
-import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
-import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
-import androidx.compose.ui.window.PopupProperties
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import compose.icons.FeatherIcons
import compose.icons.feathericons.X
@@ -96,12 +88,12 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUser
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.share.ShareService
+import dev.krtirtho.spotube.core.ui.base.AutocompleteTextField
import dev.krtirtho.spotube.core.ui.base.ChipTab
import dev.krtirtho.spotube.core.ui.component.AlbumCard
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
import dev.krtirtho.spotube.core.ui.component.ArtistCard
import dev.krtirtho.spotube.core.ui.component.ErrorDisplay
-import dev.krtirtho.spotube.core.ui.base.TextField
import dev.krtirtho.spotube.core.ui.component.PlaylistCard
import dev.krtirtho.spotube.core.ui.component.TrackList
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
@@ -122,8 +114,6 @@ import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
private val GridMinCellSize = 180.dp
-private val SearchFieldShape = RoundedCornerShape(6.dp)
-private val TabShape = RoundedCornerShape(6.dp)
@Composable
fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
@@ -138,7 +128,6 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
var isSearchFocused by remember { mutableStateOf(false) }
val focusManager = LocalFocusManager.current
val focusRequester = remember { FocusRequester() }
- var keyboardSelectedIndex by remember { mutableIntStateOf(-1) }
fun playSingleTrack(track: MetadataTrack) {
scope.launch {
@@ -242,29 +231,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
}
}
- val showRecentSearches =
+ val showDropdown =
isSearchFocused && state.query.isBlank() && state.recentSearches.isNotEmpty()
- val dropdownItems = if (showRecentSearches) state.recentSearches else emptyList()
- val totalDropdownItems = if (showRecentSearches) dropdownItems.size + 1 else 0
-
- fun navigateDropdown(delta: Int) {
- if (totalDropdownItems == 0) return
- keyboardSelectedIndex =
- (keyboardSelectedIndex + delta + totalDropdownItems) % totalDropdownItems
- }
-
- fun selectDropdownItem() {
- if (keyboardSelectedIndex < 0 || !showRecentSearches) return
- if (keyboardSelectedIndex == 0) {
- viewModel.clearAllRecentSearches()
- } else {
- val item = dropdownItems[keyboardSelectedIndex - 1]
- viewModel.applyRecentSearch(item)
- }
- keyboardSelectedIndex = -1
- isSearchFocused = false
- focusManager.clearFocus()
- }
Scaffold(
topBar = { ApplicationMainBar(backButton = false) }
@@ -279,60 +247,26 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
) {
SearchBar(
query = state.query,
- onQueryChange = {
- viewModel.onQueryChange(it)
- keyboardSelectedIndex = -1
- },
+ onQueryChange = viewModel::onQueryChange,
onClear = viewModel::clearQuery,
isFocused = isSearchFocused,
onFocusChanged = { isSearchFocused = it },
focusRequester = focusRequester,
- onKeyEvent = { event ->
- when (event.key) {
- Key.DirectionDown -> {
- navigateDropdown(1); true
- }
-
- Key.DirectionUp -> {
- navigateDropdown(-1); true
- }
-
- Key.Enter -> {
- selectDropdownItem(); true
- }
-
- Key.Escape -> {
- isSearchFocused = false
- keyboardSelectedIndex = -1
- focusManager.clearFocus()
- true
- }
-
- else -> false
- }
- },
onSearch = {
focusManager.clearFocus()
isSearchFocused = false
},
- showDropdown = showRecentSearches,
- onDismissDropdown = {
- isSearchFocused = false
- keyboardSelectedIndex = -1
- },
+ showDropdown = showDropdown,
recentSearches = state.recentSearches,
- keyboardSelectedIndex = keyboardSelectedIndex,
onRecentSearchClick = { search ->
viewModel.applyRecentSearch(search)
isSearchFocused = false
- keyboardSelectedIndex = -1
focusManager.clearFocus()
},
onRecentSearchRemove = viewModel::removeRecentSearch,
onClearAllRecentSearches = {
viewModel.clearAllRecentSearches()
isSearchFocused = false
- keyboardSelectedIndex = -1
focusManager.clearFocus()
},
)
@@ -443,6 +377,17 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
}
}
+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 SearchBar(
query: String,
@@ -451,27 +396,50 @@ private fun SearchBar(
isFocused: Boolean,
onFocusChanged: (Boolean) -> Unit,
focusRequester: FocusRequester,
- onKeyEvent: (androidx.compose.ui.input.key.KeyEvent) -> Boolean,
onSearch: () -> Unit,
showDropdown: Boolean,
- onDismissDropdown: () -> Unit,
recentSearches: List,
- keyboardSelectedIndex: Int,
onRecentSearchClick: (String) -> Unit,
onRecentSearchRemove: (String) -> Unit,
onClearAllRecentSearches: () -> Unit,
) {
+ val dropdownItems = remember(showDropdown, recentSearches) {
+ buildList {
+ if (showDropdown) {
+ add(SearchDropdownEntry.ClearAll(onClick = onClearAllRecentSearches))
+ recentSearches.forEach { search ->
+ add(
+ SearchDropdownEntry.RecentSearch(
+ query = search,
+ onClick = { onRecentSearchClick(search) },
+ onRemove = { onRecentSearchRemove(search) },
+ )
+ )
+ }
+ }
+ }
+ }
+
+ val focusManager = LocalFocusManager.current
+
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(bottom = 8.dp)
) {
- TextField(
+ AutocompleteTextField(
value = query,
onValueChange = onQueryChange,
- singleLine = true,
- maxLines = 1,
+ items = dropdownItems,
+ onItemSelected = { entry -> entry.onClick() },
+ expanded = showDropdown,
+ onExpandedChange = { expanded ->
+ if (!expanded) {
+ onFocusChanged(false)
+ focusManager.clearFocus()
+ }
+ },
placeholder = {
Text(
"Search songs, artists, albums...",
@@ -501,77 +469,80 @@ private fun SearchBar(
}
}
},
+ singleLine = true,
modifier = Modifier
.fillMaxWidth()
.onFocusChanged { onFocusChanged(it.isFocused) }
- .focusRequester(focusRequester)
- .onKeyEvent(onKeyEvent),
+ .focusRequester(focusRequester),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = { onSearch() }),
- )
-
- DropdownMenu(
- expanded = showDropdown,
- onDismissRequest = onDismissDropdown,
- modifier = Modifier
- .fillMaxWidth()
- .heightIn(max = 300.dp),
- offset = DpOffset(x = 0.dp, y = 4.dp),
- properties = PopupProperties(focusable = false),
- containerColor = MaterialTheme.colorScheme.surface,
- shape = RoundedCornerShape(8.dp),
- shadowElevation = 8.dp,
- ) {
- Box(modifier = Modifier.padding(vertical = 4.dp)) {
- Column {
- DropdownMenuItem(
- text = {
- Text(
- "Clear all history",
- color = MaterialTheme.colorScheme.error,
- style = MaterialTheme.typography.bodyMedium
- )
- },
- onClick = onClearAllRecentSearches,
- leadingIcon = {
- Icon(
- imageVector = Iconsax.IconsaxTrash,
- contentDescription = null,
- tint = MaterialTheme.colorScheme.error,
- modifier = Modifier.size(16.dp)
- )
- },
- modifier = Modifier
- .padding(horizontal = 4.dp, vertical = 2.dp)
- .clip(RoundedCornerShape(4.dp))
- .background(
- if (keyboardSelectedIndex == 0) MaterialTheme.colorScheme.surfaceVariant
- else MaterialTheme.colorScheme.surface
- )
- )
-
- recentSearches.forEachIndexed { index, search ->
- val isSelected = keyboardSelectedIndex == index + 1
- DropdownMenuItem(
- text = {
+ itemContent = { entry, isSelected ->
+ when (entry) {
+ is SearchDropdownEntry.ClearAll -> {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 4.dp, vertical = 2.dp)
+ .clip(RoundedCornerShape(4.dp))
+ .background(
+ if (isSelected) MaterialTheme.colorScheme.surfaceVariant
+ else Color.Transparent
+ )
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 12.dp, vertical = 10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ Icon(
+ imageVector = Iconsax.IconsaxTrash,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.error,
+ modifier = Modifier.size(16.dp)
+ )
Text(
- search,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
+ "Clear all history",
+ color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium
)
- },
- leadingIcon = {
+ }
+ }
+ }
+ is SearchDropdownEntry.RecentSearch -> {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 4.dp, vertical = 2.dp)
+ .clip(RoundedCornerShape(4.dp))
+ .background(
+ if (isSelected) MaterialTheme.colorScheme.surfaceVariant
+ else Color.Transparent
+ )
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 12.dp, vertical = 10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
Icon(
imageVector = Iconsax.InconsaxClock,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
- },
- trailingIcon = {
+ Text(
+ entry.query,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.weight(1f)
+ )
IconButton(
- onClick = { onRecentSearchRemove(search) },
+ onClick = entry.onRemove,
modifier = Modifier.size(24.dp)
) {
Icon(
@@ -581,20 +552,12 @@ private fun SearchBar(
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
)
}
- },
- onClick = { onRecentSearchClick(search) },
- modifier = Modifier
- .padding(horizontal = 4.dp, vertical = 2.dp)
- .clip(RoundedCornerShape(4.dp))
- .background(
- if (isSelected) MaterialTheme.colorScheme.surfaceVariant
- else MaterialTheme.colorScheme.surface
- )
- )
+ }
+ }
}
}
- }
- }
+ },
+ )
}
}