feat: add AutocompleteTextField component and integrate it into SearchScreen

This commit is contained in:
Kingkor Roy Tirtho 2026-07-03 15:15:04 +06:00
parent 8870113e4d
commit 7c01b2c7c3
3 changed files with 529 additions and 146 deletions

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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 <T> AutocompleteTextField(
value: String,
onValueChange: (String) -> Unit,
items: List<T>,
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,
)
}
},
)
}
}
}

View File

@ -65,7 +65,7 @@ private val TextFieldShape = RoundedCornerShape(14.dp)
private val TextFieldMinHeight = 44.dp private val TextFieldMinHeight = 44.dp
@Composable @Composable
private fun textFieldShadow( internal fun textFieldShadow(
shape: androidx.compose.ui.graphics.Shape, shape: androidx.compose.ui.graphics.Shape,
focused: Boolean, focused: Boolean,
colors: ButtonColors, colors: ButtonColors,

View File

@ -37,7 +37,6 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.material3.LinearProgressIndicator 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.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions 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.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -60,7 +57,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
@ -69,21 +65,17 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.graphicsLayer 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.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.PopupProperties
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import compose.icons.FeatherIcons import compose.icons.FeatherIcons
import compose.icons.feathericons.X 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.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.share.ShareService 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.base.ChipTab
import dev.krtirtho.spotube.core.ui.component.AlbumCard import dev.krtirtho.spotube.core.ui.component.AlbumCard
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
import dev.krtirtho.spotube.core.ui.component.ArtistCard import dev.krtirtho.spotube.core.ui.component.ArtistCard
import dev.krtirtho.spotube.core.ui.component.ErrorDisplay 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.PlaylistCard
import dev.krtirtho.spotube.core.ui.component.TrackList import dev.krtirtho.spotube.core.ui.component.TrackList
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
@ -122,8 +114,6 @@ import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
private val GridMinCellSize = 180.dp private val GridMinCellSize = 180.dp
private val SearchFieldShape = RoundedCornerShape(6.dp)
private val TabShape = RoundedCornerShape(6.dp)
@Composable @Composable
fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
@ -138,7 +128,6 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
var isSearchFocused by remember { mutableStateOf(false) } var isSearchFocused by remember { mutableStateOf(false) }
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
var keyboardSelectedIndex by remember { mutableIntStateOf(-1) }
fun playSingleTrack(track: MetadataTrack) { fun playSingleTrack(track: MetadataTrack) {
scope.launch { scope.launch {
@ -242,29 +231,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
} }
} }
val showRecentSearches = val showDropdown =
isSearchFocused && state.query.isBlank() && state.recentSearches.isNotEmpty() 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( Scaffold(
topBar = { ApplicationMainBar(backButton = false) } topBar = { ApplicationMainBar(backButton = false) }
@ -279,60 +247,26 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
) { ) {
SearchBar( SearchBar(
query = state.query, query = state.query,
onQueryChange = { onQueryChange = viewModel::onQueryChange,
viewModel.onQueryChange(it)
keyboardSelectedIndex = -1
},
onClear = viewModel::clearQuery, onClear = viewModel::clearQuery,
isFocused = isSearchFocused, isFocused = isSearchFocused,
onFocusChanged = { isSearchFocused = it }, onFocusChanged = { isSearchFocused = it },
focusRequester = focusRequester, 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 = { onSearch = {
focusManager.clearFocus() focusManager.clearFocus()
isSearchFocused = false isSearchFocused = false
}, },
showDropdown = showRecentSearches, showDropdown = showDropdown,
onDismissDropdown = {
isSearchFocused = false
keyboardSelectedIndex = -1
},
recentSearches = state.recentSearches, recentSearches = state.recentSearches,
keyboardSelectedIndex = keyboardSelectedIndex,
onRecentSearchClick = { search -> onRecentSearchClick = { search ->
viewModel.applyRecentSearch(search) viewModel.applyRecentSearch(search)
isSearchFocused = false isSearchFocused = false
keyboardSelectedIndex = -1
focusManager.clearFocus() focusManager.clearFocus()
}, },
onRecentSearchRemove = viewModel::removeRecentSearch, onRecentSearchRemove = viewModel::removeRecentSearch,
onClearAllRecentSearches = { onClearAllRecentSearches = {
viewModel.clearAllRecentSearches() viewModel.clearAllRecentSearches()
isSearchFocused = false isSearchFocused = false
keyboardSelectedIndex = -1
focusManager.clearFocus() 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 @Composable
private fun SearchBar( private fun SearchBar(
query: String, query: String,
@ -451,27 +396,50 @@ private fun SearchBar(
isFocused: Boolean, isFocused: Boolean,
onFocusChanged: (Boolean) -> Unit, onFocusChanged: (Boolean) -> Unit,
focusRequester: FocusRequester, focusRequester: FocusRequester,
onKeyEvent: (androidx.compose.ui.input.key.KeyEvent) -> Boolean,
onSearch: () -> Unit, onSearch: () -> Unit,
showDropdown: Boolean, showDropdown: Boolean,
onDismissDropdown: () -> Unit,
recentSearches: List<String>, recentSearches: List<String>,
keyboardSelectedIndex: Int,
onRecentSearchClick: (String) -> Unit, onRecentSearchClick: (String) -> Unit,
onRecentSearchRemove: (String) -> Unit, onRecentSearchRemove: (String) -> Unit,
onClearAllRecentSearches: () -> 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( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
.padding(bottom = 8.dp) .padding(bottom = 8.dp)
) { ) {
TextField( AutocompleteTextField(
value = query, value = query,
onValueChange = onQueryChange, onValueChange = onQueryChange,
singleLine = true, items = dropdownItems,
maxLines = 1, onItemSelected = { entry -> entry.onClick() },
expanded = showDropdown,
onExpandedChange = { expanded ->
if (!expanded) {
onFocusChanged(false)
focusManager.clearFocus()
}
},
placeholder = { placeholder = {
Text( Text(
"Search songs, artists, albums...", "Search songs, artists, albums...",
@ -501,77 +469,80 @@ private fun SearchBar(
} }
} }
}, },
singleLine = true,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.onFocusChanged { onFocusChanged(it.isFocused) } .onFocusChanged { onFocusChanged(it.isFocused) }
.focusRequester(focusRequester) .focusRequester(focusRequester),
.onKeyEvent(onKeyEvent),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = { onSearch() }), keyboardActions = KeyboardActions(onSearch = { onSearch() }),
) itemContent = { entry, isSelected ->
when (entry) {
DropdownMenu( is SearchDropdownEntry.ClearAll -> {
expanded = showDropdown, Box(
onDismissRequest = onDismissDropdown,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(max = 300.dp), .padding(horizontal = 4.dp, vertical = 2.dp)
offset = DpOffset(x = 0.dp, y = 4.dp), .clip(RoundedCornerShape(4.dp))
properties = PopupProperties(focusable = false), .background(
containerColor = MaterialTheme.colorScheme.surface, if (isSelected) MaterialTheme.colorScheme.surfaceVariant
shape = RoundedCornerShape(8.dp), else Color.Transparent
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, Row(
leadingIcon = { modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Icon( Icon(
imageVector = Iconsax.IconsaxTrash, imageVector = Iconsax.IconsaxTrash,
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.error, tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(16.dp) modifier = Modifier.size(16.dp)
) )
}, Text(
"Clear all history",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium
)
}
}
}
is SearchDropdownEntry.RecentSearch -> {
Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp, vertical = 2.dp) .padding(horizontal = 4.dp, vertical = 2.dp)
.clip(RoundedCornerShape(4.dp)) .clip(RoundedCornerShape(4.dp))
.background( .background(
if (keyboardSelectedIndex == 0) MaterialTheme.colorScheme.surfaceVariant if (isSelected) MaterialTheme.colorScheme.surfaceVariant
else MaterialTheme.colorScheme.surface else Color.Transparent
) )
) ) {
Row(
recentSearches.forEachIndexed { index, search -> modifier = Modifier
val isSelected = keyboardSelectedIndex == index + 1 .fillMaxWidth()
DropdownMenuItem( .padding(horizontal = 12.dp, vertical = 10.dp),
text = { verticalAlignment = Alignment.CenterVertically,
Text( horizontalArrangement = Arrangement.spacedBy(10.dp),
search, ) {
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodyMedium
)
},
leadingIcon = {
Icon( Icon(
imageVector = Iconsax.InconsaxClock, imageVector = Iconsax.InconsaxClock,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(16.dp), modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant tint = MaterialTheme.colorScheme.onSurfaceVariant
) )
}, Text(
trailingIcon = { entry.query,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f)
)
IconButton( IconButton(
onClick = { onRecentSearchRemove(search) }, onClick = entry.onRemove,
modifier = Modifier.size(24.dp) modifier = Modifier.size(24.dp)
) { ) {
Icon( Icon(
@ -581,20 +552,12 @@ private fun SearchBar(
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) 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
) )
)
}
}
}
}
} }
} }