Compare commits

..

1 Commits

Author SHA1 Message Date
Kingkor Roy Tirtho
c4b5ae6600
Merge 4302a27afa into 80626ba4b5 2026-07-05 07:01:42 +00:00
16 changed files with 881 additions and 1654 deletions

View File

@ -20,11 +20,8 @@ package dev.krtirtho.spotube
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
import dev.krtirtho.spotube.core.ui.base.rememberBaseUITheme
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@ -103,18 +100,15 @@ fun App(
} }
SpotubeTheme(settings = userSettings) { SpotubeTheme(settings = userSettings) {
val baseUITheme = rememberBaseUITheme() AppShell(navigator, navigationState) {
CompositionLocalProvider(LocalBaseUITheme provides baseUITheme) { Column {
AppShell(navigator, navigationState) { NavDisplay(
Column { modifier = Modifier.fillMaxSize(),
NavDisplay( onBack = navigator::pop,
modifier = Modifier.fillMaxSize(), entries = navigationState.toEntries(koinEntryProvider())
onBack = navigator::pop, )
entries = navigationState.toEntries(koinEntryProvider())
)
}
} }
content()
} }
content()
} }
} }

View File

@ -28,8 +28,8 @@ import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.focusable
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.Row import androidx.compose.foundation.layout.Row
@ -41,6 +41,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
@ -86,55 +87,12 @@ import androidx.compose.ui.window.PopupProperties
import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxSearchBroken 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 AutocompleteTextFieldMinHeight = 44.dp
private val AutocompleteMenuDefaultMaxHeight = 300.dp private val AutocompleteMenuDefaultMaxHeight = 300.dp
private val AutocompleteMenuDefaultOffset = 4.dp private val AutocompleteMenuDefaultOffset = 4.dp
private data class ResolvedAutoCompleteTextFieldState(
val background: Brush,
val highlight: Color,
val shape: Shape,
val border: BaseUITheme.Border,
val shadow: BaseUITheme.Shadow,
val foreground: Color,
)
@Composable
private fun resolveTextFieldState(
theme: BaseUITheme.TextFieldTheme,
isFocused: Boolean,
isHovered: Boolean,
): ResolvedAutoCompleteTextFieldState {
return when {
isFocused -> ResolvedAutoCompleteTextFieldState(
theme.background.focused,
theme.highlight.focused,
theme.shape.focused,
theme.border.focused,
theme.shadow.focused,
theme.foreground.focused
)
isHovered -> ResolvedAutoCompleteTextFieldState(
theme.background.hovered,
theme.highlight.hovered,
theme.shape.hovered,
theme.border.hovered,
theme.shadow.hovered,
theme.foreground.hovered
)
else -> ResolvedAutoCompleteTextFieldState(
theme.background.pressed,
theme.highlight.pressed,
theme.shape.pressed,
theme.border.pressed,
theme.shadow.pressed,
theme.foreground.pressed
)
}
}
@Composable @Composable
fun <T> AutocompleteTextField( fun <T> AutocompleteTextField(
value: String, value: String,
@ -157,18 +115,16 @@ fun <T> AutocompleteTextField(
visualTransformation: VisualTransformation = VisualTransformation.None, visualTransformation: VisualTransformation = VisualTransformation.None,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
textStyle: TextStyle = TextStyle.Default, textStyle: TextStyle = TextStyle.Default,
cursorBrush: Color? = null, cursorBrush: Color = MaterialTheme.colorScheme.primary,
onKeyEvent: ((KeyEvent) -> Boolean)? = null, onKeyEvent: ((KeyEvent) -> Boolean)? = null,
menuMaxHeight: Dp = AutocompleteMenuDefaultMaxHeight, menuMaxHeight: Dp = AutocompleteMenuDefaultMaxHeight,
menuOffset: Dp = AutocompleteMenuDefaultOffset, menuOffset: Dp = AutocompleteMenuDefaultOffset,
textFieldTheme: BaseUITheme.TextFieldTheme? = null, menuShape: Shape = AutocompleteMenuShape,
menuTheme: BaseUITheme.AutoCompleteMenuTheme? = null, menuShadowElevation: Dp = 8.dp,
menuContainerColor: Color = MaterialTheme.colorScheme.surfaceContainerHigh,
) { ) {
val density = LocalDensity.current val density = LocalDensity.current
val baseTextFieldTheme = LocalBaseUITheme.current.textField val colors = rememberButtonColors()
val baseMenuTheme = LocalBaseUITheme.current.autoCompleteMenuTheme
val resolvedTextFieldTheme = textFieldTheme ?: baseTextFieldTheme
val resolvedMenuTheme = menuTheme ?: baseMenuTheme
val isFocused by interactionSource.collectIsFocusedAsState() val isFocused by interactionSource.collectIsFocusedAsState()
val isHovered by interactionSource.collectIsHoveredAsState() val isHovered by interactionSource.collectIsHoveredAsState()
@ -218,21 +174,19 @@ fun <T> AutocompleteTextField(
) )
} }
val fieldState = resolveTextFieldState(resolvedTextFieldTheme, isFocused, isHovered) val gradient = outlinedGradient(colors, false)
val border = when {
val resolvedBorder = if (isError) { isError -> MaterialTheme.colorScheme.error
BaseUITheme.Border(MaterialTheme.colorScheme.error, fieldState.border.width) isFocused -> MaterialTheme.colorScheme.primary
} else { isHovered -> colors.border.copy(alpha = 0.85f)
fieldState.border else -> colors.border
} }
val contentColor = when { val contentColor = when {
!enabled -> fieldState.foreground.copy(alpha = 0.38f) !enabled -> colors.onContainer.copy(alpha = 0.38f)
else -> fieldState.foreground else -> colors.onContainer
} }
val cursor = cursorBrush?.let { SolidColor(it) } ?: resolvedTextFieldTheme.cursor
fun navigate(delta: Int) { fun navigate(delta: Int) {
if (items.isEmpty()) return if (items.isEmpty()) return
selectedIndex = ((selectedIndex + delta) % items.size + items.size) % items.size selectedIndex = ((selectedIndex + delta) % items.size + items.size) % items.size
@ -253,25 +207,13 @@ fun <T> AutocompleteTextField(
.fillMaxWidth() .fillMaxWidth()
.onSizeChanged { textFieldSize = it } .onSizeChanged { textFieldSize = it }
.defaultMinSize(minHeight = AutocompleteTextFieldMinHeight) .defaultMinSize(minHeight = AutocompleteTextFieldMinHeight)
.then( .then(textFieldShadow(AutocompleteTextFieldShape, isFocused, colors))
if (fieldState.shadow.elevation > 0.dp) { .clip(AutocompleteTextFieldShape)
Modifier.shadow( .background(gradient, AutocompleteTextFieldShape)
elevation = fieldState.shadow.elevation, .border(BorderStroke(.5.dp, border), AutocompleteTextFieldShape)
shape = fieldState.shape,
ambientColor = fieldState.shadow.ambientColor,
spotColor = fieldState.shadow.spotColor,
)
} else Modifier
)
.clip(fieldState.shape)
.background(fieldState.background, fieldState.shape)
.border(
BorderStroke(resolvedBorder.width, resolvedBorder.color),
fieldState.shape
)
.drawWithCache { .drawWithCache {
val highlightBrush = Brush.verticalGradient( val highlightBrush = Brush.verticalGradient(
colors = listOf(fieldState.highlight, Color.Transparent), colors = listOf(colors.highlight, Color.Transparent),
startY = 0f, startY = 0f,
endY = size.height * 0.5f, endY = size.height * 0.5f,
) )
@ -284,7 +226,7 @@ fun <T> AutocompleteTextField(
) )
} }
} }
.padding(resolvedTextFieldTheme.padding), .padding(horizontal = 14.dp, vertical = 10.dp),
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@ -352,7 +294,7 @@ fun <T> AutocompleteTextField(
enabled = enabled, enabled = enabled,
readOnly = readOnly, readOnly = readOnly,
textStyle = textStyle.copy(color = contentColor), textStyle = textStyle.copy(color = contentColor),
cursorBrush = cursor, cursorBrush = SolidColor(cursorBrush),
keyboardOptions = keyboardOptions, keyboardOptions = keyboardOptions,
keyboardActions = wrappedKeyboardActions, keyboardActions = wrappedKeyboardActions,
singleLine = singleLine, singleLine = singleLine,
@ -398,9 +340,9 @@ fun <T> AutocompleteTextField(
modifier = Modifier modifier = Modifier
.width(textFieldWidthDp) .width(textFieldWidthDp)
.heightIn(max = menuMaxHeight) .heightIn(max = menuMaxHeight)
.shadow(resolvedMenuTheme.shadowElevation, resolvedMenuTheme.shape) .shadow(menuShadowElevation, menuShape)
.background(resolvedMenuTheme.background, resolvedMenuTheme.shape) .background(menuContainerColor, menuShape)
.clip(resolvedMenuTheme.shape) .clip(menuShape)
.focusRequester(popupFocusRequester) .focusRequester(popupFocusRequester)
.focusable() .focusable()
.onFocusChanged { state -> .onFocusChanged { state ->
@ -470,73 +412,70 @@ fun <T> AutocompleteTextField(
@Composable @Composable
private fun AutocompleteTextFieldPreview() { private fun AutocompleteTextFieldPreview() {
MaterialTheme { MaterialTheme {
val theme = rememberBaseUITheme() androidx.compose.material3.Surface(
CompositionLocalProvider(LocalBaseUITheme provides theme) { color = MaterialTheme.colorScheme.background,
androidx.compose.material3.Surface( modifier = Modifier.padding(24.dp),
color = MaterialTheme.colorScheme.background, ) {
modifier = Modifier.padding(24.dp), val artists = remember {
) { listOf(
val artists = remember { "Twenty One Pilots",
listOf( "The Beatles",
"Twenty One Pilots", "Adele",
"The Beatles", "Drake",
"Adele", "Taylor Swift",
"Drake", "Arctic Monkeys",
"Taylor Swift", "Billie Eilish",
"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,
)
}
},
) )
} }
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,
)
}
},
)
} }
} }
} }

