mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-08-05 19:59:51 +00:00
Compare commits
5 Commits
8870113e4d
...
6d8b9a575f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d8b9a575f | ||
|
|
d7fa3d926d | ||
|
|
a869835628 | ||
|
|
15790d63a6 | ||
|
|
7c01b2c7c3 |
@ -19,6 +19,7 @@ package dev.krtirtho.spotube.core.audioplayer
|
|||||||
|
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol
|
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol
|
||||||
import dev.krtirtho.spotube.core.di.injectLogger
|
import dev.krtirtho.spotube.core.di.injectLogger
|
||||||
|
import dev.krtirtho.spotube.modules.plugin.PluginManager
|
||||||
import dev.krtirtho.spotube.modules.settings.SettingsViewModel
|
import dev.krtirtho.spotube.modules.settings.SettingsViewModel
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@ -36,12 +37,14 @@ import kotlinx.coroutines.flow.stateIn
|
|||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
|
import kotlin.random.Random
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
class DeviceAudioPlayerQueue(
|
class DeviceAudioPlayerQueue(
|
||||||
private val audioPlayer: AudioPlayer,
|
private val audioPlayer: AudioPlayer,
|
||||||
private val settingsViewModel: SettingsViewModel,
|
private val settingsViewModel: SettingsViewModel,
|
||||||
private val repository: AudioPlayerQueueRepository,
|
private val repository: AudioPlayerQueueRepository,
|
||||||
|
private val pluginManager: PluginManager,
|
||||||
) : AudioPlayerQueue, KoinComponent {
|
) : AudioPlayerQueue, KoinComponent {
|
||||||
|
|
||||||
private val logger by injectLogger<DeviceAudioPlayerQueue>()
|
private val logger by injectLogger<DeviceAudioPlayerQueue>()
|
||||||
@ -98,6 +101,27 @@ class DeviceAudioPlayerQueue(
|
|||||||
persistQueueStateIfReady()
|
persistQueueStateIfReady()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
scope.launch {
|
||||||
|
combine(
|
||||||
|
audioPlayer.currentMediaItemFlow,
|
||||||
|
audioPlayer.playlistFlow,
|
||||||
|
audioPlayer.loopStateFlow,
|
||||||
|
) { currentMedia, playlist, loopState ->
|
||||||
|
Triple(currentMedia, playlist, loopState)
|
||||||
|
}.collect { (currentMedia, playlist, loopState) ->
|
||||||
|
if (currentMedia == null || playlist.isEmpty()) return@collect
|
||||||
|
if (loopState == LoopState.ALL) return@collect
|
||||||
|
|
||||||
|
val currentIndex = playlist.indexOfFirst { it.url == currentMedia.url }
|
||||||
|
if (currentIndex < 0) return@collect
|
||||||
|
|
||||||
|
val tracksAfterCurrent = playlist.size - currentIndex - 1
|
||||||
|
if (tracksAfterCurrent <= 0) {
|
||||||
|
handleQueueCompletion()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun load(
|
override suspend fun load(
|
||||||
@ -281,6 +305,61 @@ class DeviceAudioPlayerQueue(
|
|||||||
return collectionHistoryState.value
|
return collectionHistoryState.value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var isFetchingRecommendations = false
|
||||||
|
|
||||||
|
private suspend fun handleQueueCompletion() {
|
||||||
|
if (isFetchingRecommendations) return
|
||||||
|
|
||||||
|
val settings = settingsViewModel.settingsState.value ?: return
|
||||||
|
if (!settings.enableEndlessPlayback) return
|
||||||
|
|
||||||
|
val queue = queueFlow.value
|
||||||
|
val streamingTracks = queue.filterIsInstance<QueueEntry.StreamingTrack>()
|
||||||
|
if (streamingTracks.isEmpty()) return
|
||||||
|
|
||||||
|
val seedTracks = if (streamingTracks.size <= 5) {
|
||||||
|
streamingTracks
|
||||||
|
} else {
|
||||||
|
val shuffled = streamingTracks.shuffled(Random)
|
||||||
|
shuffled.take(5)
|
||||||
|
}
|
||||||
|
|
||||||
|
val seedTrackIds = seedTracks.map { it.track.id }
|
||||||
|
if (seedTrackIds.isEmpty()) return
|
||||||
|
|
||||||
|
isFetchingRecommendations = true
|
||||||
|
logger.i { "Endless playback: fetching recommendations with ${seedTrackIds.size} seed tracks" }
|
||||||
|
|
||||||
|
val metadataService = pluginManager.selectedMetadataPlugin.value ?: run {
|
||||||
|
logger.w { "Endless playback: no metadata plugin available" }
|
||||||
|
isFetchingRecommendations = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val recommendations = metadataService.use {
|
||||||
|
metadataTrackAPI.recommendationsBasedOnTracks(seedTrackIds, limit = 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recommendations.isEmpty()) {
|
||||||
|
logger.w { "Endless playback: no recommendations returned" }
|
||||||
|
isFetchingRecommendations = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val newEntries = recommendations.map { track ->
|
||||||
|
QueueEntry.StreamingTrack(track = track, url = "")
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.i { "Endless playback: adding ${newEntries.size} recommended tracks to queue" }
|
||||||
|
addAllToQueue(newEntries)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.e(e) { "Endless playback: failed to fetch recommendations" }
|
||||||
|
} finally {
|
||||||
|
isFetchingRecommendations = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun restorePersistedState() {
|
private suspend fun restorePersistedState() {
|
||||||
val state = repository.getPersistedState()
|
val state = repository.getPersistedState()
|
||||||
if (state == null) {
|
if (state == null) {
|
||||||
|
|||||||
@ -163,7 +163,7 @@ val sharedModules = module {
|
|||||||
}
|
}
|
||||||
singleOf(::AudioPlayerQueueRepository)
|
singleOf(::AudioPlayerQueueRepository)
|
||||||
single<AudioPlayerQueue> {
|
single<AudioPlayerQueue> {
|
||||||
DeviceAudioPlayerQueue(get(), get(), get())
|
DeviceAudioPlayerQueue(get(), get(), get(), get())
|
||||||
}
|
}
|
||||||
|
|
||||||
factory { (tag: String?) ->
|
factory { (tag: String?) ->
|
||||||
|
|||||||
@ -0,0 +1,481 @@
|
|||||||
|
/*
|
||||||
|
* 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.focusable
|
||||||
|
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.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
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.input.key.onPreviewKeyEvent
|
||||||
|
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,
|
||||||
|
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 isMenuOpen by remember { mutableStateOf(false) }
|
||||||
|
var isMovingFocusToPopup by remember { mutableStateOf(false) }
|
||||||
|
var selectedIndex by remember { mutableIntStateOf(-1) }
|
||||||
|
var textFieldSize by remember { mutableStateOf(IntSize.Zero) }
|
||||||
|
val menuListState = rememberLazyListState()
|
||||||
|
val popupFocusRequester = remember { FocusRequester() }
|
||||||
|
val textFieldFocusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
|
val menuExpanded = isMenuOpen && items.isNotEmpty()
|
||||||
|
|
||||||
|
LaunchedEffect(isFocused) {
|
||||||
|
if (isFocused) {
|
||||||
|
isMenuOpen = true
|
||||||
|
isMovingFocusToPopup = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(value) {
|
||||||
|
if (selectedIndex != -1) selectedIndex = -1
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(selectedIndex) {
|
||||||
|
if (selectedIndex >= 0 && menuExpanded) {
|
||||||
|
menuListState.scrollToItem(selectedIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val wrappedKeyboardActions = remember(keyboardActions, menuExpanded, selectedIndex, items) {
|
||||||
|
KeyboardActions(
|
||||||
|
onSearch = {
|
||||||
|
if (menuExpanded && selectedIndex in items.indices) {
|
||||||
|
onItemSelected(items[selectedIndex])
|
||||||
|
selectedIndex = -1
|
||||||
|
isMenuOpen = 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(.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,
|
||||||
|
)
|
||||||
|
.focusRequester(textFieldFocusRequester)
|
||||||
|
.onFocusChanged { state ->
|
||||||
|
if (!state.isFocused && !isMovingFocusToPopup) {
|
||||||
|
isMenuOpen = false
|
||||||
|
selectedIndex = -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onPreviewKeyEvent { event ->
|
||||||
|
if (onKeyEvent?.invoke(event) == true) {
|
||||||
|
return@onPreviewKeyEvent true
|
||||||
|
}
|
||||||
|
if (!menuExpanded) return@onPreviewKeyEvent false
|
||||||
|
when (event.key) {
|
||||||
|
Key.DirectionDown -> {
|
||||||
|
isMovingFocusToPopup = true
|
||||||
|
popupFocusRequester.requestFocus()
|
||||||
|
navigate(1)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
Key.DirectionUp -> {
|
||||||
|
if (selectedIndex >= 0) {
|
||||||
|
navigate(-1)
|
||||||
|
true
|
||||||
|
} else false
|
||||||
|
}
|
||||||
|
|
||||||
|
Key.Enter -> {
|
||||||
|
if (selectedIndex in items.indices) {
|
||||||
|
onItemSelected(items[selectedIndex])
|
||||||
|
selectedIndex = -1
|
||||||
|
isMenuOpen = false
|
||||||
|
return@onPreviewKeyEvent true
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
Key.Escape -> {
|
||||||
|
selectedIndex = -1
|
||||||
|
isMenuOpen = 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 (menuExpanded && 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
|
||||||
|
isMenuOpen = false
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(textFieldWidthDp)
|
||||||
|
.heightIn(max = menuMaxHeight)
|
||||||
|
.shadow(menuShadowElevation, menuShape)
|
||||||
|
.background(menuContainerColor, menuShape)
|
||||||
|
.clip(menuShape)
|
||||||
|
.focusRequester(popupFocusRequester)
|
||||||
|
.focusable()
|
||||||
|
.onFocusChanged { state ->
|
||||||
|
if (state.isFocused) {
|
||||||
|
isMovingFocusToPopup = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onKeyEvent { event ->
|
||||||
|
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
|
||||||
|
isMenuOpen = false
|
||||||
|
textFieldFocusRequester.requestFocus()
|
||||||
|
return@onKeyEvent true
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
Key.Escape -> {
|
||||||
|
selectedIndex = -1
|
||||||
|
isMenuOpen = false
|
||||||
|
textFieldFocusRequester.requestFocus()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
state = menuListState,
|
||||||
|
modifier = Modifier
|
||||||
|
.heightIn(max = menuMaxHeight),
|
||||||
|
) {
|
||||||
|
items(items.size) { index ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable {
|
||||||
|
onItemSelected(items[index])
|
||||||
|
selectedIndex = -1
|
||||||
|
isMenuOpen = false
|
||||||
|
textFieldFocusRequester.requestFocus()
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
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("") }
|
||||||
|
|
||||||
|
AutocompleteTextField(
|
||||||
|
value = value,
|
||||||
|
onValueChange = {
|
||||||
|
value = it
|
||||||
|
},
|
||||||
|
items = artists.filter {
|
||||||
|
it.contains(value, ignoreCase = true) && value.isNotEmpty()
|
||||||
|
},
|
||||||
|
onItemSelected = { selected ->
|
||||||
|
value = selected
|
||||||
|
},
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -255,7 +255,7 @@ fun OutlineButton(
|
|||||||
.then(buttonShadow(shape, isPressed, primary = false, colors, hovered = isHovered))
|
.then(buttonShadow(shape, isPressed, primary = false, colors, hovered = isHovered))
|
||||||
.clip(shape)
|
.clip(shape)
|
||||||
.background(gradient, shape)
|
.background(gradient, shape)
|
||||||
.border(BorderStroke(1.5.dp, border), shape)
|
.border(BorderStroke(0.5.dp, border), shape)
|
||||||
.clickable(
|
.clickable(
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
interactionSource = interactionSource,
|
interactionSource = interactionSource,
|
||||||
@ -312,7 +312,7 @@ fun PrimaryButton(
|
|||||||
.then(buttonShadow(shape, isPressed, primary = true, colors, hovered = isHovered))
|
.then(buttonShadow(shape, isPressed, primary = true, colors, hovered = isHovered))
|
||||||
.clip(shape)
|
.clip(shape)
|
||||||
.background(gradient, shape)
|
.background(gradient, shape)
|
||||||
.border(BorderStroke(1.5.dp, colors.accent), shape)
|
.border(BorderStroke(0.5.dp, colors.accent), shape)
|
||||||
.clickable(
|
.clickable(
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
interactionSource = interactionSource,
|
interactionSource = interactionSource,
|
||||||
@ -372,7 +372,7 @@ fun SecondaryButton(
|
|||||||
.clip(shape)
|
.clip(shape)
|
||||||
.background(gradient, shape)
|
.background(gradient, shape)
|
||||||
.border(
|
.border(
|
||||||
BorderStroke(1.5.dp, colors.secondaryContainer.copy(alpha = 0.5f)),
|
BorderStroke(0.5.dp, colors.secondaryContainer.copy(alpha = 0.5f)),
|
||||||
shape,
|
shape,
|
||||||
)
|
)
|
||||||
.clickable(
|
.clickable(
|
||||||
@ -582,7 +582,7 @@ fun ButtonGroup(
|
|||||||
.then(buttonShadow(GroupShape, pressed = false, primary = false, colors, hovered = false)),
|
.then(buttonShadow(GroupShape, pressed = false, primary = false, colors, hovered = false)),
|
||||||
shape = GroupShape,
|
shape = GroupShape,
|
||||||
color = Color.Transparent,
|
color = Color.Transparent,
|
||||||
border = BorderStroke(1.5.dp, colors.border),
|
border = BorderStroke(0.5.dp, colors.border),
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.background(outlinedGradient(colors, false), GroupShape),
|
modifier = Modifier.background(outlinedGradient(colors, false), GroupShape),
|
||||||
@ -599,7 +599,7 @@ fun ButtonGroupDivider() {
|
|||||||
val colors = rememberButtonColors()
|
val colors = rememberButtonColors()
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(1.5.dp)
|
.width(1.dp)
|
||||||
.heightIn(min = 20.dp)
|
.heightIn(min = 20.dp)
|
||||||
.background(colors.border),
|
.background(colors.border),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -96,7 +96,7 @@ fun CheckBox(
|
|||||||
isFilled -> colors.accent
|
isFilled -> colors.accent
|
||||||
else -> colors.border
|
else -> colors.border
|
||||||
}
|
}
|
||||||
val borderWidth = if (isFilled) 1.5.dp else 1.5.dp
|
val borderWidth = 0.5.dp
|
||||||
|
|
||||||
val shadowElevation = when {
|
val shadowElevation = when {
|
||||||
isPressed -> 1.dp
|
isPressed -> 1.dp
|
||||||
|
|||||||
@ -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,
|
||||||
@ -142,7 +142,7 @@ fun TextField(
|
|||||||
.then(textFieldShadow(TextFieldShape, isFocused, colors))
|
.then(textFieldShadow(TextFieldShape, isFocused, colors))
|
||||||
.clip(TextFieldShape)
|
.clip(TextFieldShape)
|
||||||
.background(gradient, TextFieldShape)
|
.background(gradient, TextFieldShape)
|
||||||
.border(BorderStroke(1.5.dp, border), TextFieldShape)
|
.border(BorderStroke(0.5.dp, border), TextFieldShape)
|
||||||
.drawWithCache {
|
.drawWithCache {
|
||||||
val highlightBrush = Brush.verticalGradient(
|
val highlightBrush = Brush.verticalGradient(
|
||||||
colors = listOf(colors.highlight, Color.Transparent),
|
colors = listOf(colors.highlight, Color.Transparent),
|
||||||
|
|||||||
@ -272,8 +272,8 @@ private fun HomeSection(
|
|||||||
modifier = modifier.fillMaxWidth(),
|
modifier = modifier.fillMaxWidth(),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
if (!subtitle.isNullOrEmpty()) Text(
|
||||||
text = subtitle ?: "",
|
text = subtitle,
|
||||||
style = MaterialTheme.typography.labelMedium.copy(
|
style = MaterialTheme.typography.labelMedium.copy(
|
||||||
color = MaterialTheme.colorScheme.secondary,
|
color = MaterialTheme.colorScheme.secondary,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
|
|||||||
@ -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,30 +57,20 @@ 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.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.runtime.snapshotFlow
|
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.focus.FocusRequester
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.focus.focusRequester
|
|
||||||
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 +83,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 +109,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()) {
|
||||||
@ -135,10 +120,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
|||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
|
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
var isSearchFocused by remember { mutableStateOf(false) }
|
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
val focusRequester = remember { FocusRequester() }
|
|
||||||
var keyboardSelectedIndex by remember { mutableIntStateOf(-1) }
|
|
||||||
|
|
||||||
fun playSingleTrack(track: MetadataTrack) {
|
fun playSingleTrack(track: MetadataTrack) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@ -242,30 +224,6 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val showRecentSearches =
|
|
||||||
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) }
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
@ -279,60 +237,19 @@ 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,
|
|
||||||
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 = {
|
onSearch = {
|
||||||
focusManager.clearFocus()
|
focusManager.clearFocus()
|
||||||
isSearchFocused = false
|
|
||||||
},
|
|
||||||
showDropdown = showRecentSearches,
|
|
||||||
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
|
|
||||||
keyboardSelectedIndex = -1
|
|
||||||
focusManager.clearFocus()
|
focusManager.clearFocus()
|
||||||
},
|
},
|
||||||
onRecentSearchRemove = viewModel::removeRecentSearch,
|
onRecentSearchRemove = viewModel::removeRecentSearch,
|
||||||
onClearAllRecentSearches = {
|
onClearAllRecentSearches = {
|
||||||
viewModel.clearAllRecentSearches()
|
viewModel.clearAllRecentSearches()
|
||||||
isSearchFocused = false
|
|
||||||
keyboardSelectedIndex = -1
|
|
||||||
focusManager.clearFocus()
|
focusManager.clearFocus()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@ -443,35 +360,57 @@ 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,
|
||||||
onQueryChange: (String) -> Unit,
|
onQueryChange: (String) -> Unit,
|
||||||
onClear: () -> Unit,
|
onClear: () -> Unit,
|
||||||
isFocused: Boolean,
|
|
||||||
onFocusChanged: (Boolean) -> Unit,
|
|
||||||
focusRequester: FocusRequester,
|
|
||||||
onKeyEvent: (androidx.compose.ui.input.key.KeyEvent) -> Boolean,
|
|
||||||
onSearch: () -> Unit,
|
onSearch: () -> Unit,
|
||||||
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 showRecentSearches = query.isBlank() && recentSearches.isNotEmpty()
|
||||||
|
val dropdownItems = 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(
|
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() },
|
||||||
placeholder = {
|
placeholder = {
|
||||||
Text(
|
Text(
|
||||||
"Search songs, artists, albums...",
|
"Search songs, artists, albums...",
|
||||||
@ -501,77 +440,77 @@ private fun SearchBar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier
|
singleLine = true,
|
||||||
.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth(),
|
||||||
.onFocusChanged { onFocusChanged(it.isFocused) }
|
|
||||||
.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 +520,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
|
|
||||||
)
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -21,12 +21,20 @@ import androidx.compose.animation.AnimatedVisibility
|
|||||||
import androidx.compose.animation.core.animateDpAsState
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.hoverable
|
||||||
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
@ -38,20 +46,30 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.ripple
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
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.unit.dp
|
import androidx.compose.ui.draw.drawWithCache
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import dev.krtirtho.spotube.core.navigation.NavigationState
|
import dev.krtirtho.spotube.core.navigation.NavigationState
|
||||||
import dev.krtirtho.spotube.core.navigation.Navigator
|
import dev.krtirtho.spotube.core.navigation.Navigator
|
||||||
import dev.krtirtho.spotube.core.navigation.Routes
|
import dev.krtirtho.spotube.core.navigation.Routes
|
||||||
|
import dev.krtirtho.spotube.core.ui.base.SecondaryButton
|
||||||
|
import dev.krtirtho.spotube.core.ui.base.buttonShadow
|
||||||
|
import dev.krtirtho.spotube.core.ui.base.outlinedGradient
|
||||||
|
import dev.krtirtho.spotube.core.ui.base.rememberButtonColors
|
||||||
import dev.krtirtho.spotube.modules.downloads.DownloadBadgeIndicator
|
import dev.krtirtho.spotube.modules.downloads.DownloadBadgeIndicator
|
||||||
import dev.krtirtho.spotube.modules.library.LibraryState
|
import dev.krtirtho.spotube.modules.library.LibraryState
|
||||||
import dev.krtirtho.spotube.modules.library.LibraryTab
|
import dev.krtirtho.spotube.modules.library.LibraryTab
|
||||||
@ -156,20 +174,7 @@ fun SidebarItem(
|
|||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
showDownloadBadge: Boolean = false,
|
showDownloadBadge: Boolean = false,
|
||||||
) {
|
) {
|
||||||
Row(
|
val itemContent: @Composable RowScope.() -> Unit = {
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 10.dp, vertical = 4.dp)
|
|
||||||
.clip(MaterialTheme.shapes.small)
|
|
||||||
.background(
|
|
||||||
if (selected) MaterialTheme.colorScheme.secondaryContainer
|
|
||||||
else MaterialTheme.colorScheme.surfaceContainer
|
|
||||||
)
|
|
||||||
.clickable { onClick() }
|
|
||||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = if (expanded) Arrangement.Start else Arrangement.Center
|
|
||||||
) {
|
|
||||||
Box(modifier = Modifier.size(24.dp)) {
|
Box(modifier = Modifier.size(24.dp)) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = activeIcon,
|
imageVector = activeIcon,
|
||||||
@ -178,7 +183,7 @@ fun SidebarItem(
|
|||||||
MaterialTheme.colorScheme.onSecondaryContainer
|
MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
} else {
|
} else {
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
if (showDownloadBadge) {
|
if (showDownloadBadge) {
|
||||||
DownloadBadgeIndicator(
|
DownloadBadgeIndicator(
|
||||||
@ -187,17 +192,89 @@ fun SidebarItem(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
AnimatedVisibility(visible = expanded, enter = fadeIn(), exit = fadeOut()) {
|
AnimatedVisibility(visible = expanded, enter = fadeIn(), exit = fadeOut()) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Spacer(modifier = Modifier.width(12.dp))
|
|
||||||
Text(
|
Text(
|
||||||
text = label,
|
text = label,
|
||||||
|
maxLines = 1,
|
||||||
|
softWrap = false,
|
||||||
color = if (selected) {
|
color = if (selected) {
|
||||||
MaterialTheme.colorScheme.onSecondaryContainer
|
MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
} else {
|
} else {
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
Spacer(modifier = Modifier.weight(if (expanded) 1f else 0f))
|
||||||
|
}
|
||||||
|
|
||||||
|
val buttonModifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 10.dp, vertical = 4.dp)
|
||||||
|
val contentPadding = PaddingValues(horizontal = 14.dp, vertical = 12.dp)
|
||||||
|
|
||||||
|
if (selected) {
|
||||||
|
SecondaryButton(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = buttonModifier,
|
||||||
|
contentPadding = contentPadding,
|
||||||
|
content = itemContent,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val colors = rememberButtonColors()
|
||||||
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
val isHovered by interactionSource.collectIsHoveredAsState()
|
||||||
|
val isPressed by interactionSource.collectIsPressedAsState()
|
||||||
|
val gradient = outlinedGradient(colors, isPressed)
|
||||||
|
val border = colors.border.copy(alpha = if (isPressed) 0.7f else 1f)
|
||||||
|
val lift = if (isHovered && !isPressed) (-1).dp else 0.dp
|
||||||
|
val shape = RoundedCornerShape(14.dp)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = buttonModifier
|
||||||
|
.hoverable(interactionSource = interactionSource)
|
||||||
|
.graphicsLayer { translationY = lift.toPx() }
|
||||||
|
.clip(shape)
|
||||||
|
.then(
|
||||||
|
if (isHovered || isPressed) {
|
||||||
|
buttonShadow(
|
||||||
|
shape,
|
||||||
|
isPressed,
|
||||||
|
primary = false,
|
||||||
|
colors,
|
||||||
|
hovered = isHovered
|
||||||
|
).background(gradient, shape)
|
||||||
|
.border(BorderStroke(0.5.dp, border), shape)
|
||||||
|
.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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.clickable(
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
indication = ripple(),
|
||||||
|
onClick = onClick,
|
||||||
|
)
|
||||||
|
.padding(contentPadding),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
content = itemContent,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue
Block a user