Compare commits

..

4 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.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
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.graphics.vector.ImageVector
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@ -100,15 +103,18 @@ fun App(
}
SpotubeTheme(settings = userSettings) {
AppShell(navigator, navigationState) {
Column {
NavDisplay(
modifier = Modifier.fillMaxSize(),
onBack = navigator::pop,
entries = navigationState.toEntries(koinEntryProvider())
)
val baseUITheme = rememberBaseUITheme()
CompositionLocalProvider(LocalBaseUITheme provides baseUITheme) {
AppShell(navigator, navigationState) {
Column {
NavDisplay(
modifier = Modifier.fillMaxSize(),
onBack = navigator::pop,
entries = navigationState.toEntries(koinEntryProvider())
)
}
}
content()
}
content()
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

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.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.hoverable
@ -63,10 +61,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.focus.focusModifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
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.spotube.core.ui.base.ButtonGroup
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.CheckBoxState
import dev.krtirtho.spotube.core.ui.base.GhostIconButton
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.buttonShadow
import dev.krtirtho.spotube.core.ui.base.rememberButtonColors
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer
import dev.krtirtho.spotube.core.ui.misc.shimmerApply
@ -348,25 +345,10 @@ fun TrackList(
}
item(key = "track-card") {
val colors = rememberButtonColors()
val trackListShape = MaterialTheme.shapes.large
Box(
Card(
modifier = Modifier
.fillMaxWidth()
.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)
.padding(vertical = 8.dp),
) {
Column(
modifier = Modifier
@ -532,17 +514,21 @@ private fun TrackListRow(
onArtistsOverflowClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val rowColors = rememberButtonColors()
val rowTheme = LocalBaseUITheme.current.listRowTile
val isLight = MaterialTheme.colorScheme.surface.luminance() > 0.5f
val artworkInteractionSource = remember { MutableInteractionSource() }
val isArtworkHovered by artworkInteractionSource.collectIsHoveredAsState()
val rowInteractionSource = remember { MutableInteractionSource() }
val isRowHovered by rowInteractionSource.collectIsHoveredAsState()
val rowBackgroundColor = when {
isSelected -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
isCurrentTrack && isCurrentTrackPlaying -> MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)
isCurrentTrack -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f)
else -> Color.Transparent
isSelected -> rowTheme.background.selected
isCurrentTrack && isCurrentTrackPlaying -> rowTheme.background.hovered
isCurrentTrack -> rowTheme.background.focused
else -> Brush.verticalGradient(listOf(Color.Transparent, Color.Transparent))
}
val highlightColor = Color.White.copy(
alpha = if (isLight) 0.9f else 0.06f
)
Row(
modifier = modifier
@ -555,7 +541,7 @@ private fun TrackListRow(
.background(rowBackgroundColor)
.drawWithCache {
val highlightBrush = Brush.verticalGradient(
colors = listOf(rowColors.highlight, Color.Transparent),
colors = listOf(highlightColor, Color.Transparent),
startY = 0f,
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.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import dev.krtirtho.spotube.core.ui.base.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
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.unit.dp
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.SecondaryIconButton
import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer
import dev.krtirtho.spotube.core.ui.misc.shimmerApply
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
import dev.krtirtho.spotube.resources.iconsax.IconsaxPauseCircle
import dev.krtirtho.spotube.resources.iconsax.Play
import dev.krtirtho.spotube.resources.iconsax.IconsaxPause
import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay
@Composable
fun PlayableCard(
@ -108,7 +110,11 @@ fun PlayableCard(
SecondaryIconButton(
onClick = onAddToQueue,
modifier = Modifier.size(30.dp),
shape = RoundedCornerShape(6.dp)
theme = LocalBaseUITheme.current.iconButtons.secondary.copy(
shape = BaseUITheme.InteractionState.fromSingleValue(
CircleShape
)
)
) {
Icon(
imageVector = Iconsax.IconsaxAddSquare,
@ -121,12 +127,17 @@ fun PlayableCard(
PrimaryIconButton(
onClick = onPlay,
modifier = Modifier.size(30.dp),
shape = RoundedCornerShape(6.dp)
theme = LocalBaseUITheme.current.iconButtons.secondary.copy(
shape = BaseUITheme.InteractionState.fromSingleValue(
CircleShape
)
)
) {
Icon(
imageVector = if (isPlaying) {
Iconsax.IconsaxPauseCircle
} else Iconsax.Play,
Iconsax.IconsaxPause
} else Iconsax.IconsaxPlay,
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.navigation.NavigationCommands
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.IconButton
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
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.DownloadStatus
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
@ -466,7 +469,10 @@ fun AppExpandedPlayer(
textAlign = TextAlign.Center,
)
}
IconButton(onClick = onQueue, shape = CircleShape) {
IconButton(
onClick = onQueue,
theme = LocalBaseUITheme.current.iconButtons.outline.copyShape(CircleShape)
) {
Icon(Iconsax.IconsaxMusicFilter, contentDescription = "Queue")
}
}
@ -526,20 +532,14 @@ fun AppExpandedPlayer(
}
IconButton(
onClick = ::onPlayPause,
colors = rememberButtonColors().copy(
containerDarker = MaterialTheme.colorScheme.onSurface,
containerLighter = MaterialTheme.colorScheme.onSurface,
containerPressed = MaterialTheme.colorScheme.onSurfaceVariant,
),
theme = invertedButtonStyle().copyShape(CircleShape),
modifier = Modifier
.size(72.dp),
shape = CircleShape,
) {
Icon(
if (playerUiState.isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
contentDescription = if (playerUiState.isPlaying) "Pause" else "Play",
modifier = Modifier.size(30.dp),
tint = MaterialTheme.colorScheme.surface,
)
}
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.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
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.QueueEntry
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.VariableIconButton
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.DownloadsViewModel
import dev.krtirtho.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID
@ -274,7 +278,12 @@ fun AppLargePlayer(
GhostIconButton(onClick = ::onSkipPrevious) {
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(
if (playerUiState.isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
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.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import dev.krtirtho.spotube.core.navigation.NavigationState
import dev.krtirtho.spotube.core.navigation.Navigator
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.buttonShadow
import dev.krtirtho.spotube.core.ui.base.outlinedGradient
import dev.krtirtho.spotube.core.ui.base.rememberButtonColors
import dev.krtirtho.spotube.core.ui.base.copyPadding
import dev.krtirtho.spotube.modules.downloads.DownloadBadgeIndicator
import dev.krtirtho.spotube.modules.library.LibraryState
import dev.krtirtho.spotube.modules.library.LibraryTab
@ -215,66 +201,16 @@ fun SidebarItem(
SecondaryButton(
onClick = onClick,
modifier = buttonModifier,
contentPadding = contentPadding,
theme = LocalBaseUITheme.current.buttons.secondary.copyPadding(contentPadding),
content = itemContent,
)
} else {
val colors = rememberButtonColors()
val interactionSource = remember { MutableInteractionSource() }
val isHovered by interactionSource.collectIsHoveredAsState()
val isPressed by interactionSource.collectIsPressedAsState()
val gradient = outlinedGradient(colors, isPressed)
val border = colors.border.copy(alpha = if (isPressed) 0.7f else 1f)
val lift = if (isHovered && !isPressed) (-1).dp else 0.dp
val shape = RoundedCornerShape(14.dp)
Box(
modifier = buttonModifier
.hoverable(interactionSource = interactionSource)
.graphicsLayer { translationY = lift.toPx() }
.clip(shape)
.then(
if (isHovered || isPressed) {
buttonShadow(
shape,
isPressed,
primary = false,
colors,
hovered = isHovered
).background(gradient, shape)
.border(BorderStroke(0.5.dp, border), shape)
.drawWithCache {
val highlightBrush = Brush.verticalGradient(
colors = listOf(colors.highlight, Color.Transparent),
startY = 0f,
endY = size.height * 0.5f,
)
onDrawWithContent {
drawContent()
drawRect(
brush = highlightBrush,
topLeft = androidx.compose.ui.geometry.Offset.Zero,
size = size,
)
}
}
} else {
Modifier
}
)
.clickable(
interactionSource = interactionSource,
indication = ripple(),
onClick = onClick,
)
.padding(contentPadding),
contentAlignment = Alignment.Center,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
content = itemContent,
)
}
OutlineButton(
onClick = onClick,
modifier = buttonModifier,
theme = LocalBaseUITheme.current.buttons.outline.copyPadding(contentPadding),
hoverOnly = true,
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.QueueEntry
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.SavedTracksViewModel
import dev.krtirtho.spotube.modules.saved_tracks.rememberIsSavedTracks
@ -69,7 +71,7 @@ fun PlayerHeartButton(
IconButton(
onClick = ::onLike,
enabled = isSavedTrackState is SavedState.Success,
shape = CircleShape,
theme = LocalBaseUITheme.current.iconButtons.outline.copyShape(CircleShape),
) {
Icon(
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.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -63,7 +61,11 @@ import compose.icons.FeatherIcons
import compose.icons.feathericons.MoreVertical
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
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.copyShape
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicSquareRemove
@ -186,9 +188,9 @@ fun PlayerQueueContent(
singleLine = true,
modifier = Modifier.weight(1f),
)
FilledTonalIconButton(
SecondaryIconButton(
onClick = viewModel::clearQueue,
shape = MaterialTheme.shapes.small,
theme = LocalBaseUITheme.current.iconButtons.secondary.copyShape(MaterialTheme.shapes.small),
) {
Icon(Iconsax.IconsaxTrash, contentDescription = "Clear Queue")
}
@ -333,7 +335,7 @@ private fun QueueItemRow(
Spacer(modifier = Modifier.width(4.dp))
Box {
IconButton(
GhostIconButton(
onClick = { showMenu = true },
modifier = Modifier.size(36.dp),
) {