View File

@ -1,81 +0,0 @@
/*
* 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.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.dp
@Composable
fun Card(
modifier: Modifier = Modifier,
theme: BaseUITheme.CardTheme? = null,
content: @Composable () -> Unit,
) {
val cardTheme = theme ?: LocalBaseUITheme.current.card
Box(
modifier = modifier
.then(
if (cardTheme.shadow.elevation > 0.dp) {
Modifier.shadow(
elevation = cardTheme.shadow.elevation,
shape = cardTheme.shape,
ambientColor = cardTheme.shadow.ambientColor,
spotColor = cardTheme.shadow.spotColor,
)
} else Modifier
)
.clip(cardTheme.shape)
.background(cardTheme.background, cardTheme.shape)
.border(BorderStroke(cardTheme.border.width, cardTheme.border.color), cardTheme.shape)
.padding(cardTheme.padding),
) {
content()
}
}
@Composable
@androidx.compose.ui.tooling.preview.Preview
private fun CardPreview() {
MaterialTheme {
val theme = rememberBaseUITheme()
CompositionLocalProvider(LocalBaseUITheme provides theme) {
Surface(
color = MaterialTheme.colorScheme.background,
modifier = Modifier.padding(24.dp),
) {
Card {
Box(modifier = Modifier.padding(16.dp))
}
}
}
}
}

View File

@ -39,7 +39,6 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.ripple import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -51,7 +50,6 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.Stroke
@ -67,28 +65,9 @@ enum class CheckBoxState {
CLEAR; CLEAR;
} }
private val CheckBoxShape = RoundedCornerShape(6.dp)
private val CheckBoxSize = 22.dp private val CheckBoxSize = 22.dp
private data class ResolvedCheckBoxState(
val colors: BaseUITheme.ButtonColors,
val shape: Shape,
val shadow: BaseUITheme.Shadow,
val border: BaseUITheme.Border,
)
@Composable
private fun resolveCheckBoxState(
style: BaseUITheme.ButtonStyle,
isPressed: Boolean,
isHovered: Boolean,
): ResolvedCheckBoxState {
return when {
isPressed -> ResolvedCheckBoxState(style.colors.pressed, style.shape.pressed, style.shadow.pressed, style.border.pressed)
isHovered -> ResolvedCheckBoxState(style.colors.hovered, style.shape.hovered, style.shadow.hovered, style.border.hovered)
else -> ResolvedCheckBoxState(style.colors.focused, style.shape.focused, style.shadow.focused, style.border.focused)
}
}
@Composable @Composable
fun CheckBox( fun CheckBox(
state: CheckBoxState = CheckBoxState.UNSELECTED, state: CheckBoxState = CheckBoxState.UNSELECTED,
@ -96,25 +75,69 @@ fun CheckBox(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
enabled: Boolean = true, enabled: Boolean = true,
interactionSource: MutableInteractionSource? = null, interactionSource: MutableInteractionSource? = null,
theme: BaseUITheme.CheckBoxTheme? = null,
) { ) {
val baseTheme = LocalBaseUITheme.current.checkBox val colors = rememberButtonColors()
val checkBoxTheme = theme ?: baseTheme
val source = interactionSource ?: remember { MutableInteractionSource() } val source = interactionSource ?: remember { MutableInteractionSource() }
val isPressed by source.collectIsPressedAsState() val isPressed by source.collectIsPressedAsState()
val isHovered by source.collectIsHoveredAsState() val isHovered by source.collectIsHoveredAsState()
val lift = if (isHovered && !isPressed && onClick != null) (-1).dp else 0.dp
val isSelected = state == CheckBoxState.SELECTED val isSelected = state == CheckBoxState.SELECTED
val isIndeterminate = state == CheckBoxState.INDETERMINATE val isIndeterminate = state == CheckBoxState.INDETERMINATE
val isFilled = isSelected || isIndeterminate val isFilled = isSelected || isIndeterminate
val style = if (isFilled) checkBoxTheme.selected else checkBoxTheme.unselected val backgroundBrush = if (isFilled) {
val resolved = resolveCheckBoxState(style, isPressed, isHovered) primaryGradient(colors, isPressed)
val lift = if (isHovered && !isPressed && onClick != null) (-1).dp else 0.dp } else {
outlinedGradient(colors, false)
}
val borderColor = when { val borderColor = when {
!enabled -> resolved.colors.foreground.copy(alpha = 0.38f) !enabled -> colors.onContainer.copy(alpha = 0.38f)
else -> resolved.border.color isFilled -> colors.accent
else -> colors.border
}
val borderWidth = 0.5.dp
val shadowElevation = when {
isPressed -> 1.dp
isFilled && isHovered -> 8.dp
isFilled -> 6.dp
isHovered -> 5.dp
else -> 3.dp
}
val shadowAmbient = if (isFilled) {
colors.accent.copy(
alpha = when {
isPressed -> 0.2f
isHovered -> 0.4f
else -> 0.3f
}
)
} else {
colors.shadow.copy(
alpha = when {
isPressed -> 0.08f
isHovered -> 0.2f
else -> 0.15f
}
)
}
val shadowSpot = if (isFilled) {
colors.accent.copy(
alpha = when {
isPressed -> 0.25f
isHovered -> 0.45f
else -> 0.35f
}
)
} else {
colors.shadow.copy(
alpha = when {
isPressed -> 0.1f
isHovered -> 0.24f
else -> 0.18f
}
)
} }
val checkProgress by animateFloatAsState( val checkProgress by animateFloatAsState(
@ -132,29 +155,28 @@ fun CheckBox(
modifier = modifier modifier = modifier
.size(CheckBoxSize) .size(CheckBoxSize)
.graphicsLayer { translationY = lift.toPx() } .graphicsLayer { translationY = lift.toPx() }
.then( .shadow(
if (resolved.shadow.elevation > 0.dp) { elevation = shadowElevation,
Modifier.shadow( shape = CheckBoxShape,
elevation = resolved.shadow.elevation, ambientColor = shadowAmbient,
shape = resolved.shape, spotColor = shadowSpot,
ambientColor = resolved.shadow.ambientColor,
spotColor = resolved.shadow.spotColor,
)
} else Modifier
) )
.clip(resolved.shape) .clip(CheckBoxShape)
.background(resolved.colors.background, resolved.shape) .background(backgroundBrush, CheckBoxShape)
.border(BorderStroke(0.5.dp, borderColor), resolved.shape) .border(BorderStroke(borderWidth, borderColor), CheckBoxShape)
.drawWithCache { .drawWithCache {
val highlightBrush = Brush.verticalGradient( val highlight = Brush.verticalGradient(
colors = listOf(resolved.colors.highlight, Color.Transparent), colors = listOf(
if (isFilled) Color.White.copy(alpha = 0.25f) else colors.highlight,
Color.Transparent,
),
startY = 0f, startY = 0f,
endY = size.height * 0.5f, endY = size.height * 0.5f,
) )
onDrawWithContent { onDrawWithContent {
drawContent() drawContent()
drawRect( drawRect(
brush = highlightBrush, brush = highlight,
topLeft = Offset.Zero, topLeft = Offset.Zero,
size = size, size = size,
) )
@ -193,7 +215,7 @@ fun CheckBox(
} }
drawPath( drawPath(
path = path, path = path,
color = checkBoxTheme.checkmarkColor, color = colors.onAccent,
style = Stroke( style = Stroke(
width = stroke, width = stroke,
cap = StrokeCap.Round, cap = StrokeCap.Round,
@ -211,8 +233,8 @@ fun CheckBox(
.background( .background(
brush = Brush.horizontalGradient( brush = Brush.horizontalGradient(
colors = listOf( colors = listOf(
checkBoxTheme.checkmarkColor, colors.onAccent,
checkBoxTheme.checkmarkColor.copy(alpha = 0.92f), colors.onAccent.copy(alpha = 0.92f),
) )
), ),
), ),
@ -225,41 +247,38 @@ fun CheckBox(
@Composable @Composable
fun CheckBoxPreview() { fun CheckBoxPreview() {
MaterialTheme { MaterialTheme {
val theme = rememberBaseUITheme() Surface(
CompositionLocalProvider(LocalBaseUITheme provides theme) { color = MaterialTheme.colorScheme.background,
Surface( modifier = Modifier.padding(24.dp),
color = MaterialTheme.colorScheme.background, ) {
modifier = Modifier.padding(24.dp), Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
) { Row(
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { horizontalArrangement = Arrangement.spacedBy(12.dp),
Row( verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp), ) {
verticalAlignment = Alignment.CenterVertically, CheckBox(state = CheckBoxState.SELECTED, onClick = {})
) { Text("Selected", style = MaterialTheme.typography.bodyMedium)
CheckBox(state = CheckBoxState.SELECTED, onClick = {}) }
Text("Selected", style = MaterialTheme.typography.bodyMedium) Row(
} horizontalArrangement = Arrangement.spacedBy(12.dp),
Row( verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp), ) {
verticalAlignment = Alignment.CenterVertically, CheckBox(state = CheckBoxState.UNSELECTED, onClick = {})
) { Text("Unselected", style = MaterialTheme.typography.bodyMedium)
CheckBox(state = CheckBoxState.UNSELECTED, onClick = {}) }
Text("Unselected", style = MaterialTheme.typography.bodyMedium) Row(
} horizontalArrangement = Arrangement.spacedBy(12.dp),
Row( verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp), ) {
verticalAlignment = Alignment.CenterVertically, CheckBox(state = CheckBoxState.INDETERMINATE, onClick = {})
) { Text("Indeterminate", style = MaterialTheme.typography.bodyMedium)
CheckBox(state = CheckBoxState.INDETERMINATE, onClick = {}) }
Text("Indeterminate", style = MaterialTheme.typography.bodyMedium) Row(
} horizontalArrangement = Arrangement.spacedBy(12.dp),
Row( verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp), ) {
verticalAlignment = Alignment.CenterVertically, CheckBox(state = CheckBoxState.SELECTED, onClick = {}, enabled = false)
) { Text("Disabled", style = MaterialTheme.typography.bodyMedium)
CheckBox(state = CheckBoxState.SELECTED, onClick = {}, enabled = false)
Text("Disabled", style = MaterialTheme.typography.bodyMedium)
}
} }
} }
} }

View File

@ -27,10 +27,12 @@ import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.interaction.collectIsPressedAsState 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.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.Text import androidx.compose.material3.Text
@ -43,78 +45,57 @@ 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.draw.drawWithCache import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
private val ChipTabShape = RoundedCornerShape(10.dp)
private val ChipTabMinHeight = 36.dp private val ChipTabMinHeight = 36.dp
private data class ResolvedChipState(
val colors: BaseUITheme.ButtonColors,
val shape: Shape,
val shadow: BaseUITheme.Shadow,
val border: BaseUITheme.Border,
val padding: androidx.compose.foundation.layout.PaddingValues,
)
@Composable
private fun resolveChipState(
style: BaseUITheme.ButtonStyle,
isPressed: Boolean,
isHovered: Boolean,
): ResolvedChipState {
return when {
isPressed -> ResolvedChipState(style.colors.pressed, style.shape.pressed, style.shadow.pressed, style.border.pressed, style.padding.pressed)
isHovered -> ResolvedChipState(style.colors.hovered, style.shape.hovered, style.shadow.hovered, style.border.hovered, style.padding.hovered)
else -> ResolvedChipState(style.colors.focused, style.shape.focused, style.shadow.focused, style.border.focused, style.padding.focused)
}
}
private fun Modifier.applyChipShadow(
shadow: BaseUITheme.Shadow,
shape: Shape,
): Modifier {
return if (shadow.elevation > 0.dp) {
this.shadow(
elevation = shadow.elevation,
shape = shape,
ambientColor = shadow.ambientColor,
spotColor = shadow.spotColor,
)
} else {
this
}
}
@Composable @Composable
fun ChipTab( fun ChipTab(
selected: Boolean, selected: Boolean,
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
enabled: Boolean = true, enabled: Boolean = true,
theme: BaseUITheme.ChipTabTheme? = null, contentPadding: PaddingValues = PaddingValues(horizontal = 14.dp, vertical = 4.dp),
content: @Composable RowScope.() -> Unit, content: @Composable RowScope.() -> Unit,
) { ) {
val baseTheme = LocalBaseUITheme.current.chipTab val colors = rememberButtonColors()
val chipTheme = theme ?: baseTheme
val style = if (selected) chipTheme.selected else chipTheme.unselected
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState() val isPressed by interactionSource.collectIsPressedAsState()
val isHovered by interactionSource.collectIsHoveredAsState() val isHovered by interactionSource.collectIsHoveredAsState()
val state = resolveChipState(style, isPressed, isHovered) val gradient = if (selected) {
primaryGradient(colors, isPressed)
} else {
outlinedGradient(colors, isPressed)
}
val borderColor = if (selected) {
colors.accent
} else {
colors.border.copy(alpha = if (isPressed) 0.7f else 1f)
}
val contentColor = if (selected) colors.onAccent else colors.onContainer
Box( Box(
modifier = modifier modifier = modifier
.defaultMinSize(minHeight = ChipTabMinHeight) .defaultMinSize(minHeight = ChipTabMinHeight)
.hoverable(interactionSource = interactionSource, enabled = enabled) .hoverable(interactionSource = interactionSource, enabled = enabled)
.applyChipShadow(state.shadow, state.shape) .then(
.clip(state.shape) buttonShadow(
.background(state.colors.background, state.shape) shape = ChipTabShape,
.border(BorderStroke(state.border.width, state.border.color), state.shape) pressed = isPressed,
primary = selected,
colors = colors,
hovered = isHovered,
)
)
.clip(ChipTabShape)
.background(gradient, ChipTabShape)
.border(BorderStroke(0.5.dp, borderColor), ChipTabShape)
.clickable( .clickable(
enabled = enabled, enabled = enabled,
interactionSource = interactionSource, interactionSource = interactionSource,
@ -122,8 +103,13 @@ fun ChipTab(
onClick = onClick, onClick = onClick,
) )
.drawWithCache { .drawWithCache {
val highlight = if (selected) {
Color.White.copy(alpha = 0.25f)
} else {
colors.highlight
}
val highlightBrush = Brush.verticalGradient( val highlightBrush = Brush.verticalGradient(
colors = listOf(state.colors.highlight, Color.Transparent), colors = listOf(highlight, Color.Transparent),
startY = 0f, startY = 0f,
endY = size.height * 0.5f, endY = size.height * 0.5f,
) )
@ -136,11 +122,11 @@ fun ChipTab(
) )
} }
} }
.padding(state.padding), .padding(contentPadding),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
CompositionLocalProvider( CompositionLocalProvider(
LocalContentColor provides state.colors.foreground, LocalContentColor provides contentColor,
LocalTextStyle provides LocalTextStyle.current.copy( LocalTextStyle provides LocalTextStyle.current.copy(
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
fontSize = 14.sp, fontSize = 14.sp,
@ -163,14 +149,14 @@ fun ChipTab(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
enabled: Boolean = true, enabled: Boolean = true,
leadingIcon: @Composable (() -> Unit)? = null, leadingIcon: @Composable (() -> Unit)? = null,
theme: BaseUITheme.ChipTabTheme? = null, contentPadding: PaddingValues = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
) { ) {
ChipTab( ChipTab(
selected = selected, selected = selected,
onClick = onClick, onClick = onClick,
modifier = modifier, modifier = modifier,
enabled = enabled, enabled = enabled,
theme = theme, contentPadding = contentPadding,
) { ) {
if (leadingIcon != null) { if (leadingIcon != null) {
leadingIcon() leadingIcon()

View File

@ -43,7 +43,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
@ -80,10 +79,8 @@ fun Slider(
steps: Int = 0, steps: Int = 0,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
onValueChangeFinished: () -> Unit = {}, onValueChangeFinished: () -> Unit = {},
theme: BaseUITheme.SliderTheme? = null,
) { ) {
val baseTheme = LocalBaseUITheme.current.slider val colors = rememberButtonColors()
val sliderTheme = theme ?: baseTheme
val isHovered by interactionSource.collectIsHoveredAsState() val isHovered by interactionSource.collectIsHoveredAsState()
val isPressed by interactionSource.collectIsPressedAsState() val isPressed by interactionSource.collectIsPressedAsState()
val density = LocalDensity.current val density = LocalDensity.current
@ -92,30 +89,13 @@ fun Slider(
val range = valueRange.endInclusive - valueRange.start val range = valueRange.endInclusive - valueRange.start
val fraction = if (range > 0f) ((value - valueRange.start) / range).coerceIn(0f, 1f) else 0f val fraction = if (range > 0f) ((value - valueRange.start) / range).coerceIn(0f, 1f) else 0f
val stateColor = when { val trackActiveColor = if (enabled) colors.accent else colors.onContainer.copy(alpha = 0.38f)
isPressed -> sliderTheme.trackActiveColor.pressed val trackInactiveColor = if (enabled) {
isHovered -> sliderTheme.trackActiveColor.hovered colors.border.copy(alpha = 0.6f)
else -> sliderTheme.trackActiveColor.focused } else {
colors.onContainer.copy(alpha = 0.2f)
} }
val inactiveColor = when { val thumbColor = if (enabled) colors.accent else colors.onContainer.copy(alpha = 0.38f)
isPressed -> sliderTheme.trackInactiveColor.pressed
isHovered -> sliderTheme.trackInactiveColor.hovered
else -> sliderTheme.trackInactiveColor.focused
}
val thumbColor = when {
isPressed -> sliderTheme.thumbColor.pressed
isHovered -> sliderTheme.thumbColor.hovered
else -> sliderTheme.thumbColor.focused
}
val thumbShadowState = when {
isPressed -> sliderTheme.thumbShadow.pressed
isHovered -> sliderTheme.thumbShadow.hovered
else -> sliderTheme.thumbShadow.focused
}
val trackActiveColor = if (enabled) stateColor else stateColor.copy(alpha = 0.38f)
val trackInactiveColor = if (enabled) inactiveColor else inactiveColor.copy(alpha = 0.38f)
val resolvedThumbColor = if (enabled) thumbColor else thumbColor.copy(alpha = 0.38f)
val thumbScale by animateFloatAsState( val thumbScale by animateFloatAsState(
targetValue = when { targetValue = when {
@ -127,6 +107,12 @@ fun Slider(
label = "thumbScale", label = "thumbScale",
) )
val thumbElevation = when {
isPressed -> 2.dp
isHovered -> 8.dp
else -> 5.dp
}
Box( Box(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
@ -255,9 +241,9 @@ fun Slider(
val x = trackStart + stepSpacing * i val x = trackStart + stepSpacing * i
drawCircle( drawCircle(
color = if (x <= activeEnd) { color = if (x <= activeEnd) {
sliderTheme.stepActiveColor colors.onAccent.copy(alpha = 0.5f)
} else { } else {
sliderTheme.stepInactiveColor colors.onContainer.copy(alpha = 0.25f)
}, },
radius = 2.dp.toPx(), radius = 2.dp.toPx(),
center = Offset(x, trackY), center = Offset(x, trackY),
@ -281,17 +267,17 @@ fun Slider(
scaleY = thumbScale scaleY = thumbScale
} }
.shadow( .shadow(
elevation = thumbShadowState.elevation, elevation = thumbElevation,
shape = CircleShape, shape = CircleShape,
ambientColor = thumbShadowState.ambientColor, ambientColor = colors.accent.copy(alpha = 0.35f),
spotColor = thumbShadowState.spotColor, spotColor = colors.accent.copy(alpha = 0.5f),
) )
.background( .background(
brush = Brush.radialGradient( brush = Brush.radialGradient(
colors = listOf( colors = listOf(
Color.White.copy(alpha = 0.6f), Color.White.copy(alpha = 0.6f),
resolvedThumbColor.copy(alpha = 0.95f), thumbColor.copy(alpha = 0.95f),
resolvedThumbColor, thumbColor,
), ),
center = Offset(0f, -0.55f), center = Offset(0f, -0.55f),
radius = 1.1f, radius = 1.1f,

View File

@ -34,6 +34,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
@ -51,7 +52,6 @@ import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.draw.shadow import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.input.VisualTransformation
@ -61,28 +61,32 @@ import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxSearchBroken import dev.krtirtho.spotube.resources.iconsax.IconsaxSearchBroken
import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash
private val TextFieldShape = RoundedCornerShape(14.dp)
private val TextFieldMinHeight = 44.dp private val TextFieldMinHeight = 44.dp
private data class ResolvedTextFieldState(
val background: Brush,
val highlight: Color,
val shape: Shape,
val border: BaseUITheme.Border,
val shadow: BaseUITheme.Shadow,
val foreground: Color,
)
@Composable @Composable
private fun resolveTextFieldState( internal fun textFieldShadow(
theme: BaseUITheme.TextFieldTheme, shape: androidx.compose.ui.graphics.Shape,
isFocused: Boolean, focused: Boolean,
isHovered: Boolean, colors: ButtonColors,
): ResolvedTextFieldState { ): Modifier {
return when { val elevation = if (focused) 9.dp else 6.dp
isFocused -> ResolvedTextFieldState(theme.background.focused, theme.highlight.focused, theme.shape.focused, theme.border.focused, theme.shadow.focused, theme.foreground.focused) val ambient = if (focused) {
isHovered -> ResolvedTextFieldState(theme.background.hovered, theme.highlight.hovered, theme.shape.hovered, theme.border.hovered, theme.shadow.hovered, theme.foreground.hovered) colors.accent.copy(alpha = 0.2f)
else -> ResolvedTextFieldState(theme.background.pressed, theme.highlight.pressed, theme.shape.pressed, theme.border.pressed, theme.shadow.pressed, theme.foreground.pressed) } else {
colors.shadow.copy(alpha = 0.15f)
} }
val spot = if (focused) {
colors.accent.copy(alpha = 0.25f)
} else {
colors.shadow.copy(alpha = 0.18f)
}
return Modifier.shadow(
elevation = elevation,
shape = shape,
ambientColor = ambient,
spotColor = spot,
)
} }
@Composable @Composable
@ -104,29 +108,25 @@ fun TextField(
visualTransformation: VisualTransformation = VisualTransformation.None, visualTransformation: VisualTransformation = VisualTransformation.None,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
textStyle: TextStyle = TextStyle.Default, textStyle: TextStyle = TextStyle.Default,
cursorBrush: Color? = null, cursorBrush: Color = MaterialTheme.colorScheme.primary,
theme: BaseUITheme.TextFieldTheme? = null,
) { ) {
val baseTheme = LocalBaseUITheme.current.textField val colors = rememberButtonColors()
val textFieldTheme = theme ?: baseTheme
val isFocused by interactionSource.collectIsFocusedAsState() val isFocused by interactionSource.collectIsFocusedAsState()
val isHovered by interactionSource.collectIsHoveredAsState() val isHovered by interactionSource.collectIsHoveredAsState()
val state = resolveTextFieldState(textFieldTheme, isFocused, isHovered) val gradient = outlinedGradient(colors, false)
val border = when {
val resolvedBorder = if (isError) { isError -> MaterialTheme.colorScheme.error
BaseUITheme.Border(MaterialTheme.colorScheme.error, state.border.width) isFocused -> MaterialTheme.colorScheme.primary
} else { isHovered -> colors.border.copy(alpha = 0.85f)
state.border else -> colors.border
} }
val contentColor = when { val contentColor = when {
!enabled -> state.foreground.copy(alpha = 0.38f) !enabled -> colors.onContainer.copy(alpha = 0.38f)
else -> state.foreground else -> colors.onContainer
} }
val cursor = cursorBrush?.let { SolidColor(it) } ?: textFieldTheme.cursor
Column(modifier = modifier) { Column(modifier = modifier) {
AnimatedVisibility( AnimatedVisibility(
visible = label != null, visible = label != null,
@ -139,22 +139,13 @@ fun TextField(
Box( Box(
modifier = Modifier modifier = Modifier
.defaultMinSize(minHeight = TextFieldMinHeight) .defaultMinSize(minHeight = TextFieldMinHeight)
.then( .then(textFieldShadow(TextFieldShape, isFocused, colors))
if (state.shadow.elevation > 0.dp) { .clip(TextFieldShape)
Modifier.shadow( .background(gradient, TextFieldShape)
elevation = state.shadow.elevation, .border(BorderStroke(0.5.dp, border), TextFieldShape)
shape = state.shape,
ambientColor = state.shadow.ambientColor,
spotColor = state.shadow.spotColor,
)
} else Modifier
)
.clip(state.shape)
.background(state.background, state.shape)
.border(BorderStroke(resolvedBorder.width, resolvedBorder.color), state.shape)
.drawWithCache { .drawWithCache {
val highlightBrush = Brush.verticalGradient( val highlightBrush = Brush.verticalGradient(
colors = listOf(state.highlight, Color.Transparent), colors = listOf(colors.highlight, Color.Transparent),
startY = 0f, startY = 0f,
endY = size.height * 0.5f, endY = size.height * 0.5f,
) )
@ -167,7 +158,7 @@ fun TextField(
) )
} }
} }
.padding(textFieldTheme.padding), .padding(horizontal = 14.dp, vertical = 10.dp),
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@ -186,7 +177,7 @@ fun TextField(
enabled = enabled, enabled = enabled,
readOnly = readOnly, readOnly = readOnly,
textStyle = textStyle.copy(color = contentColor), textStyle = textStyle.copy(color = contentColor),
cursorBrush = cursor, cursorBrush = SolidColor(cursorBrush),
keyboardOptions = keyboardOptions, keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions, keyboardActions = keyboardActions,
singleLine = singleLine, singleLine = singleLine,
@ -219,39 +210,36 @@ fun TextField(
@Composable @Composable
fun TextFieldPreview() { fun TextFieldPreview() {
MaterialTheme { MaterialTheme {
val theme = rememberBaseUITheme() androidx.compose.material3.Surface(
CompositionLocalProvider(LocalBaseUITheme provides theme) { color = MaterialTheme.colorScheme.background,
androidx.compose.material3.Surface( modifier = Modifier.padding(24.dp),
color = MaterialTheme.colorScheme.background, ) {
modifier = Modifier.padding(24.dp), TextField(
) { modifier = Modifier.padding(top = 4.dp),
TextField( value = "Twenty One Pilots",
modifier = Modifier.padding(top = 4.dp), onValueChange = {},
value = "Twenty One Pilots", placeholder = { Text("Placeholder") },
onValueChange = {}, label = {
placeholder = { Text("Placeholder") }, Text(
label = { "Search Field",
Text( modifier = Modifier.padding(bottom = 6.dp),
"Search Field", style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(bottom = 6.dp), color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
style = MaterialTheme.typography.labelMedium, )
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), },
) leadingIcon = {
}, androidx.compose.material3.Icon(
leadingIcon = { imageVector = Iconsax.IconsaxSearchBroken,
androidx.compose.material3.Icon( contentDescription = "Search Icon",
imageVector = Iconsax.IconsaxSearchBroken, )
contentDescription = "Search Icon", },
) trailingIcon = {
}, androidx.compose.material3.Icon(
trailingIcon = { imageVector = Iconsax.IconsaxTrash,
androidx.compose.material3.Icon( contentDescription = "Clear Icon",
imageVector = Iconsax.IconsaxTrash, )
contentDescription = "Clear Icon", },
) )
},
)
}
} }
} }
} }

View File

@ -1,756 +0,0 @@
/*
* 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.foundation.layout.PaddingValues
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.staticCompositionLocalOf
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.graphics.luminance
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import dev.krtirtho.spotube.core.ui.base.BaseUITheme.AutoCompleteMenuTheme
import dev.krtirtho.spotube.core.ui.base.BaseUITheme.CheckBoxTheme
import dev.krtirtho.spotube.core.ui.base.BaseUITheme.ChipTabTheme
import dev.krtirtho.spotube.core.ui.base.BaseUITheme.SliderTheme
import dev.krtirtho.spotube.core.ui.base.BaseUITheme.TextFieldTheme
@Stable
data class BaseUITheme(
val buttons: ButtonVariants,
val iconButtons: ButtonVariants,
val textField: TextFieldTheme,
val autoCompleteMenuTheme: AutoCompleteMenuTheme,
val slider: SliderTheme,
val checkBox: CheckBoxTheme,
val chipTab: ChipTabTheme,
val card: CardTheme,
val listRowTile: ListRowTheme,
) {
@Stable
data class InteractionState<T>(
val hovered: T,
val pressed: T,
val focused: T,
) {
companion object {
fun <T> fromSingleValue(value: T): InteractionState<T> {
return InteractionState(value, value, value)
}
}
}
@Stable
data class AdvancedInteractionState<T>(
val hovered: T,
val pressed: T,
val focused: T,
val selected: T,
val disabled: T,
) {
companion object {
fun <T> fromSingleValue(value: T): AdvancedInteractionState<T> {
return AdvancedInteractionState(value, value, value, value, value)
}
}
}
@Stable
data class Border(
val color: Color,
val width: Dp,
)
@Stable
data class Shadow(
val elevation: Dp,
val clip: Boolean = elevation > 0.dp,
val ambientColor: Color,
val spotColor: Color,
)
@Stable
data class ButtonVariants(
val primary: ButtonStyle,
val secondary: ButtonStyle,
val outline: ButtonStyle,
val ghost: ButtonStyle,
)
@Stable
data class ButtonStyle(
val colors: InteractionState<ButtonColors>,
val shape: InteractionState<Shape>,
val shadow: InteractionState<Shadow>,
val border: InteractionState<Border>,
val padding: InteractionState<PaddingValues>,
)
@Stable
data class ButtonColors(
val background: Brush,
val foreground: Color,
val highlight: Color,
)
data class CardTheme(
val background: Brush,
val shape: Shape,
val border: Border,
val padding: PaddingValues,
val shadow: Shadow,
)
data class ListRowTheme(
val background: AdvancedInteractionState<Brush>,
val shape: AdvancedInteractionState<Shape>,
val border: AdvancedInteractionState<Border>,
val shadow: AdvancedInteractionState<Shadow>,
val padding: PaddingValues,
val textStyle: AdvancedInteractionState<TextStyle>,
val foreground: AdvancedInteractionState<Color>,
)
data class TextFieldTheme(
val background: InteractionState<Brush>,
val highlight: InteractionState<Color>,
val shape: InteractionState<Shape>,
val border: InteractionState<Border>,
val shadow: InteractionState<Shadow>,
val padding: PaddingValues,
val textStyle: TextStyle,
val cursor: Brush,
val foreground: InteractionState<Color>,
)
data class AutoCompleteMenuTheme(
val background: Brush,
val shape: Shape,
val border: Border,
val padding: PaddingValues,
val shadowElevation: Dp,
)
data class SliderTheme(
val trackActiveColor: InteractionState<Color>,
val trackInactiveColor: InteractionState<Color>,
val thumbColor: InteractionState<Color>,
val thumbShadow: InteractionState<Shadow>,
val stepActiveColor: Color,
val stepInactiveColor: Color,
)
data class CheckBoxTheme(
val selected: ButtonStyle,
val unselected: ButtonStyle,
val checkmarkColor: Color,
)
data class ChipTabTheme(
val selected: ButtonStyle,
val unselected: ButtonStyle,
)
}
val LocalBaseUITheme = staticCompositionLocalOf<BaseUITheme> {
throw IllegalStateException("No BaseUITheme provided")
}
@Composable
fun rememberBaseUITheme(): BaseUITheme {
val scheme = MaterialTheme.colorScheme
val isLight = scheme.surface.luminance() > 0.5f
val containerLighter = if (isLight) Color.White else scheme.surfaceContainerHigh
val containerDarker = if (isLight) Color(0xFFF2F2F4) else scheme.surfaceContainer
val containerPressed = if (isLight) Color(0xFFE0E0E3) else scheme.surfaceContainerHighest
val highlight = if (isLight) Color.White.copy(alpha = 0.9f) else Color.White.copy(alpha = 0.06f)
val shadowColor = scheme.onSurface
val border = scheme.outlineVariant
val secondaryContainer = scheme.secondaryContainer
val secondaryContainerPressed = secondaryContainer.copy(alpha = if (isLight) 0.85f else 0.92f)
val secondaryHighlight =
if (isLight) Color.White.copy(alpha = 0.5f) else Color.White.copy(alpha = 0.08f)
val defaultShape = RoundedCornerShape(14.dp)
val defaultShapeState =
BaseUITheme.InteractionState<Shape>(defaultShape, defaultShape, defaultShape)
val noBorder = BaseUITheme.Border(Color.Transparent, 0.dp)
val noBorderState = BaseUITheme.InteractionState(noBorder, noBorder, noBorder)
val noShadow = BaseUITheme.Shadow(0.dp, false, Color.Transparent, Color.Transparent)
val noShadowState = BaseUITheme.InteractionState(noShadow, noShadow, noShadow)
val defaultButtonPadding = BaseUITheme.InteractionState(
PaddingValues(horizontal = 20.dp, vertical = 10.dp),
PaddingValues(horizontal = 20.dp, vertical = 10.dp),
PaddingValues(horizontal = 20.dp, vertical = 10.dp),
)
val iconButtonPadding = BaseUITheme.InteractionState(
PaddingValues(8.dp),
PaddingValues(8.dp),
PaddingValues(8.dp),
)
val outlineColors = BaseUITheme.InteractionState(
hovered = BaseUITheme.ButtonColors(
background = Brush.verticalGradient(listOf(containerLighter, containerDarker)),
foreground = scheme.onSurface,
highlight = highlight,
),
pressed = BaseUITheme.ButtonColors(
background = Brush.verticalGradient(listOf(containerPressed, containerPressed)),
foreground = scheme.onSurface,
highlight = highlight,
),
focused = BaseUITheme.ButtonColors(
background = Brush.verticalGradient(listOf(containerLighter, containerDarker)),
foreground = scheme.onSurface,
highlight = highlight,
),
)
val outlineShadow = BaseUITheme.InteractionState(
hovered = BaseUITheme.Shadow(
9.dp,
true,
shadowColor.copy(alpha = 0.22f),
shadowColor.copy(alpha = 0.26f)
),
pressed = BaseUITheme.Shadow(
1.dp,
true,
shadowColor.copy(alpha = 0.08f),
shadowColor.copy(alpha = 0.1f)
),
focused = BaseUITheme.Shadow(
6.dp,
true,
shadowColor.copy(alpha = 0.15f),
shadowColor.copy(alpha = 0.18f)
),
)
val outlineBorder = BaseUITheme.InteractionState(
hovered = BaseUITheme.Border(border, 0.5.dp),
pressed = BaseUITheme.Border(border.copy(alpha = 0.7f), 0.5.dp),
focused = BaseUITheme.Border(border, 0.5.dp),
)
val primaryColors = BaseUITheme.InteractionState(
hovered = BaseUITheme.ButtonColors(
background = Brush.verticalGradient(listOf(scheme.primary, scheme.primary)),
foreground = scheme.onPrimary,
highlight = Color.White.copy(alpha = 0.25f),
),
pressed = BaseUITheme.ButtonColors(
background = Brush.verticalGradient(
listOf(
scheme.primary.copy(alpha = 0.85f),
scheme.primary
)
),
foreground = scheme.onPrimary,
highlight = Color.White.copy(alpha = 0.25f),
),
focused = BaseUITheme.ButtonColors(
background = Brush.verticalGradient(listOf(scheme.primary, scheme.primary)),
foreground = scheme.onPrimary,
highlight = Color.White.copy(alpha = 0.25f),
),
)
val primaryShadow = BaseUITheme.InteractionState(
hovered = BaseUITheme.Shadow(
9.dp,
true,
scheme.primary.copy(alpha = 0.45f),
scheme.primary.copy(alpha = 0.5f)
),
pressed = BaseUITheme.Shadow(
1.dp,
true,
scheme.primary.copy(alpha = 0.2f),
scheme.primary.copy(alpha = 0.25f)
),
focused = BaseUITheme.Shadow(
6.dp,
true,
scheme.primary.copy(alpha = 0.35f),
scheme.primary.copy(alpha = 0.4f)
),
)
val primaryBorder = BaseUITheme.InteractionState(
hovered = BaseUITheme.Border(scheme.primary, 0.5.dp),
pressed = BaseUITheme.Border(scheme.primary, 0.5.dp),
focused = BaseUITheme.Border(scheme.primary, 0.5.dp),
)
val secondaryColors = BaseUITheme.InteractionState(
hovered = BaseUITheme.ButtonColors(
background = Brush.verticalGradient(
listOf(
secondaryContainer,
secondaryContainer.copy(alpha = if (secondaryContainer.luminance() > 0.5f) 0.92f else 1f)
)
),
foreground = scheme.onSecondaryContainer,
highlight = secondaryHighlight,
),
pressed = BaseUITheme.ButtonColors(
background = Brush.verticalGradient(
listOf(
secondaryContainerPressed,
secondaryContainerPressed
)
),
foreground = scheme.onSecondaryContainer,
highlight = secondaryHighlight,
),
focused = BaseUITheme.ButtonColors(
background = Brush.verticalGradient(
listOf(
secondaryContainer,
secondaryContainer.copy(alpha = if (secondaryContainer.luminance() > 0.5f) 0.92f else 1f)
)
),
foreground = scheme.onSecondaryContainer,
highlight = secondaryHighlight,
),
)
val secondaryShadow = BaseUITheme.InteractionState(
hovered = BaseUITheme.Shadow(
9.dp,
true,
shadowColor.copy(alpha = 0.22f),
shadowColor.copy(alpha = 0.26f)
),
pressed = BaseUITheme.Shadow(
1.dp,
true,
shadowColor.copy(alpha = 0.08f),
shadowColor.copy(alpha = 0.1f)
),
focused = BaseUITheme.Shadow(
6.dp,
true,
shadowColor.copy(alpha = 0.15f),
shadowColor.copy(alpha = 0.18f)
),
)
val secondaryBorder = BaseUITheme.InteractionState(
hovered = BaseUITheme.Border(secondaryContainer.copy(alpha = 0.5f), 0.5.dp),
pressed = BaseUITheme.Border(secondaryContainer.copy(alpha = 0.5f), 0.5.dp),
focused = BaseUITheme.Border(secondaryContainer.copy(alpha = 0.5f), 0.5.dp),
)
val ghostColors = BaseUITheme.InteractionState(
hovered = BaseUITheme.ButtonColors(
background = Brush.verticalGradient(
listOf(
containerPressed.copy(alpha = 0.5f),
containerPressed.copy(alpha = 0.5f)
)
),
foreground = scheme.onSurface,
highlight = Color.Transparent,
),
pressed = BaseUITheme.ButtonColors(
background = Brush.verticalGradient(listOf(containerPressed, containerPressed)),
foreground = scheme.onSurface,
highlight = Color.Transparent,
),
focused = BaseUITheme.ButtonColors(
background = Brush.verticalGradient(listOf(Color.Transparent, Color.Transparent)),
foreground = scheme.onSurface,
highlight = Color.Transparent,
),
)
val outlineStyle = BaseUITheme.ButtonStyle(
outlineColors,
defaultShapeState,
outlineShadow,
outlineBorder,
defaultButtonPadding
)
val primaryStyle = BaseUITheme.ButtonStyle(
primaryColors,
defaultShapeState,
primaryShadow,
primaryBorder,
defaultButtonPadding
)
val secondaryStyle = BaseUITheme.ButtonStyle(
secondaryColors,
defaultShapeState,
secondaryShadow,
secondaryBorder,
defaultButtonPadding
)
val ghostStyle = BaseUITheme.ButtonStyle(
ghostColors,
defaultShapeState,
noShadowState,
noBorderState,
defaultButtonPadding
)
val outlineIconStyle = BaseUITheme.ButtonStyle(
outlineColors,
defaultShapeState,
outlineShadow,
outlineBorder,
iconButtonPadding
)
val primaryIconStyle = BaseUITheme.ButtonStyle(
primaryColors,
defaultShapeState,
primaryShadow,
primaryBorder,
iconButtonPadding
)
val secondaryIconStyle = BaseUITheme.ButtonStyle(
secondaryColors,
defaultShapeState,
secondaryShadow,
secondaryBorder,
iconButtonPadding
)
val ghostIconStyle = BaseUITheme.ButtonStyle(
ghostColors,
defaultShapeState,
noShadowState,
noBorderState,
iconButtonPadding
)
val textFieldForeground = BaseUITheme.InteractionState(
hovered = scheme.onSurface,
pressed = scheme.onSurface,
focused = scheme.onSurface,
)
val checkBoxShape = RoundedCornerShape(6.dp)
val checkBoxShapeState =
BaseUITheme.InteractionState<Shape>(checkBoxShape, checkBoxShape, checkBoxShape)
val noPadding =
BaseUITheme.InteractionState(PaddingValues(0.dp), PaddingValues(0.dp), PaddingValues(0.dp))
val chipTabShape = RoundedCornerShape(10.dp)
val chipTabShapeState =
BaseUITheme.InteractionState<Shape>(chipTabShape, chipTabShape, chipTabShape)
val chipTabPadding = BaseUITheme.InteractionState(
PaddingValues(horizontal = 14.dp, vertical = 4.dp),
PaddingValues(horizontal = 14.dp, vertical = 4.dp),
PaddingValues(horizontal = 14.dp, vertical = 4.dp),
)
return BaseUITheme(
buttons = BaseUITheme.ButtonVariants(
primary = primaryStyle,
secondary = secondaryStyle,
outline = outlineStyle,
ghost = ghostStyle,
),
iconButtons = BaseUITheme.ButtonVariants(
primary = primaryIconStyle,
secondary = secondaryIconStyle,
outline = outlineIconStyle,
ghost = ghostIconStyle,
),
textField = TextFieldTheme(
background = BaseUITheme.InteractionState(
hovered = Brush.verticalGradient(listOf(containerLighter, containerDarker)),
pressed = Brush.verticalGradient(listOf(containerLighter, containerDarker)),
focused = Brush.verticalGradient(listOf(containerLighter, containerDarker)),
),
highlight = BaseUITheme.InteractionState(
hovered = highlight,
pressed = highlight,
focused = highlight,
),
shape = BaseUITheme.InteractionState(
hovered = defaultShape,
pressed = defaultShape,
focused = defaultShape,
),
border = BaseUITheme.InteractionState(
hovered = BaseUITheme.Border(border.copy(alpha = 0.85f), 0.5.dp),
pressed = BaseUITheme.Border(border, 0.5.dp),
focused = BaseUITheme.Border(scheme.primary, 0.5.dp),
),
shadow = BaseUITheme.InteractionState(
hovered = BaseUITheme.Shadow(
9.dp,
true,
shadowColor.copy(alpha = 0.22f),
shadowColor.copy(alpha = 0.26f)
),
pressed = BaseUITheme.Shadow(
6.dp,
true,
shadowColor.copy(alpha = 0.15f),
shadowColor.copy(alpha = 0.18f)
),
focused = BaseUITheme.Shadow(
9.dp,
true,
scheme.primary.copy(alpha = 0.2f),
scheme.primary.copy(alpha = 0.25f)
),
),
padding = PaddingValues(horizontal = 14.dp, vertical = 10.dp),
textStyle = TextStyle.Default,
cursor = SolidColor(scheme.primary),
foreground = textFieldForeground,
),
autoCompleteMenuTheme = AutoCompleteMenuTheme(
background = Brush.verticalGradient(
listOf(
scheme.surfaceContainerHigh,
scheme.surfaceContainer
)
),
shape = RoundedCornerShape(12.dp),
border = BaseUITheme.Border(border, 0.5.dp),
padding = PaddingValues(0.dp),
shadowElevation = 8.dp,
),
slider = SliderTheme(
trackActiveColor = BaseUITheme.InteractionState(
hovered = scheme.primary,
pressed = scheme.primary,
focused = scheme.primary,
),
trackInactiveColor = BaseUITheme.InteractionState(
hovered = border.copy(alpha = 0.6f),
pressed = border.copy(alpha = 0.6f),
focused = border.copy(alpha = 0.6f),
),
thumbColor = BaseUITheme.InteractionState(
hovered = scheme.primary,
pressed = scheme.primary,
focused = scheme.primary,
),
thumbShadow = BaseUITheme.InteractionState(
hovered = BaseUITheme.Shadow(
8.dp,
true,
scheme.primary.copy(alpha = 0.4f),
scheme.primary.copy(alpha = 0.5f)
),
pressed = BaseUITheme.Shadow(
2.dp,
true,
scheme.primary.copy(alpha = 0.2f),
scheme.primary.copy(alpha = 0.25f)
),
focused = BaseUITheme.Shadow(
5.dp,
true,
scheme.primary.copy(alpha = 0.35f),
scheme.primary.copy(alpha = 0.4f)
),
),
stepActiveColor = scheme.onPrimary.copy(alpha = 0.5f),
stepInactiveColor = scheme.onSurface.copy(alpha = 0.25f),
),
checkBox = CheckBoxTheme(
selected = BaseUITheme.ButtonStyle(
colors = primaryColors,
shape = checkBoxShapeState,
shadow = BaseUITheme.InteractionState(
hovered = BaseUITheme.Shadow(
8.dp,
true,
scheme.primary.copy(alpha = 0.4f),
scheme.primary.copy(alpha = 0.45f)
),
pressed = BaseUITheme.Shadow(
1.dp,
true,
scheme.primary.copy(alpha = 0.2f),
scheme.primary.copy(alpha = 0.25f)
),
focused = BaseUITheme.Shadow(
6.dp,
true,
scheme.primary.copy(alpha = 0.3f),
scheme.primary.copy(alpha = 0.35f)
),
),
border = primaryBorder,
padding = noPadding,
),
unselected = BaseUITheme.ButtonStyle(
colors = outlineColors,
shape = checkBoxShapeState,
shadow = BaseUITheme.InteractionState(
hovered = BaseUITheme.Shadow(
5.dp,
true,
shadowColor.copy(alpha = 0.2f),
shadowColor.copy(alpha = 0.24f)
),
pressed = BaseUITheme.Shadow(
1.dp,
true,
shadowColor.copy(alpha = 0.08f),
shadowColor.copy(alpha = 0.1f)
),
focused = BaseUITheme.Shadow(
3.dp,
true,
shadowColor.copy(alpha = 0.15f),
shadowColor.copy(alpha = 0.18f)
),
),
border = outlineBorder,
padding = noPadding,
),
checkmarkColor = scheme.onPrimary,
),
chipTab = ChipTabTheme(
selected = BaseUITheme.ButtonStyle(
colors = primaryColors,
shape = chipTabShapeState,
shadow = primaryShadow,
border = primaryBorder,
padding = chipTabPadding,
),
unselected = BaseUITheme.ButtonStyle(
colors = outlineColors,
shape = chipTabShapeState,
shadow = outlineShadow,
border = outlineBorder,
padding = chipTabPadding,
),
),
card = BaseUITheme.CardTheme(
background = Brush.verticalGradient(listOf(containerLighter, containerDarker)),
shape = RoundedCornerShape(16.dp),
border = BaseUITheme.Border(border, 0.5.dp),
padding = PaddingValues(0.dp),
shadow = BaseUITheme.Shadow(6.dp, true, shadowColor.copy(alpha = 0.15f), shadowColor.copy(alpha = 0.18f)),
),
listRowTile = BaseUITheme.ListRowTheme(
background = BaseUITheme.AdvancedInteractionState(
hovered = Brush.verticalGradient(listOf(containerLighter, containerDarker)),
pressed = Brush.verticalGradient(listOf(containerPressed, containerPressed)),
focused = Brush.verticalGradient(listOf(containerLighter, containerDarker)),
selected = Brush.verticalGradient(listOf(scheme.primaryContainer.copy(alpha = 0.3f), scheme.primaryContainer.copy(alpha = 0.3f))),
disabled = Brush.verticalGradient(listOf(containerLighter.copy(alpha = 0.5f), containerDarker.copy(alpha = 0.5f))),
),
shape = BaseUITheme.AdvancedInteractionState(
hovered = RoundedCornerShape(8.dp),
pressed = RoundedCornerShape(8.dp),
focused = RoundedCornerShape(8.dp),
selected = RoundedCornerShape(8.dp),
disabled = RoundedCornerShape(8.dp),
),
border = BaseUITheme.AdvancedInteractionState(
hovered = BaseUITheme.Border(Color.Transparent, 0.dp),
pressed = BaseUITheme.Border(Color.Transparent, 0.dp),
focused = BaseUITheme.Border(Color.Transparent, 0.dp),
selected = BaseUITheme.Border(Color.Transparent, 0.dp),
disabled = BaseUITheme.Border(Color.Transparent, 0.dp),
),
shadow = BaseUITheme.AdvancedInteractionState(
hovered = BaseUITheme.Shadow(0.dp, false, Color.Transparent, Color.Transparent),
pressed = BaseUITheme.Shadow(0.dp, false, Color.Transparent, Color.Transparent),
focused = BaseUITheme.Shadow(0.dp, false, Color.Transparent, Color.Transparent),
selected = BaseUITheme.Shadow(0.dp, false, Color.Transparent, Color.Transparent),
disabled = BaseUITheme.Shadow(0.dp, false, Color.Transparent, Color.Transparent),
),
padding = PaddingValues(horizontal = 8.dp, vertical = 6.dp),
textStyle = BaseUITheme.AdvancedInteractionState(
hovered = TextStyle.Default,
pressed = TextStyle.Default,
focused = TextStyle.Default,
selected = TextStyle.Default,
disabled = TextStyle.Default,
),
foreground = BaseUITheme.AdvancedInteractionState(
hovered = scheme.onSurface,
pressed = scheme.onSurface,
focused = scheme.onSurface,
selected = scheme.onSurface,
disabled = scheme.onSurface.copy(alpha = 0.38f),
),
),
)
}
// Helper functions for easier theme overrides
fun BaseUITheme.ButtonStyle.copyShape(shape: Shape): BaseUITheme.ButtonStyle =
copy(shape = BaseUITheme.InteractionState.fromSingleValue(shape))
fun BaseUITheme.ButtonStyle.copyShadow(shadow: BaseUITheme.Shadow): BaseUITheme.ButtonStyle =
copy(shadow = BaseUITheme.InteractionState.fromSingleValue(shadow))
fun BaseUITheme.ButtonStyle.copyBorder(border: BaseUITheme.Border): BaseUITheme.ButtonStyle =
copy(border = BaseUITheme.InteractionState.fromSingleValue(border))
fun BaseUITheme.ButtonStyle.copyPadding(padding: PaddingValues): BaseUITheme.ButtonStyle =
copy(padding = BaseUITheme.InteractionState.fromSingleValue(padding))
fun BaseUITheme.ButtonStyle.copyColors(colors: BaseUITheme.ButtonColors): BaseUITheme.ButtonStyle =
copy(colors = BaseUITheme.InteractionState.fromSingleValue(colors))
@Composable
fun invertedButtonStyle(): BaseUITheme.ButtonStyle {
val scheme = MaterialTheme.colorScheme
val isLight = scheme.surface.luminance() > 0.5f
val shadowColor = scheme.onSurface
val invertedColors = BaseUITheme.ButtonColors(
background = Brush.verticalGradient(listOf(scheme.onSurface, scheme.onSurface)),
foreground = scheme.surface,
highlight = if (isLight) Color.White.copy(alpha = 0.15f) else Color.White.copy(alpha = 0.08f),
)
val pressedColors = BaseUITheme.ButtonColors(
background = Brush.verticalGradient(listOf(scheme.onSurfaceVariant, scheme.onSurfaceVariant)),
foreground = scheme.surface,
highlight = if (isLight) Color.White.copy(alpha = 0.1f) else Color.White.copy(alpha = 0.05f),
)
val border = BaseUITheme.Border(scheme.onSurfaceVariant, 0.5.dp)
val shadow = BaseUITheme.Shadow(
elevation = 6.dp,
clip = true,
ambientColor = shadowColor.copy(alpha = 0.2f),
spotColor = shadowColor.copy(alpha = 0.25f),
)
return BaseUITheme.ButtonStyle(
colors = BaseUITheme.InteractionState(
hovered = invertedColors,
pressed = pressedColors,
focused = invertedColors,
),
shape = BaseUITheme.InteractionState.fromSingleValue(RoundedCornerShape(14.dp)),
shadow = BaseUITheme.InteractionState.fromSingleValue(shadow),
border = BaseUITheme.InteractionState.fromSingleValue(border),
padding = BaseUITheme.InteractionState.fromSingleValue(PaddingValues(8.dp)),
)
}

View File

@ -21,8 +21,10 @@ import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith import androidx.compose.animation.togetherWith
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
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.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.hoverable import androidx.compose.foundation.hoverable
@ -61,10 +63,10 @@ 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.draw.drawWithCache import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.focus.focusModifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.platform.LocalWindowInfo
@ -84,13 +86,14 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.Thumbnail
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.ui.base.ButtonGroup import dev.krtirtho.spotube.core.ui.base.ButtonGroup
import dev.krtirtho.spotube.core.ui.base.ButtonGroupDivider import dev.krtirtho.spotube.core.ui.base.ButtonGroupDivider
import dev.krtirtho.spotube.core.ui.base.Card
import dev.krtirtho.spotube.core.ui.base.CheckBox import dev.krtirtho.spotube.core.ui.base.CheckBox
import dev.krtirtho.spotube.core.ui.base.CheckBoxState import dev.krtirtho.spotube.core.ui.base.CheckBoxState
import dev.krtirtho.spotube.core.ui.base.GhostIconButton import dev.krtirtho.spotube.core.ui.base.GhostIconButton
import dev.krtirtho.spotube.core.ui.base.GroupIconButton import dev.krtirtho.spotube.core.ui.base.GroupIconButton
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme import dev.krtirtho.spotube.core.ui.base.IconButton
import dev.krtirtho.spotube.core.ui.base.TextField import dev.krtirtho.spotube.core.ui.base.TextField
import dev.krtirtho.spotube.core.ui.base.buttonShadow
import dev.krtirtho.spotube.core.ui.base.rememberButtonColors
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer
import dev.krtirtho.spotube.core.ui.misc.shimmerApply import dev.krtirtho.spotube.core.ui.misc.shimmerApply
@ -345,10 +348,25 @@ fun TrackList(
} }
item(key = "track-card") { item(key = "track-card") {
Card( val colors = rememberButtonColors()
val trackListShape = MaterialTheme.shapes.large
Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 8.dp), .padding(vertical = 8.dp)
.then(
buttonShadow(
trackListShape,
pressed = false,
primary = false,
colors,
hovered = false
)
)
.clip(trackListShape)
.background(MaterialTheme.colorScheme.surfaceContainer, trackListShape)
.border(BorderStroke(0.5.dp, colors.border), trackListShape)
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
@ -514,21 +532,17 @@ private fun TrackListRow(
onArtistsOverflowClick: () -> Unit, onArtistsOverflowClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val rowTheme = LocalBaseUITheme.current.listRowTile val rowColors = rememberButtonColors()
val isLight = MaterialTheme.colorScheme.surface.luminance() > 0.5f
val artworkInteractionSource = remember { MutableInteractionSource() } val artworkInteractionSource = remember { MutableInteractionSource() }
val isArtworkHovered by artworkInteractionSource.collectIsHoveredAsState() val isArtworkHovered by artworkInteractionSource.collectIsHoveredAsState()
val rowInteractionSource = remember { MutableInteractionSource() } val rowInteractionSource = remember { MutableInteractionSource() }
val isRowHovered by rowInteractionSource.collectIsHoveredAsState() val isRowHovered by rowInteractionSource.collectIsHoveredAsState()
val rowBackgroundColor = when { val rowBackgroundColor = when {
isSelected -> rowTheme.background.selected isSelected -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
isCurrentTrack && isCurrentTrackPlaying -> rowTheme.background.hovered isCurrentTrack && isCurrentTrackPlaying -> MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)
isCurrentTrack -> rowTheme.background.focused isCurrentTrack -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f)
else -> Brush.verticalGradient(listOf(Color.Transparent, Color.Transparent)) else -> Color.Transparent
} }
val highlightColor = Color.White.copy(
alpha = if (isLight) 0.9f else 0.06f
)
Row( Row(
modifier = modifier modifier = modifier
@ -541,7 +555,7 @@ private fun TrackListRow(
.background(rowBackgroundColor) .background(rowBackgroundColor)
.drawWithCache { .drawWithCache {
val highlightBrush = Brush.verticalGradient( val highlightBrush = Brush.verticalGradient(
colors = listOf(highlightColor, Color.Transparent), colors = listOf(rowColors.highlight, Color.Transparent),
startY = 0f, startY = 0f,
endY = size.height * 0.5f, endY = size.height * 0.5f,
) )

View File

@ -33,9 +33,9 @@ import androidx.compose.foundation.layout.height
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.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import dev.krtirtho.spotube.core.ui.base.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -49,16 +49,14 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import dev.krtirtho.spotube.core.ui.base.BaseUITheme
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
import dev.krtirtho.spotube.core.ui.base.PrimaryIconButton import dev.krtirtho.spotube.core.ui.base.PrimaryIconButton
import dev.krtirtho.spotube.core.ui.base.SecondaryIconButton import dev.krtirtho.spotube.core.ui.base.SecondaryIconButton
import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer
import dev.krtirtho.spotube.core.ui.misc.shimmerApply import dev.krtirtho.spotube.core.ui.misc.shimmerApply
import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
import dev.krtirtho.spotube.resources.iconsax.IconsaxPause import dev.krtirtho.spotube.resources.iconsax.IconsaxPauseCircle
import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay import dev.krtirtho.spotube.resources.iconsax.Play
@Composable @Composable
fun PlayableCard( fun PlayableCard(
@ -110,11 +108,7 @@ fun PlayableCard(
SecondaryIconButton( SecondaryIconButton(
onClick = onAddToQueue, onClick = onAddToQueue,
modifier = Modifier.size(30.dp), modifier = Modifier.size(30.dp),
theme = LocalBaseUITheme.current.iconButtons.secondary.copy( shape = RoundedCornerShape(6.dp)
shape = BaseUITheme.InteractionState.fromSingleValue(
CircleShape
)
)
) { ) {
Icon( Icon(
imageVector = Iconsax.IconsaxAddSquare, imageVector = Iconsax.IconsaxAddSquare,
@ -127,17 +121,12 @@ fun PlayableCard(
PrimaryIconButton( PrimaryIconButton(
onClick = onPlay, onClick = onPlay,
modifier = Modifier.size(30.dp), modifier = Modifier.size(30.dp),
theme = LocalBaseUITheme.current.iconButtons.secondary.copy( shape = RoundedCornerShape(6.dp)
shape = BaseUITheme.InteractionState.fromSingleValue(
CircleShape
)
)
) { ) {
Icon( Icon(
imageVector = if (isPlaying) { imageVector = if (isPlaying) {
Iconsax.IconsaxPause Iconsax.IconsaxPauseCircle
} else Iconsax.IconsaxPlay, } else Iconsax.Play,
contentDescription = "Play", contentDescription = "Play",
) )
} }

View File

@ -83,13 +83,10 @@ import dev.krtirtho.spotube.core.audioplayer.LoopState
import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.ui.base.BaseUITheme
import dev.krtirtho.spotube.core.ui.base.GhostIconButton import dev.krtirtho.spotube.core.ui.base.GhostIconButton
import dev.krtirtho.spotube.core.ui.base.IconButton import dev.krtirtho.spotube.core.ui.base.IconButton
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
import dev.krtirtho.spotube.core.ui.base.Slider import dev.krtirtho.spotube.core.ui.base.Slider
import dev.krtirtho.spotube.core.ui.base.copyShape import dev.krtirtho.spotube.core.ui.base.rememberButtonColors
import dev.krtirtho.spotube.core.ui.base.invertedButtonStyle
import dev.krtirtho.spotube.modules.downloads.DownloadProgressIcon import dev.krtirtho.spotube.modules.downloads.DownloadProgressIcon
import dev.krtirtho.spotube.modules.downloads.DownloadStatus import dev.krtirtho.spotube.modules.downloads.DownloadStatus
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
@ -469,10 +466,7 @@ fun AppExpandedPlayer(
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
} }
IconButton( IconButton(onClick = onQueue, shape = CircleShape) {
onClick = onQueue,
theme = LocalBaseUITheme.current.iconButtons.outline.copyShape(CircleShape)
) {
Icon(Iconsax.IconsaxMusicFilter, contentDescription = "Queue") Icon(Iconsax.IconsaxMusicFilter, contentDescription = "Queue")
} }
} }
@ -532,14 +526,20 @@ fun AppExpandedPlayer(
} }
IconButton( IconButton(
onClick = ::onPlayPause, onClick = ::onPlayPause,
theme = invertedButtonStyle().copyShape(CircleShape), colors = rememberButtonColors().copy(
containerDarker = MaterialTheme.colorScheme.onSurface,
containerLighter = MaterialTheme.colorScheme.onSurface,
containerPressed = MaterialTheme.colorScheme.onSurfaceVariant,
),
modifier = Modifier modifier = Modifier
.size(72.dp), .size(72.dp),
shape = CircleShape,
) { ) {
Icon( Icon(
if (playerUiState.isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, if (playerUiState.isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
contentDescription = if (playerUiState.isPlaying) "Pause" else "Play", contentDescription = if (playerUiState.isPlaying) "Pause" else "Play",
modifier = Modifier.size(30.dp), modifier = Modifier.size(30.dp),
tint = MaterialTheme.colorScheme.surface,
) )
} }
GhostIconButton(onClick = ::onSkipNext) { GhostIconButton(onClick = ::onSkipNext) {

View File

@ -28,7 +28,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
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.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape 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
@ -55,12 +54,9 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.LoopState import dev.krtirtho.spotube.core.audioplayer.LoopState
import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.ui.base.GhostIconButton import dev.krtirtho.spotube.core.ui.base.GhostIconButton
import dev.krtirtho.spotube.core.ui.base.IconButton
import dev.krtirtho.spotube.core.ui.base.Slider import dev.krtirtho.spotube.core.ui.base.Slider
import dev.krtirtho.spotube.core.ui.base.VariableIconButton import dev.krtirtho.spotube.core.ui.base.VariableIconButton
import dev.krtirtho.spotube.core.ui.base.VariableIconButtonVariant import dev.krtirtho.spotube.core.ui.base.VariableIconButtonVariant
import dev.krtirtho.spotube.core.ui.base.copyShape
import dev.krtirtho.spotube.core.ui.base.invertedButtonStyle
import dev.krtirtho.spotube.modules.downloads.DownloadProgressIcon import dev.krtirtho.spotube.modules.downloads.DownloadProgressIcon
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
import dev.krtirtho.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID import dev.krtirtho.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID
@ -278,12 +274,7 @@ fun AppLargePlayer(
GhostIconButton(onClick = ::onSkipPrevious) { GhostIconButton(onClick = ::onSkipPrevious) {
Icon(Iconsax.IconsaxPrevious, contentDescription = "Previous") Icon(Iconsax.IconsaxPrevious, contentDescription = "Previous")
} }
IconButton( GhostIconButton(onClick = ::onPlayPause, modifier = Modifier.size(44.dp)) {
onClick = ::onPlayPause,
theme = invertedButtonStyle().copyShape(CircleShape),
modifier = Modifier
.size(50.dp),
) {
Icon( Icon(
if (playerUiState.isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, if (playerUiState.isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
contentDescription = if (playerUiState.isPlaying) "Pause" else "Play or pause", contentDescription = if (playerUiState.isPlaying) "Pause" else "Play or pause",

View File

@ -21,8 +21,14 @@ 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
@ -36,26 +42,34 @@ import androidx.compose.foundation.layout.height
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.foundation.layout.width import androidx.compose.foundation.layout.width
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.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 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.LocalBaseUITheme
import dev.krtirtho.spotube.core.ui.base.OutlineButton
import dev.krtirtho.spotube.core.ui.base.SecondaryButton import dev.krtirtho.spotube.core.ui.base.SecondaryButton
import dev.krtirtho.spotube.core.ui.base.copyPadding 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
@ -201,16 +215,66 @@ fun SidebarItem(
SecondaryButton( SecondaryButton(
onClick = onClick, onClick = onClick,
modifier = buttonModifier, modifier = buttonModifier,
theme = LocalBaseUITheme.current.buttons.secondary.copyPadding(contentPadding), contentPadding = contentPadding,
content = itemContent, content = itemContent,
) )
} else { } else {
OutlineButton( val colors = rememberButtonColors()
onClick = onClick, val interactionSource = remember { MutableInteractionSource() }
modifier = buttonModifier, val isHovered by interactionSource.collectIsHoveredAsState()
theme = LocalBaseUITheme.current.buttons.outline.copyPadding(contentPadding), val isPressed by interactionSource.collectIsPressedAsState()
hoverOnly = true, val gradient = outlinedGradient(colors, isPressed)
content = itemContent, 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,
)
}
} }
} }

View File

@ -27,8 +27,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
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.ui.base.IconButton import dev.krtirtho.spotube.core.ui.base.IconButton
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
import dev.krtirtho.spotube.core.ui.base.copyShape
import dev.krtirtho.spotube.modules.saved_tracks.SavedState import dev.krtirtho.spotube.modules.saved_tracks.SavedState
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksViewModel import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksViewModel
import dev.krtirtho.spotube.modules.saved_tracks.rememberIsSavedTracks import dev.krtirtho.spotube.modules.saved_tracks.rememberIsSavedTracks
@ -71,7 +69,7 @@ fun PlayerHeartButton(
IconButton( IconButton(
onClick = ::onLike, onClick = ::onLike,
enabled = isSavedTrackState is SavedState.Success, enabled = isSavedTrackState is SavedState.Success,
theme = LocalBaseUITheme.current.iconButtons.outline.copyShape(CircleShape), shape = CircleShape,
) { ) {
Icon( Icon(
imageVector = if (isLiked) Iconsax.IconsaxHeart2 else Iconsax.IconsaxHeart, imageVector = if (isLiked) Iconsax.IconsaxHeart2 else Iconsax.IconsaxHeart,

View File

@ -37,19 +37,21 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text 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.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.collectAsState
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
@ -61,11 +63,7 @@ import compose.icons.FeatherIcons
import compose.icons.feathericons.MoreVertical import compose.icons.feathericons.MoreVertical
import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.di.rememberLogger import dev.krtirtho.spotube.core.di.rememberLogger
import dev.krtirtho.spotube.core.ui.base.GhostIconButton
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
import dev.krtirtho.spotube.core.ui.base.SecondaryIconButton
import dev.krtirtho.spotube.core.ui.base.TextField import dev.krtirtho.spotube.core.ui.base.TextField
import dev.krtirtho.spotube.core.ui.base.copyShape
import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicSquareRemove import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicSquareRemove
@ -188,9 +186,9 @@ fun PlayerQueueContent(
singleLine = true, singleLine = true,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
SecondaryIconButton( FilledTonalIconButton(
onClick = viewModel::clearQueue, onClick = viewModel::clearQueue,
theme = LocalBaseUITheme.current.iconButtons.secondary.copyShape(MaterialTheme.shapes.small), shape = MaterialTheme.shapes.small,
) { ) {
Icon(Iconsax.IconsaxTrash, contentDescription = "Clear Queue") Icon(Iconsax.IconsaxTrash, contentDescription = "Clear Queue")
} }
@ -335,7 +333,7 @@ private fun QueueItemRow(
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
Box { Box {
GhostIconButton( IconButton(
onClick = { showMenu = true }, onClick = { showMenu = true },
modifier = Modifier.size(36.dp), modifier = Modifier.size(36.dp),
) { ) {