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 - ) - ) + } + } } } - } - } + }, + ) } }