Compare commits

...

3 Commits

16 changed files with 1652 additions and 879 deletions

View File

@ -20,8 +20,11 @@ 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
@ -100,6 +103,8 @@ fun App(
} }
SpotubeTheme(settings = userSettings) { SpotubeTheme(settings = userSettings) {
val baseUITheme = rememberBaseUITheme()
CompositionLocalProvider(LocalBaseUITheme provides baseUITheme) {
AppShell(navigator, navigationState) { AppShell(navigator, navigationState) {
Column { Column {
NavDisplay( NavDisplay(
@ -112,3 +117,4 @@ fun App(
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.layout.Arrangement
import androidx.compose.foundation.focusable import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@ -41,7 +41,6 @@ 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
@ -87,12 +86,55 @@ 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,
@ -115,16 +157,18 @@ 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 = MaterialTheme.colorScheme.primary, cursorBrush: Color? = null,
onKeyEvent: ((KeyEvent) -> Boolean)? = null, onKeyEvent: ((KeyEvent) -> Boolean)? = null,
menuMaxHeight: Dp = AutocompleteMenuDefaultMaxHeight, menuMaxHeight: Dp = AutocompleteMenuDefaultMaxHeight,
menuOffset: Dp = AutocompleteMenuDefaultOffset, menuOffset: Dp = AutocompleteMenuDefaultOffset,
menuShape: Shape = AutocompleteMenuShape, textFieldTheme: BaseUITheme.TextFieldTheme? = null,
menuShadowElevation: Dp = 8.dp, menuTheme: BaseUITheme.AutoCompleteMenuTheme? = null,
menuContainerColor: Color = MaterialTheme.colorScheme.surfaceContainerHigh,
) { ) {
val density = LocalDensity.current val density = LocalDensity.current
val colors = rememberButtonColors() val baseTextFieldTheme = LocalBaseUITheme.current.textField
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()
@ -174,19 +218,21 @@ fun <T> AutocompleteTextField(
) )
} }
val gradient = outlinedGradient(colors, false) val fieldState = resolveTextFieldState(resolvedTextFieldTheme, isFocused, isHovered)
val border = when {
isError -> MaterialTheme.colorScheme.error val resolvedBorder = if (isError) {
isFocused -> MaterialTheme.colorScheme.primary BaseUITheme.Border(MaterialTheme.colorScheme.error, fieldState.border.width)
isHovered -> colors.border.copy(alpha = 0.85f) } else {
else -> colors.border fieldState.border
} }
val contentColor = when { val contentColor = when {
!enabled -> colors.onContainer.copy(alpha = 0.38f) !enabled -> fieldState.foreground.copy(alpha = 0.38f)
else -> colors.onContainer else -> fieldState.foreground
} }
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
@ -207,13 +253,25 @@ fun <T> AutocompleteTextField(
.fillMaxWidth() .fillMaxWidth()
.onSizeChanged { textFieldSize = it } .onSizeChanged { textFieldSize = it }
.defaultMinSize(minHeight = AutocompleteTextFieldMinHeight) .defaultMinSize(minHeight = AutocompleteTextFieldMinHeight)
.then(textFieldShadow(AutocompleteTextFieldShape, isFocused, colors)) .then(
.clip(AutocompleteTextFieldShape) if (fieldState.shadow.elevation > 0.dp) {
.background(gradient, AutocompleteTextFieldShape) Modifier.shadow(
.border(BorderStroke(.5.dp, border), AutocompleteTextFieldShape) elevation = fieldState.shadow.elevation,
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(colors.highlight, Color.Transparent), colors = listOf(fieldState.highlight, Color.Transparent),
startY = 0f, startY = 0f,
endY = size.height * 0.5f, endY = size.height * 0.5f,
) )
@ -226,7 +284,7 @@ fun <T> AutocompleteTextField(
) )
} }
} }
.padding(horizontal = 14.dp, vertical = 10.dp), .padding(resolvedTextFieldTheme.padding),
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@ -294,7 +352,7 @@ fun <T> AutocompleteTextField(
enabled = enabled, enabled = enabled,
readOnly = readOnly, readOnly = readOnly,
textStyle = textStyle.copy(color = contentColor), textStyle = textStyle.copy(color = contentColor),
cursorBrush = SolidColor(cursorBrush), cursorBrush = cursor,
keyboardOptions = keyboardOptions, keyboardOptions = keyboardOptions,
keyboardActions = wrappedKeyboardActions, keyboardActions = wrappedKeyboardActions,
singleLine = singleLine, singleLine = singleLine,
@ -340,9 +398,9 @@ fun <T> AutocompleteTextField(
modifier = Modifier modifier = Modifier
.width(textFieldWidthDp) .width(textFieldWidthDp)
.heightIn(max = menuMaxHeight) .heightIn(max = menuMaxHeight)
.shadow(menuShadowElevation, menuShape) .shadow(resolvedMenuTheme.shadowElevation, resolvedMenuTheme.shape)
.background(menuContainerColor, menuShape) .background(resolvedMenuTheme.background, resolvedMenuTheme.shape)
.clip(menuShape) .clip(resolvedMenuTheme.shape)
.focusRequester(popupFocusRequester) .focusRequester(popupFocusRequester)
.focusable() .focusable()
.onFocusChanged { state -> .onFocusChanged { state ->
@ -412,6 +470,8 @@ fun <T> AutocompleteTextField(
@Composable @Composable
private fun AutocompleteTextFieldPreview() { private fun AutocompleteTextFieldPreview() {
MaterialTheme { MaterialTheme {
val theme = rememberBaseUITheme()
CompositionLocalProvider(LocalBaseUITheme provides theme) {
androidx.compose.material3.Surface( androidx.compose.material3.Surface(
color = MaterialTheme.colorScheme.background, color = MaterialTheme.colorScheme.background,
modifier = Modifier.padding(24.dp), modifier = Modifier.padding(24.dp),
@ -479,3 +539,4 @@ private fun AutocompleteTextFieldPreview() {
} }
} }
} }
}

View File

@ -44,7 +44,6 @@ 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.CompositionLocalProvider
import androidx.compose.runtime.Immutable
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
@ -54,8 +53,8 @@ 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.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@ -71,163 +70,45 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxNext
import dev.krtirtho.spotube.resources.iconsax.IconsaxShare import dev.krtirtho.spotube.resources.iconsax.IconsaxShare
import dev.krtirtho.spotube.resources.iconsax.User import dev.krtirtho.spotube.resources.iconsax.User
private val ButtonShape = RoundedCornerShape(14.dp)
private val GroupShape = RoundedCornerShape(14.dp)
private val BadgeShape = RoundedCornerShape(11.dp) private val BadgeShape = RoundedCornerShape(11.dp)
private val ButtonMinHeight = 40.dp private val ButtonMinHeight = 40.dp
private val SquareButtonSize = 40.dp private val SquareButtonSize = 40.dp
@Immutable private data class ResolvedButtonState(
data class ButtonColors( val colors: BaseUITheme.ButtonColors,
val containerLighter: Color, val shape: Shape,
val containerDarker: Color, val shadow: BaseUITheme.Shadow,
val containerPressed: Color, val border: BaseUITheme.Border,
val onContainer: Color, val padding: PaddingValues,
val border: Color,
val accent: Color,
val onAccent: Color,
val secondaryContainer: Color,
val onSecondaryContainer: Color,
val secondaryContainerPressed: Color,
val secondaryHighlight: Color,
val shadow: Color,
val highlight: Color,
) )
@Composable @Composable
fun rememberButtonColors(): ButtonColors { private fun resolveButtonState(
val scheme = MaterialTheme.colorScheme style: BaseUITheme.ButtonStyle,
val isLight = scheme.surface.luminance() > 0.5f isPressed: Boolean,
isHovered: Boolean,
return remember(scheme) { ): ResolvedButtonState {
ButtonColors( return when {
containerLighter = if (isLight) { isPressed -> ResolvedButtonState(style.colors.pressed, style.shape.pressed, style.shadow.pressed, style.border.pressed, style.padding.pressed)
Color.White isHovered -> ResolvedButtonState(style.colors.hovered, style.shape.hovered, style.shadow.hovered, style.border.hovered, style.padding.hovered)
} else { else -> ResolvedButtonState(style.colors.focused, style.shape.focused, style.shadow.focused, style.border.focused, style.padding.focused)
scheme.surfaceContainerHigh
},
containerDarker = if (isLight) {
Color(0xFFF2F2F4)
} else {
scheme.surfaceContainer
},
containerPressed = if (isLight) {
Color(0xFFE0E0E3)
} else {
scheme.surfaceContainerHighest
},
onContainer = scheme.onSurface,
border = scheme.outlineVariant,
accent = scheme.primary,
onAccent = scheme.onPrimary,
secondaryContainer = scheme.secondaryContainer,
onSecondaryContainer = scheme.onSecondaryContainer,
secondaryContainerPressed = scheme.secondaryContainer.copy(
alpha = if (isLight) 0.85f else 0.92f
),
secondaryHighlight = if (isLight) {
Color.White.copy(alpha = 0.5f)
} else {
Color.White.copy(alpha = 0.08f)
},
shadow = scheme.onSurface.copy(alpha = 0.12f),
highlight = if (isLight) {
Color.White.copy(alpha = 0.9f)
} else {
Color.White.copy(alpha = 0.06f)
},
)
} }
} }
@Composable private fun Modifier.applyShadow(
fun outlinedGradient(colors: ButtonColors, pressed: Boolean): Brush { shadow: BaseUITheme.Shadow,
val top = if (pressed) colors.containerPressed else colors.containerLighter shape: Shape,
val bottom = if (pressed) colors.containerPressed else colors.containerDarker
return remember(colors, pressed) { Brush.verticalGradient(listOf(top, bottom)) }
}
@Composable
fun primaryGradient(colors: ButtonColors, pressed: Boolean): Brush {
val alpha = if (pressed) 0.85f else 1f
return remember(colors, pressed) {
Brush.verticalGradient(
listOf(colors.accent.copy(alpha = alpha), colors.accent)
)
}
}
@Composable
private fun secondaryGradient(colors: ButtonColors, pressed: Boolean): Brush {
val top = if (pressed) colors.secondaryContainerPressed else colors.secondaryContainer
val bottom = if (pressed) {
colors.secondaryContainerPressed
} else {
colors.secondaryContainer.copy(
alpha = if (top.luminance() > 0.5f) 0.92f else 1f
)
}
return remember(colors, pressed) { Brush.verticalGradient(listOf(top, bottom)) }
}
@Composable
private fun badgeGradient(colors: ButtonColors): Brush = remember(colors) {
Brush.verticalGradient(listOf(colors.containerLighter, colors.containerDarker))
}
@Composable
internal fun buttonShadow(
shape: androidx.compose.ui.graphics.Shape,
pressed: Boolean,
primary: Boolean,
colors: ButtonColors,
hovered: Boolean = false,
): Modifier { ): Modifier {
val elevation = when { return if (shadow.elevation > 0.dp) {
pressed -> 1.dp this.shadow(
hovered -> 9.dp elevation = shadow.elevation,
else -> 6.dp
}
val ambient = if (primary) {
colors.accent.copy(
alpha = when {
pressed -> 0.2f
hovered -> 0.45f
else -> 0.35f
}
)
} else {
colors.shadow.copy(
alpha = when {
pressed -> 0.08f
hovered -> 0.22f
else -> 0.15f
}
)
}
val spot = if (primary) {
colors.accent.copy(
alpha = when {
pressed -> 0.25f
hovered -> 0.5f
else -> 0.4f
}
)
} else {
colors.shadow.copy(
alpha = when {
pressed -> 0.1f
hovered -> 0.26f
else -> 0.18f
}
)
}
return Modifier.shadow(
elevation = elevation,
shape = shape, shape = shape,
ambientColor = ambient, ambientColor = shadow.ambientColor,
spotColor = spot, spotColor = shadow.spotColor,
) )
} else {
this
}
} }
@Composable @Composable
@ -235,18 +116,16 @@ fun OutlineButton(
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
enabled: Boolean = true, enabled: Boolean = true,
shape: androidx.compose.ui.graphics.Shape = ButtonShape,
contentPadding: PaddingValues = PaddingValues(horizontal = 20.dp, vertical = 10.dp),
hoverOnly: Boolean = false, hoverOnly: Boolean = false,
colors: ButtonColors? = null, theme: BaseUITheme.ButtonStyle? = null,
content: @Composable RowScope.() -> Unit, content: @Composable RowScope.() -> Unit,
) { ) {
val colors = colors ?: rememberButtonColors() val baseTheme = LocalBaseUITheme.current
val style = theme ?: baseTheme.buttons.outline
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 gradient = outlinedGradient(colors, isPressed) val state = resolveButtonState(style, isPressed, isHovered)
val border = colors.border.copy(alpha = if (isPressed) 0.7f else 1f)
val lift = if (isHovered && !isPressed) (-1).dp else 0.dp val lift = if (isHovered && !isPressed) (-1).dp else 0.dp
Box( Box(
@ -255,18 +134,12 @@ fun OutlineButton(
.hoverable(interactionSource = interactionSource, enabled = enabled) .hoverable(interactionSource = interactionSource, enabled = enabled)
.graphicsLayer { translationY = lift.toPx() } .graphicsLayer { translationY = lift.toPx() }
.then( .then(
if (hoverOnly && !isHovered) Modifier else buttonShadow( if (hoverOnly && !isHovered) Modifier else Modifier.applyShadow(state.shadow, state.shape)
shape,
isPressed,
primary = false,
colors,
hovered = isHovered
) )
) .clip(state.shape)
.clip(shape)
.then( .then(
if (hoverOnly && !isHovered) Modifier else Modifier.background(gradient, shape) if (hoverOnly && !isHovered) Modifier else Modifier.background(state.colors.background, state.shape)
.border(BorderStroke(0.5.dp, border), shape) .border(BorderStroke(state.border.width, state.border.color), state.shape)
) )
.clickable( .clickable(
enabled = enabled, enabled = enabled,
@ -276,7 +149,7 @@ fun OutlineButton(
) )
.then(if (hoverOnly && !isHovered) Modifier else Modifier.drawWithCache { .then(if (hoverOnly && !isHovered) Modifier else Modifier.drawWithCache {
val highlightBrush = Brush.verticalGradient( val highlightBrush = Brush.verticalGradient(
colors = listOf(colors.highlight, Color.Transparent), colors = listOf(state.colors.highlight, Color.Transparent),
startY = 0f, startY = 0f,
endY = size.height * 0.5f, endY = size.height * 0.5f,
) )
@ -289,13 +162,17 @@ fun OutlineButton(
) )
} }
}) })
.padding(contentPadding), .padding(state.padding),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
content = content, content = {
CompositionLocalProvider(LocalContentColor provides state.colors.foreground) {
content()
}
},
) )
} }
} }
@ -305,16 +182,15 @@ fun PrimaryButton(
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
enabled: Boolean = true, enabled: Boolean = true,
shape: androidx.compose.ui.graphics.Shape = ButtonShape, theme: BaseUITheme.ButtonStyle? = null,
contentPadding: PaddingValues = PaddingValues(horizontal = 20.dp, vertical = 10.dp),
colors: ButtonColors? = null,
content: @Composable RowScope.() -> Unit, content: @Composable RowScope.() -> Unit,
) { ) {
val colors = colors ?: rememberButtonColors() val baseTheme = LocalBaseUITheme.current
val style = theme ?: baseTheme.buttons.primary
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 gradient = primaryGradient(colors, isPressed) val state = resolveButtonState(style, isPressed, isHovered)
val lift = if (isHovered && !isPressed) (-1).dp else 0.dp val lift = if (isHovered && !isPressed) (-1).dp else 0.dp
Box( Box(
@ -322,10 +198,10 @@ fun PrimaryButton(
.defaultMinSize(minHeight = ButtonMinHeight) .defaultMinSize(minHeight = ButtonMinHeight)
.hoverable(interactionSource = interactionSource, enabled = enabled) .hoverable(interactionSource = interactionSource, enabled = enabled)
.graphicsLayer { translationY = lift.toPx() } .graphicsLayer { translationY = lift.toPx() }
.then(buttonShadow(shape, isPressed, primary = true, colors, hovered = isHovered)) .applyShadow(state.shadow, state.shape)
.clip(shape) .clip(state.shape)
.background(gradient, shape) .background(state.colors.background, state.shape)
.border(BorderStroke(0.5.dp, colors.accent), shape) .border(BorderStroke(state.border.width, state.border.color), state.shape)
.clickable( .clickable(
enabled = enabled, enabled = enabled,
interactionSource = interactionSource, interactionSource = interactionSource,
@ -334,7 +210,7 @@ fun PrimaryButton(
) )
.drawWithCache { .drawWithCache {
val highlightBrush = Brush.verticalGradient( val highlightBrush = Brush.verticalGradient(
colors = listOf(Color.White.copy(alpha = 0.25f), Color.Transparent), colors = listOf(state.colors.highlight, Color.Transparent),
startY = 0f, startY = 0f,
endY = size.height * 0.5f, endY = size.height * 0.5f,
) )
@ -347,10 +223,10 @@ fun PrimaryButton(
) )
} }
} }
.padding(contentPadding), .padding(state.padding),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
CompositionLocalProvider(LocalContentColor provides colors.onAccent) { CompositionLocalProvider(LocalContentColor provides state.colors.foreground) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
@ -365,16 +241,15 @@ fun SecondaryButton(
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
enabled: Boolean = true, enabled: Boolean = true,
shape: androidx.compose.ui.graphics.Shape = ButtonShape, theme: BaseUITheme.ButtonStyle? = null,
contentPadding: PaddingValues = PaddingValues(horizontal = 20.dp, vertical = 10.dp),
colors: ButtonColors? = null,
content: @Composable RowScope.() -> Unit, content: @Composable RowScope.() -> Unit,
) { ) {
val colors = colors ?: rememberButtonColors() val baseTheme = LocalBaseUITheme.current
val style = theme ?: baseTheme.buttons.secondary
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 gradient = secondaryGradient(colors, isPressed) val state = resolveButtonState(style, isPressed, isHovered)
val lift = if (isHovered && !isPressed) (-1).dp else 0.dp val lift = if (isHovered && !isPressed) (-1).dp else 0.dp
Box( Box(
@ -382,13 +257,10 @@ fun SecondaryButton(
.defaultMinSize(minHeight = ButtonMinHeight) .defaultMinSize(minHeight = ButtonMinHeight)
.hoverable(interactionSource = interactionSource, enabled = enabled) .hoverable(interactionSource = interactionSource, enabled = enabled)
.graphicsLayer { translationY = lift.toPx() } .graphicsLayer { translationY = lift.toPx() }
.then(buttonShadow(shape, isPressed, primary = false, colors, hovered = isHovered)) .applyShadow(state.shadow, state.shape)
.clip(shape) .clip(state.shape)
.background(gradient, shape) .background(state.colors.background, state.shape)
.border( .border(BorderStroke(state.border.width, state.border.color), state.shape)
BorderStroke(0.5.dp, colors.secondaryContainer.copy(alpha = 0.5f)),
shape,
)
.clickable( .clickable(
enabled = enabled, enabled = enabled,
interactionSource = interactionSource, interactionSource = interactionSource,
@ -397,7 +269,7 @@ fun SecondaryButton(
) )
.drawWithCache { .drawWithCache {
val highlightBrush = Brush.verticalGradient( val highlightBrush = Brush.verticalGradient(
colors = listOf(colors.secondaryHighlight, Color.Transparent), colors = listOf(state.colors.highlight, Color.Transparent),
startY = 0f, startY = 0f,
endY = size.height * 0.5f, endY = size.height * 0.5f,
) )
@ -410,10 +282,10 @@ fun SecondaryButton(
) )
} }
} }
.padding(contentPadding), .padding(state.padding),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
CompositionLocalProvider(LocalContentColor provides colors.onSecondaryContainer) { CompositionLocalProvider(LocalContentColor provides state.colors.foreground) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
@ -428,40 +300,60 @@ fun IconButton(
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
enabled: Boolean = true, enabled: Boolean = true,
shape: androidx.compose.ui.graphics.Shape = ButtonShape, theme: BaseUITheme.ButtonStyle? = null,
colors: ButtonColors? = null,
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
val baseTheme = LocalBaseUITheme.current
val style = theme ?: baseTheme.iconButtons.outline
OutlineButton( OutlineButton(
onClick = onClick, onClick = onClick,
modifier = modifier, modifier = modifier,
enabled = enabled, enabled = enabled,
shape = shape, theme = style,
contentPadding = PaddingValues(8.dp),
colors = colors,
) { ) {
content() content()
} }
} }
@Composable @Composable
fun GhostIconButton( fun GhostIconButton(
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
enabled: Boolean = true, enabled: Boolean = true,
shape: androidx.compose.ui.graphics.Shape = ButtonShape, theme: BaseUITheme.ButtonStyle? = null,
colors: ButtonColors? = null,
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
val baseTheme = LocalBaseUITheme.current
val outlineStyle = baseTheme.iconButtons.outline
val ghostStyle = theme ?: BaseUITheme.ButtonStyle(
colors = BaseUITheme.InteractionState(
hovered = outlineStyle.colors.hovered,
pressed = outlineStyle.colors.pressed,
focused = BaseUITheme.ButtonColors(
background = Brush.verticalGradient(listOf(Color.Transparent, Color.Transparent)),
foreground = outlineStyle.colors.focused.foreground,
highlight = outlineStyle.colors.focused.highlight,
),
),
shape = outlineStyle.shape,
shadow = BaseUITheme.InteractionState(
hovered = outlineStyle.shadow.hovered,
pressed = outlineStyle.shadow.pressed,
focused = BaseUITheme.Shadow(0.dp, false, Color.Transparent, Color.Transparent),
),
border = BaseUITheme.InteractionState(
hovered = outlineStyle.border.hovered,
pressed = outlineStyle.border.pressed,
focused = BaseUITheme.Border(Color.Transparent, 0.dp),
),
padding = outlineStyle.padding,
)
OutlineButton( OutlineButton(
onClick = onClick, onClick = onClick,
modifier = modifier, modifier = modifier,
enabled = enabled, enabled = enabled,
shape = shape,
contentPadding = PaddingValues(8.dp),
hoverOnly = true, hoverOnly = true,
colors = colors, theme = ghostStyle,
) { ) {
content() content()
} }
@ -472,17 +364,16 @@ fun PrimaryIconButton(
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
enabled: Boolean = true, enabled: Boolean = true,
shape: androidx.compose.ui.graphics.Shape = ButtonShape, theme: BaseUITheme.ButtonStyle? = null,
colors: ButtonColors? = null,
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
val baseTheme = LocalBaseUITheme.current
val style = theme ?: baseTheme.iconButtons.primary
PrimaryButton( PrimaryButton(
onClick = onClick, onClick = onClick,
modifier = modifier, modifier = modifier,
enabled = enabled, enabled = enabled,
shape = shape, theme = style,
contentPadding = PaddingValues(8.dp),
colors = colors,
) { ) {
content() content()
} }
@ -493,17 +384,16 @@ fun SecondaryIconButton(
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
enabled: Boolean = true, enabled: Boolean = true,
shape: androidx.compose.ui.graphics.Shape = ButtonShape, theme: BaseUITheme.ButtonStyle? = null,
colors: ButtonColors? = null,
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
val baseTheme = LocalBaseUITheme.current
val style = theme ?: baseTheme.iconButtons.secondary
SecondaryButton( SecondaryButton(
onClick = onClick, onClick = onClick,
modifier = modifier, modifier = modifier,
enabled = enabled, enabled = enabled,
shape = shape, theme = style,
contentPadding = PaddingValues(8.dp),
colors = colors,
) { ) {
content() content()
} }
@ -513,15 +403,17 @@ fun SecondaryIconButton(
fun ButtonBadge( fun ButtonBadge(
count: Int, count: Int,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
colors: ButtonColors? = null, theme: BaseUITheme.ButtonStyle? = null,
) { ) {
val colors = colors ?: rememberButtonColors() val baseTheme = LocalBaseUITheme.current
val style = theme ?: baseTheme.buttons.outline
val state = resolveButtonState(style, isPressed = false, isHovered = false)
Box( Box(
modifier = modifier modifier = modifier
.heightIn(min = 22.dp) .heightIn(min = 22.dp)
.defaultMinSize(minWidth = 22.dp) .defaultMinSize(minWidth = 22.dp)
.background(badgeGradient(colors), BadgeShape) .background(state.colors.background, BadgeShape)
.border(1.dp, colors.border, BadgeShape) .border(1.dp, state.border.color, BadgeShape)
.padding(horizontal = 7.dp), .padding(horizontal = 7.dp),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
@ -531,7 +423,7 @@ fun ButtonBadge(
fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold, fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold,
maxLines = 1, maxLines = 1,
softWrap = false, softWrap = false,
color = colors.onContainer.copy(alpha = 0.7f), color = state.colors.foreground.copy(alpha = 0.7f),
) )
} }
} }
@ -541,18 +433,19 @@ fun GroupButton(
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
enabled: Boolean = true, enabled: Boolean = true,
contentPadding: PaddingValues = PaddingValues(horizontal = 14.dp, vertical = 11.dp), theme: BaseUITheme.ButtonStyle? = null,
colors: ButtonColors? = null,
content: @Composable RowScope.() -> Unit, content: @Composable RowScope.() -> Unit,
) { ) {
val colors = colors ?: rememberButtonColors() val baseTheme = LocalBaseUITheme.current
val style = theme ?: baseTheme.buttons.outline
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 = resolveButtonState(style, isPressed, isHovered)
val overlay = when { val overlay = when {
isPressed -> colors.containerPressed isPressed -> state.colors.background
isHovered -> colors.containerPressed.copy(alpha = 0.5f) isHovered -> state.colors.background
else -> Color.Transparent else -> Brush.verticalGradient(listOf(Color.Transparent, Color.Transparent))
} }
val lift = if (isHovered && !isPressed) (-1).dp else 0.dp val lift = if (isHovered && !isPressed) (-1).dp else 0.dp
@ -568,7 +461,7 @@ fun GroupButton(
indication = ripple(), indication = ripple(),
onClick = onClick, onClick = onClick,
) )
.padding(contentPadding), .padding(state.padding),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Row( Row(
@ -584,17 +477,19 @@ fun GroupIconButton(
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
enabled: Boolean = true, enabled: Boolean = true,
colors: ButtonColors? = null, theme: BaseUITheme.ButtonStyle? = null,
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
val colors = colors ?: rememberButtonColors() val baseTheme = LocalBaseUITheme.current
val style = theme ?: baseTheme.buttons.outline
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 = resolveButtonState(style, isPressed, isHovered)
val overlay = when { val overlay = when {
isPressed -> colors.containerPressed isPressed -> state.colors.background
isHovered -> colors.containerPressed.copy(alpha = 0.5f) isHovered -> state.colors.background
else -> Color.Transparent else -> Brush.verticalGradient(listOf(Color.Transparent, Color.Transparent))
} }
val lift = if (isHovered && !isPressed) (-1).dp else 0.dp val lift = if (isHovered && !isPressed) (-1).dp else 0.dp
@ -619,28 +514,23 @@ fun GroupIconButton(
@Composable @Composable
fun ButtonGroup( fun ButtonGroup(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
colors: ButtonColors? = null, theme: BaseUITheme.ButtonStyle? = null,
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
val colors = colors ?: rememberButtonColors() val baseTheme = LocalBaseUITheme.current
val style = theme ?: baseTheme.buttons.outline
val state = resolveButtonState(style, isPressed = false, isHovered = false)
val groupShape = state.shape
Surface( Surface(
modifier = modifier modifier = modifier
.defaultMinSize(minHeight = ButtonMinHeight) .defaultMinSize(minHeight = ButtonMinHeight)
.then( .applyShadow(state.shadow, groupShape),
buttonShadow( shape = groupShape,
GroupShape,
pressed = false,
primary = false,
colors,
hovered = false
)
),
shape = GroupShape,
color = Color.Transparent, color = Color.Transparent,
border = BorderStroke(0.5.dp, colors.border), border = BorderStroke(state.border.width, state.border.color),
) { ) {
Box( Box(
modifier = Modifier.background(outlinedGradient(colors, false), GroupShape), modifier = Modifier.background(state.colors.background, groupShape),
) { ) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
content() content()
@ -651,14 +541,16 @@ fun ButtonGroup(
@Composable @Composable
fun ButtonGroupDivider( fun ButtonGroupDivider(
colors: ButtonColors? = null, theme: BaseUITheme.ButtonStyle? = null,
) { ) {
val colors = colors ?: rememberButtonColors() val baseTheme = LocalBaseUITheme.current
val style = theme ?: baseTheme.buttons.outline
val state = resolveButtonState(style, isPressed = false, isHovered = false)
Box( Box(
modifier = Modifier modifier = Modifier
.width(1.dp) .width(1.dp)
.heightIn(min = 20.dp) .heightIn(min = 20.dp)
.background(colors.border), .background(state.border.color),
) )
} }
@ -666,6 +558,8 @@ fun ButtonGroupDivider(
@Composable @Composable
private fun ButtonsRow1Preview() { private fun ButtonsRow1Preview() {
MaterialTheme { MaterialTheme {
val theme = rememberBaseUITheme()
CompositionLocalProvider(LocalBaseUITheme provides theme) {
Surface( Surface(
color = MaterialTheme.colorScheme.background, color = MaterialTheme.colorScheme.background,
modifier = Modifier.padding(24.dp), modifier = Modifier.padding(24.dp),
@ -711,11 +605,14 @@ private fun ButtonsRow1Preview() {
} }
} }
} }
}
@Preview(showBackground = true) @Preview(showBackground = true)
@Composable @Composable
private fun ButtonsRow2Preview() { private fun ButtonsRow2Preview() {
MaterialTheme { MaterialTheme {
val theme = rememberBaseUITheme()
CompositionLocalProvider(LocalBaseUITheme provides theme) {
Surface( Surface(
color = MaterialTheme.colorScheme.background, color = MaterialTheme.colorScheme.background,
modifier = Modifier.padding(24.dp), modifier = Modifier.padding(24.dp),
@ -772,11 +669,14 @@ private fun ButtonsRow2Preview() {
} }
} }
} }
}
@Preview(showBackground = true) @Preview(showBackground = true)
@Composable @Composable
private fun ButtonsRow3Preview() { private fun ButtonsRow3Preview() {
MaterialTheme { MaterialTheme {
val theme = rememberBaseUITheme()
CompositionLocalProvider(LocalBaseUITheme provides theme) {
Surface( Surface(
color = MaterialTheme.colorScheme.background, color = MaterialTheme.colorScheme.background,
modifier = Modifier.padding(24.dp), modifier = Modifier.padding(24.dp),
@ -816,11 +716,14 @@ private fun ButtonsRow3Preview() {
} }
} }
} }
}
@Preview(showBackground = true) @Preview(showBackground = true)
@Composable @Composable
private fun ButtonsRow4Preview() { private fun ButtonsRow4Preview() {
MaterialTheme { MaterialTheme {
val theme = rememberBaseUITheme()
CompositionLocalProvider(LocalBaseUITheme provides theme) {
Surface( Surface(
color = MaterialTheme.colorScheme.background, color = MaterialTheme.colorScheme.background,
modifier = Modifier.padding(24.dp), modifier = Modifier.padding(24.dp),
@ -858,11 +761,14 @@ private fun ButtonsRow4Preview() {
} }
} }
} }
}
@Preview(showBackground = true) @Preview(showBackground = true)
@Composable @Composable
private fun ButtonStylesPreview() { private fun ButtonStylesPreview() {
MaterialTheme { MaterialTheme {
val theme = rememberBaseUITheme()
CompositionLocalProvider(LocalBaseUITheme provides theme) {
Surface( Surface(
color = MaterialTheme.colorScheme.background, color = MaterialTheme.colorScheme.background,
modifier = Modifier.padding(24.dp), modifier = Modifier.padding(24.dp),
@ -889,6 +795,7 @@ private fun ButtonStylesPreview() {
} }
} }
} }
}
enum class VariableIconButtonVariant { enum class VariableIconButtonVariant {
Outline, Outline,
@ -903,8 +810,7 @@ fun VariableIconButton(
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
enabled: Boolean = true, enabled: Boolean = true,
shape: androidx.compose.ui.graphics.Shape = ButtonShape, theme: BaseUITheme.ButtonStyle? = null,
colors: ButtonColors? = null,
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
when (variant) { when (variant) {
@ -912,8 +818,7 @@ fun VariableIconButton(
onClick = onClick, onClick = onClick,
modifier = modifier, modifier = modifier,
enabled = enabled, enabled = enabled,
shape = shape, theme = theme,
colors = colors,
content = content, content = content,
) )
@ -921,8 +826,7 @@ fun VariableIconButton(
onClick = onClick, onClick = onClick,
modifier = modifier, modifier = modifier,
enabled = enabled, enabled = enabled,
shape = shape, theme = theme,
colors = colors,
content = content, content = content,
) )
@ -930,8 +834,7 @@ fun VariableIconButton(
onClick = onClick, onClick = onClick,
modifier = modifier, modifier = modifier,
enabled = enabled, enabled = enabled,
shape = shape, theme = theme,
colors = colors,
content = content, content = content,
) )
@ -939,8 +842,7 @@ fun VariableIconButton(
onClick = onClick, onClick = onClick,
modifier = modifier, modifier = modifier,
enabled = enabled, enabled = enabled,
shape = shape, theme = theme,
colors = colors,
content = content, content = content,
) )
} }

View File

@ -0,0 +1,81 @@
/*
* 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,6 +39,7 @@ 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
@ -50,6 +51,7 @@ 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
@ -65,9 +67,28 @@ 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,
@ -75,69 +96,25 @@ 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 colors = rememberButtonColors() val baseTheme = LocalBaseUITheme.current.checkBox
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 backgroundBrush = if (isFilled) { val style = if (isFilled) checkBoxTheme.selected else checkBoxTheme.unselected
primaryGradient(colors, isPressed) val resolved = resolveCheckBoxState(style, isPressed, isHovered)
} else { val lift = if (isHovered && !isPressed && onClick != null) (-1).dp else 0.dp
outlinedGradient(colors, false)
}
val borderColor = when {
!enabled -> colors.onContainer.copy(alpha = 0.38f)
isFilled -> colors.accent
else -> colors.border
}
val borderWidth = 0.5.dp
val shadowElevation = when { val borderColor = when {
isPressed -> 1.dp !enabled -> resolved.colors.foreground.copy(alpha = 0.38f)
isFilled && isHovered -> 8.dp else -> resolved.border.color
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(
@ -155,28 +132,29 @@ fun CheckBox(
modifier = modifier modifier = modifier
.size(CheckBoxSize) .size(CheckBoxSize)
.graphicsLayer { translationY = lift.toPx() } .graphicsLayer { translationY = lift.toPx() }
.shadow( .then(
elevation = shadowElevation, if (resolved.shadow.elevation > 0.dp) {
shape = CheckBoxShape, Modifier.shadow(
ambientColor = shadowAmbient, elevation = resolved.shadow.elevation,
spotColor = shadowSpot, shape = resolved.shape,
ambientColor = resolved.shadow.ambientColor,
spotColor = resolved.shadow.spotColor,
) )
.clip(CheckBoxShape) } else Modifier
.background(backgroundBrush, CheckBoxShape) )
.border(BorderStroke(borderWidth, borderColor), CheckBoxShape) .clip(resolved.shape)
.background(resolved.colors.background, resolved.shape)
.border(BorderStroke(0.5.dp, borderColor), resolved.shape)
.drawWithCache { .drawWithCache {
val highlight = Brush.verticalGradient( val highlightBrush = Brush.verticalGradient(
colors = listOf( colors = listOf(resolved.colors.highlight, Color.Transparent),
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 = highlight, brush = highlightBrush,
topLeft = Offset.Zero, topLeft = Offset.Zero,
size = size, size = size,
) )
@ -215,7 +193,7 @@ fun CheckBox(
} }
drawPath( drawPath(
path = path, path = path,
color = colors.onAccent, color = checkBoxTheme.checkmarkColor,
style = Stroke( style = Stroke(
width = stroke, width = stroke,
cap = StrokeCap.Round, cap = StrokeCap.Round,
@ -233,8 +211,8 @@ fun CheckBox(
.background( .background(
brush = Brush.horizontalGradient( brush = Brush.horizontalGradient(
colors = listOf( colors = listOf(
colors.onAccent, checkBoxTheme.checkmarkColor,
colors.onAccent.copy(alpha = 0.92f), checkBoxTheme.checkmarkColor.copy(alpha = 0.92f),
) )
), ),
), ),
@ -247,6 +225,8 @@ fun CheckBox(
@Composable @Composable
fun CheckBoxPreview() { fun CheckBoxPreview() {
MaterialTheme { MaterialTheme {
val theme = rememberBaseUITheme()
CompositionLocalProvider(LocalBaseUITheme provides theme) {
Surface( Surface(
color = MaterialTheme.colorScheme.background, color = MaterialTheme.colorScheme.background,
modifier = Modifier.padding(24.dp), modifier = Modifier.padding(24.dp),
@ -284,3 +264,4 @@ fun CheckBoxPreview() {
} }
} }
} }
}

View File

@ -27,12 +27,10 @@ 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
@ -45,57 +43,78 @@ 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.graphicsLayer import androidx.compose.ui.graphics.Shape
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,
contentPadding: PaddingValues = PaddingValues(horizontal = 14.dp, vertical = 4.dp), theme: BaseUITheme.ChipTabTheme? = null,
content: @Composable RowScope.() -> Unit, content: @Composable RowScope.() -> Unit,
) { ) {
val colors = rememberButtonColors() val baseTheme = LocalBaseUITheme.current.chipTab
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 gradient = if (selected) { val state = resolveChipState(style, isPressed, isHovered)
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)
.then( .applyChipShadow(state.shadow, state.shape)
buttonShadow( .clip(state.shape)
shape = ChipTabShape, .background(state.colors.background, state.shape)
pressed = isPressed, .border(BorderStroke(state.border.width, state.border.color), state.shape)
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,
@ -103,13 +122,8 @@ 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(highlight, Color.Transparent), colors = listOf(state.colors.highlight, Color.Transparent),
startY = 0f, startY = 0f,
endY = size.height * 0.5f, endY = size.height * 0.5f,
) )
@ -122,11 +136,11 @@ fun ChipTab(
) )
} }
} }
.padding(contentPadding), .padding(state.padding),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
CompositionLocalProvider( CompositionLocalProvider(
LocalContentColor provides contentColor, LocalContentColor provides state.colors.foreground,
LocalTextStyle provides LocalTextStyle.current.copy( LocalTextStyle provides LocalTextStyle.current.copy(
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
fontSize = 14.sp, fontSize = 14.sp,
@ -149,14 +163,14 @@ fun ChipTab(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
enabled: Boolean = true, enabled: Boolean = true,
leadingIcon: @Composable (() -> Unit)? = null, leadingIcon: @Composable (() -> Unit)? = null,
contentPadding: PaddingValues = PaddingValues(horizontal = 16.dp, vertical = 8.dp), theme: BaseUITheme.ChipTabTheme? = null,
) { ) {
ChipTab( ChipTab(
selected = selected, selected = selected,
onClick = onClick, onClick = onClick,
modifier = modifier, modifier = modifier,
enabled = enabled, enabled = enabled,
contentPadding = contentPadding, theme = theme,
) { ) {
if (leadingIcon != null) { if (leadingIcon != null) {
leadingIcon() leadingIcon()

View File

@ -43,6 +43,7 @@ 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
@ -79,8 +80,10 @@ 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 colors = rememberButtonColors() val baseTheme = LocalBaseUITheme.current.slider
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
@ -89,13 +92,30 @@ 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 trackActiveColor = if (enabled) colors.accent else colors.onContainer.copy(alpha = 0.38f) val stateColor = when {
val trackInactiveColor = if (enabled) { isPressed -> sliderTheme.trackActiveColor.pressed
colors.border.copy(alpha = 0.6f) isHovered -> sliderTheme.trackActiveColor.hovered
} else { else -> sliderTheme.trackActiveColor.focused
colors.onContainer.copy(alpha = 0.2f)
} }
val thumbColor = if (enabled) colors.accent else colors.onContainer.copy(alpha = 0.38f) val inactiveColor = when {
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 {
@ -107,12 +127,6 @@ 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()
@ -241,9 +255,9 @@ fun Slider(
val x = trackStart + stepSpacing * i val x = trackStart + stepSpacing * i
drawCircle( drawCircle(
color = if (x <= activeEnd) { color = if (x <= activeEnd) {
colors.onAccent.copy(alpha = 0.5f) sliderTheme.stepActiveColor
} else { } else {
colors.onContainer.copy(alpha = 0.25f) sliderTheme.stepInactiveColor
}, },
radius = 2.dp.toPx(), radius = 2.dp.toPx(),
center = Offset(x, trackY), center = Offset(x, trackY),
@ -267,17 +281,17 @@ fun Slider(
scaleY = thumbScale scaleY = thumbScale
} }
.shadow( .shadow(
elevation = thumbElevation, elevation = thumbShadowState.elevation,
shape = CircleShape, shape = CircleShape,
ambientColor = colors.accent.copy(alpha = 0.35f), ambientColor = thumbShadowState.ambientColor,
spotColor = colors.accent.copy(alpha = 0.5f), spotColor = thumbShadowState.spotColor,
) )
.background( .background(
brush = Brush.radialGradient( brush = Brush.radialGradient(
colors = listOf( colors = listOf(
Color.White.copy(alpha = 0.6f), Color.White.copy(alpha = 0.6f),
thumbColor.copy(alpha = 0.95f), resolvedThumbColor.copy(alpha = 0.95f),
thumbColor, resolvedThumbColor,
), ),
center = Offset(0f, -0.55f), center = Offset(0f, -0.55f),
radius = 1.1f, radius = 1.1f,

View File

@ -34,7 +34,6 @@ 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
@ -52,6 +51,7 @@ 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,32 +61,28 @@ 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
@Composable private data class ResolvedTextFieldState(
internal fun textFieldShadow( val background: Brush,
shape: androidx.compose.ui.graphics.Shape, val highlight: Color,
focused: Boolean, val shape: Shape,
colors: ButtonColors, val border: BaseUITheme.Border,
): Modifier { val shadow: BaseUITheme.Shadow,
val elevation = if (focused) 9.dp else 6.dp val foreground: Color,
val ambient = if (focused) {
colors.accent.copy(alpha = 0.2f)
} 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
private fun resolveTextFieldState(
theme: BaseUITheme.TextFieldTheme,
isFocused: Boolean,
isHovered: Boolean,
): ResolvedTextFieldState {
return when {
isFocused -> ResolvedTextFieldState(theme.background.focused, theme.highlight.focused, theme.shape.focused, theme.border.focused, theme.shadow.focused, theme.foreground.focused)
isHovered -> ResolvedTextFieldState(theme.background.hovered, theme.highlight.hovered, theme.shape.hovered, theme.border.hovered, theme.shadow.hovered, theme.foreground.hovered)
else -> ResolvedTextFieldState(theme.background.pressed, theme.highlight.pressed, theme.shape.pressed, theme.border.pressed, theme.shadow.pressed, theme.foreground.pressed)
}
} }
@Composable @Composable
@ -108,25 +104,29 @@ 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 = MaterialTheme.colorScheme.primary, cursorBrush: Color? = null,
theme: BaseUITheme.TextFieldTheme? = null,
) { ) {
val colors = rememberButtonColors() val baseTheme = LocalBaseUITheme.current.textField
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 gradient = outlinedGradient(colors, false) val state = resolveTextFieldState(textFieldTheme, isFocused, isHovered)
val border = when {
isError -> MaterialTheme.colorScheme.error val resolvedBorder = if (isError) {
isFocused -> MaterialTheme.colorScheme.primary BaseUITheme.Border(MaterialTheme.colorScheme.error, state.border.width)
isHovered -> colors.border.copy(alpha = 0.85f) } else {
else -> colors.border state.border
} }
val contentColor = when { val contentColor = when {
!enabled -> colors.onContainer.copy(alpha = 0.38f) !enabled -> state.foreground.copy(alpha = 0.38f)
else -> colors.onContainer else -> state.foreground
} }
val cursor = cursorBrush?.let { SolidColor(it) } ?: textFieldTheme.cursor
Column(modifier = modifier) { Column(modifier = modifier) {
AnimatedVisibility( AnimatedVisibility(
visible = label != null, visible = label != null,
@ -139,13 +139,22 @@ fun TextField(
Box( Box(
modifier = Modifier modifier = Modifier
.defaultMinSize(minHeight = TextFieldMinHeight) .defaultMinSize(minHeight = TextFieldMinHeight)
.then(textFieldShadow(TextFieldShape, isFocused, colors)) .then(
.clip(TextFieldShape) if (state.shadow.elevation > 0.dp) {
.background(gradient, TextFieldShape) Modifier.shadow(
.border(BorderStroke(0.5.dp, border), TextFieldShape) elevation = state.shadow.elevation,
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(colors.highlight, Color.Transparent), colors = listOf(state.highlight, Color.Transparent),
startY = 0f, startY = 0f,
endY = size.height * 0.5f, endY = size.height * 0.5f,
) )
@ -158,7 +167,7 @@ fun TextField(
) )
} }
} }
.padding(horizontal = 14.dp, vertical = 10.dp), .padding(textFieldTheme.padding),
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@ -177,7 +186,7 @@ fun TextField(
enabled = enabled, enabled = enabled,
readOnly = readOnly, readOnly = readOnly,
textStyle = textStyle.copy(color = contentColor), textStyle = textStyle.copy(color = contentColor),
cursorBrush = SolidColor(cursorBrush), cursorBrush = cursor,
keyboardOptions = keyboardOptions, keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions, keyboardActions = keyboardActions,
singleLine = singleLine, singleLine = singleLine,
@ -210,6 +219,8 @@ fun TextField(
@Composable @Composable
fun TextFieldPreview() { fun TextFieldPreview() {
MaterialTheme { MaterialTheme {
val theme = rememberBaseUITheme()
CompositionLocalProvider(LocalBaseUITheme provides theme) {
androidx.compose.material3.Surface( androidx.compose.material3.Surface(
color = MaterialTheme.colorScheme.background, color = MaterialTheme.colorScheme.background,
modifier = Modifier.padding(24.dp), modifier = Modifier.padding(24.dp),
@ -243,3 +254,4 @@ fun TextFieldPreview() {
} }
} }
} }
}

View File

@ -0,0 +1,756 @@
/*
* 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,10 +21,8 @@ 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
@ -63,10 +61,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
@ -86,14 +84,13 @@ 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.IconButton import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
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
@ -348,25 +345,10 @@ fun TrackList(
} }
item(key = "track-card") { item(key = "track-card") {
val colors = rememberButtonColors() Card(
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
@ -532,17 +514,21 @@ private fun TrackListRow(
onArtistsOverflowClick: () -> Unit, onArtistsOverflowClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val rowColors = rememberButtonColors() val rowTheme = LocalBaseUITheme.current.listRowTile
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 -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) isSelected -> rowTheme.background.selected
isCurrentTrack && isCurrentTrackPlaying -> MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) isCurrentTrack && isCurrentTrackPlaying -> rowTheme.background.hovered
isCurrentTrack -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f) isCurrentTrack -> rowTheme.background.focused
else -> Color.Transparent else -> Brush.verticalGradient(listOf(Color.Transparent, Color.Transparent))
} }
val highlightColor = Color.White.copy(
alpha = if (isLight) 0.9f else 0.06f
)
Row( Row(
modifier = modifier modifier = modifier
@ -555,7 +541,7 @@ private fun TrackListRow(
.background(rowBackgroundColor) .background(rowBackgroundColor)
.drawWithCache { .drawWithCache {
val highlightBrush = Brush.verticalGradient( val highlightBrush = Brush.verticalGradient(
colors = listOf(rowColors.highlight, Color.Transparent), colors = listOf(highlightColor, 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,14 +49,16 @@ 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.IconsaxPauseCircle import dev.krtirtho.spotube.resources.iconsax.IconsaxPause
import dev.krtirtho.spotube.resources.iconsax.Play import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay
@Composable @Composable
fun PlayableCard( fun PlayableCard(
@ -108,7 +110,11 @@ fun PlayableCard(
SecondaryIconButton( SecondaryIconButton(
onClick = onAddToQueue, onClick = onAddToQueue,
modifier = Modifier.size(30.dp), modifier = Modifier.size(30.dp),
shape = RoundedCornerShape(6.dp) theme = LocalBaseUITheme.current.iconButtons.secondary.copy(
shape = BaseUITheme.InteractionState.fromSingleValue(
CircleShape
)
)
) { ) {
Icon( Icon(
imageVector = Iconsax.IconsaxAddSquare, imageVector = Iconsax.IconsaxAddSquare,
@ -121,12 +127,17 @@ fun PlayableCard(
PrimaryIconButton( PrimaryIconButton(
onClick = onPlay, onClick = onPlay,
modifier = Modifier.size(30.dp), modifier = Modifier.size(30.dp),
shape = RoundedCornerShape(6.dp) theme = LocalBaseUITheme.current.iconButtons.secondary.copy(
shape = BaseUITheme.InteractionState.fromSingleValue(
CircleShape
)
)
) { ) {
Icon( Icon(
imageVector = if (isPlaying) { imageVector = if (isPlaying) {
Iconsax.IconsaxPauseCircle Iconsax.IconsaxPause
} else Iconsax.Play, } else Iconsax.IconsaxPlay,
contentDescription = "Play", contentDescription = "Play",
) )
} }

View File

@ -83,10 +83,13 @@ 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.rememberButtonColors 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.DownloadStatus import dev.krtirtho.spotube.modules.downloads.DownloadStatus
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
@ -466,7 +469,10 @@ fun AppExpandedPlayer(
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
} }
IconButton(onClick = onQueue, shape = CircleShape) { IconButton(
onClick = onQueue,
theme = LocalBaseUITheme.current.iconButtons.outline.copyShape(CircleShape)
) {
Icon(Iconsax.IconsaxMusicFilter, contentDescription = "Queue") Icon(Iconsax.IconsaxMusicFilter, contentDescription = "Queue")
} }
} }
@ -526,20 +532,14 @@ fun AppExpandedPlayer(
} }
IconButton( IconButton(
onClick = ::onPlayPause, onClick = ::onPlayPause,
colors = rememberButtonColors().copy( theme = invertedButtonStyle().copyShape(CircleShape),
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,6 +28,7 @@ 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
@ -54,9 +55,12 @@ 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
@ -274,7 +278,12 @@ fun AppLargePlayer(
GhostIconButton(onClick = ::onSkipPrevious) { GhostIconButton(onClick = ::onSkipPrevious) {
Icon(Iconsax.IconsaxPrevious, contentDescription = "Previous") Icon(Iconsax.IconsaxPrevious, contentDescription = "Previous")
} }
GhostIconButton(onClick = ::onPlayPause, modifier = Modifier.size(44.dp)) { IconButton(
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,14 +21,8 @@ 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
@ -42,34 +36,26 @@ 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.buttonShadow import dev.krtirtho.spotube.core.ui.base.copyPadding
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
@ -215,66 +201,16 @@ fun SidebarItem(
SecondaryButton( SecondaryButton(
onClick = onClick, onClick = onClick,
modifier = buttonModifier, modifier = buttonModifier,
contentPadding = contentPadding, theme = LocalBaseUITheme.current.buttons.secondary.copyPadding(contentPadding),
content = itemContent, content = itemContent,
) )
} else { } else {
val colors = rememberButtonColors() OutlineButton(
val interactionSource = remember { MutableInteractionSource() }
val isHovered by interactionSource.collectIsHoveredAsState()
val isPressed by interactionSource.collectIsPressedAsState()
val gradient = outlinedGradient(colors, isPressed)
val border = colors.border.copy(alpha = if (isPressed) 0.7f else 1f)
val lift = if (isHovered && !isPressed) (-1).dp else 0.dp
val shape = RoundedCornerShape(14.dp)
Box(
modifier = buttonModifier
.hoverable(interactionSource = interactionSource)
.graphicsLayer { translationY = lift.toPx() }
.clip(shape)
.then(
if (isHovered || isPressed) {
buttonShadow(
shape,
isPressed,
primary = false,
colors,
hovered = isHovered
).background(gradient, shape)
.border(BorderStroke(0.5.dp, border), shape)
.drawWithCache {
val highlightBrush = Brush.verticalGradient(
colors = listOf(colors.highlight, Color.Transparent),
startY = 0f,
endY = size.height * 0.5f,
)
onDrawWithContent {
drawContent()
drawRect(
brush = highlightBrush,
topLeft = androidx.compose.ui.geometry.Offset.Zero,
size = size,
)
}
}
} else {
Modifier
}
)
.clickable(
interactionSource = interactionSource,
indication = ripple(),
onClick = onClick, onClick = onClick,
) modifier = buttonModifier,
.padding(contentPadding), theme = LocalBaseUITheme.current.buttons.outline.copyPadding(contentPadding),
contentAlignment = Alignment.Center, hoverOnly = true,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
content = itemContent, content = itemContent,
) )
} }
} }
}

View File

@ -27,6 +27,8 @@ 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
@ -69,7 +71,7 @@ fun PlayerHeartButton(
IconButton( IconButton(
onClick = ::onLike, onClick = ::onLike,
enabled = isSavedTrackState is SavedState.Success, enabled = isSavedTrackState is SavedState.Success,
shape = CircleShape, theme = LocalBaseUITheme.current.iconButtons.outline.copyShape(CircleShape),
) { ) {
Icon( Icon(
imageVector = if (isLiked) Iconsax.IconsaxHeart2 else Iconsax.IconsaxHeart, imageVector = if (isLiked) Iconsax.IconsaxHeart2 else Iconsax.IconsaxHeart,

View File

@ -37,21 +37,19 @@ 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
@ -63,7 +61,11 @@ 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
@ -186,9 +188,9 @@ fun PlayerQueueContent(
singleLine = true, singleLine = true,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
FilledTonalIconButton( SecondaryIconButton(
onClick = viewModel::clearQueue, onClick = viewModel::clearQueue,
shape = MaterialTheme.shapes.small, theme = LocalBaseUITheme.current.iconButtons.secondary.copyShape(MaterialTheme.shapes.small),
) { ) {
Icon(Iconsax.IconsaxTrash, contentDescription = "Clear Queue") Icon(Iconsax.IconsaxTrash, contentDescription = "Clear Queue")
} }
@ -333,7 +335,7 @@ private fun QueueItemRow(
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
Box { Box {
IconButton( GhostIconButton(
onClick = { showMenu = true }, onClick = { showMenu = true },
modifier = Modifier.size(36.dp), modifier = Modifier.size(36.dp),
) { ) {