feat(previews): add comprehensive previews for UI components including sliders, toggles, checkboxes, and text fields

This commit is contained in:
Kingkor Roy Tirtho 2026-10-07 10:39:26 +06:00
parent 71d7649db6
commit 5c0652b395
14 changed files with 1378 additions and 529 deletions

View File

@ -33,6 +33,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
@ -43,6 +44,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@ -74,7 +76,6 @@ import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
@ -83,6 +84,7 @@ import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxSearchBroken
import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash
private val AutocompleteTextFieldMinHeight = 44.dp
private val AutocompleteMenuDefaultMaxHeight = 300.dp
@ -373,13 +375,11 @@ fun <T> AutocompleteTextField(
isMenuOpen = false
},
) {
Box(
AutocompleteMenuContainer(
theme = resolvedMenuTheme,
modifier = Modifier
.width(textFieldWidthDp)
.heightIn(max = menuMaxHeight)
.shadow(resolvedMenuTheme.shadowElevation, resolvedMenuTheme.shape)
.background(resolvedMenuTheme.background, resolvedMenuTheme.shape)
.clip(resolvedMenuTheme.shape),
.heightIn(max = menuMaxHeight),
) {
LazyColumn(
state = menuListState,
@ -407,18 +407,29 @@ fun <T> AutocompleteTextField(
}
}
@Preview
/**
* The visual container of the autocomplete suggestion menu, without the Popup window.
*
* Extracted so the menu can be rendered in previews, since popups are hosted in a
* separate window the preview canvas cannot show.
*/
@Composable
private fun AutocompleteTextFieldPreview() {
MaterialTheme {
val theme = rememberBaseUITheme()
CompositionLocalProvider(LocalBaseUITheme provides theme) {
androidx.compose.material3.Surface(
color = MaterialTheme.colorScheme.background,
modifier = Modifier.padding(24.dp),
internal fun AutocompleteMenuContainer(
theme: BaseUITheme.AutoCompleteMenuTheme,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
Box(
modifier = modifier
.shadow(theme.shadowElevation, theme.shape)
.background(theme.background, theme.shape)
.clip(theme.shape),
) {
val artists = remember {
listOf(
content()
}
}
private val AutocompletePreviewArtists = listOf(
"Twenty One Pilots",
"The Beatles",
"Adele",
@ -426,30 +437,110 @@ private fun AutocompleteTextFieldPreview() {
"Taylor Swift",
"Arctic Monkeys",
"Billie Eilish",
)
}
)
@BaseUIPhonePreview
@Composable
private fun AutocompleteTextFieldPreview() {
BaseUIPreview {
var value by remember { mutableStateOf("") }
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
Text(
text = "Field states",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
AutocompleteTextField(
value = value,
onValueChange = {
value = it
},
items = artists.filter {
it.contains(value, ignoreCase = true) && value.isNotEmpty()
},
onItemSelected = { selected ->
value = selected
},
value = "",
onValueChange = {},
items = emptyList<String>(),
itemContent = { _, _ -> },
placeholder = { Text("Search artists...") },
leadingIcon = {
androidx.compose.material3.Icon(
Icon(
imageVector = Iconsax.IconsaxSearchBroken,
contentDescription = "Search",
)
},
modifier = Modifier.fillMaxWidth(),
)
AutocompleteTextField(
value = value,
onValueChange = { value = it },
items = AutocompletePreviewArtists.filter {
it.contains(value, ignoreCase = true)
},
onItemSelected = { value = it },
placeholder = { Text("Search artists...") },
leadingIcon = {
Icon(
imageVector = Iconsax.IconsaxSearchBroken,
contentDescription = "Search",
)
},
trailingIcon = {
Icon(
imageVector = Iconsax.IconsaxTrash,
contentDescription = "Clear",
)
},
modifier = Modifier.fillMaxWidth(),
itemContent = { item, isSelected ->
AutocompletePreviewItem(item, isSelected)
},
)
}
}
}
@BaseUIPhonePreview
@Composable
private fun AutocompleteMenuPreview() {
BaseUIPreview {
var selectedIndex by remember { mutableStateOf(1) }
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = "Suggestion menu",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
AutocompleteMenuContainer(
theme = LocalBaseUITheme.current.autoCompleteMenuTheme,
modifier = Modifier.width(320.dp),
) {
Column {
AutocompletePreviewArtists.take(5).forEachIndexed { index, artist ->
Box(
modifier = Modifier.clickable { selectedIndex = index },
) {
AutocompletePreviewItem(artist, isSelected = selectedIndex == index)
}
}
}
}
Text(
text = "Selected: ${AutocompletePreviewArtists[selectedIndex]}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun AutocompletePreviewItem(text: String, isSelected: Boolean) {
Row(
modifier = Modifier
.fillMaxWidth()
@ -464,20 +555,15 @@ private fun AutocompleteTextFieldPreview() {
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
androidx.compose.material3.Icon(
Icon(
imageVector = Iconsax.IconsaxSearchBroken,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = item,
text = text,
style = MaterialTheme.typography.bodyMedium,
)
}
},
)
}
}
}
}

View File

@ -0,0 +1,89 @@
/*
* 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.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.AndroidUiModes.UI_MODE_NIGHT_YES
import androidx.compose.ui.tooling.preview.Devices
import androidx.compose.ui.tooling.preview.Preview
/**
* Shared scaffold for every preview of a base UI component.
*
* It mirrors the runtime environment the components expect:
* - a [MaterialTheme] whose light/dark mode follows the preview's `uiMode`, so the
* `@PreviewLightDark`-style annotations actually change the rendered colors,
* - a [LocalBaseUITheme] built from that color scheme via [rememberBaseUITheme],
* - a [Surface] matching the real app background, so layered highlights and shadows
* render against the color they will actually sit on.
*
* Always render base components inside [BaseUIPreview]; several of them read
* [LocalBaseUITheme] directly and would otherwise throw at composition time.
*/
@Composable
internal fun BaseUIPreview(
modifier: Modifier = Modifier,
content: @Composable BoxScope.() -> Unit,
) {
MaterialTheme(
colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme(),
) {
CompositionLocalProvider(LocalBaseUITheme provides rememberBaseUITheme()) {
Surface(
modifier = modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
Box(modifier = Modifier.fillMaxSize(), content = content)
}
}
}
}
/**
* Renders a base UI component on a phone-sized canvas in both light and dark themes.
*/
@Preview(name = "Phone · Light", group = "Base UI devices", device = Devices.PHONE)
@Preview(
name = "Phone · Dark",
group = "Base UI devices",
device = Devices.PHONE,
uiMode = UI_MODE_NIGHT_YES,
)
internal annotation class BaseUIPhonePreview
/**
* Renders a base UI component on a desktop-sized canvas in both light and dark themes.
*/
@Preview(name = "Desktop · Light", group = "Base UI devices", device = Devices.DESKTOP)
@Preview(
name = "Desktop · Dark",
group = "Base UI devices",
device = Devices.DESKTOP,
uiMode = UI_MODE_NIGHT_YES,
)
internal annotation class BaseUIDesktopPreview

View File

@ -27,6 +27,7 @@ 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
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
@ -46,7 +47,10 @@ import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
@ -57,7 +61,6 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import dev.krtirtho.spotube.resources.iconsax.ArrowLeft3
@ -549,15 +552,20 @@ fun ButtonGroupDivider(
)
}
@Preview(showBackground = true)
@BaseUIPhonePreview
@BaseUIDesktopPreview
@Composable
private fun ButtonsRow1Preview() {
MaterialTheme {
val theme = rememberBaseUITheme()
CompositionLocalProvider(LocalBaseUITheme provides theme) {
Surface(
color = MaterialTheme.colorScheme.background,
modifier = Modifier.padding(24.dp),
private fun ButtonsPreview() {
BaseUIPreview {
var liked by remember { mutableStateOf(false) }
var likeCount by remember { mutableIntStateOf(128) }
var following by remember { mutableStateOf(false) }
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
@ -569,20 +577,17 @@ private fun ButtonsRow1Preview() {
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Text(
"Copy link",
maxLines = 1,
softWrap = false,
)
Text("Share", maxLines = 1, softWrap = false)
}
OutlineButton(onClick = {}) {
Icon(
imageVector = Iconsax.User,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Text("Login", maxLines = 1, softWrap = false)
SecondaryButton(onClick = {}) {
Text("Secondary", maxLines = 1, softWrap = false)
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
PrimaryButton(onClick = {}) {
Icon(
imageVector = Iconsax.IconsaxAddSquare,
@ -590,7 +595,67 @@ private fun ButtonsRow1Preview() {
modifier = Modifier.size(18.dp),
)
Text(
"Sign Up",
"Sign up",
maxLines = 1,
softWrap = false,
fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold,
)
}
GhostButton(onClick = {}) {
Text("Ghost", maxLines = 1, softWrap = false)
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlineButton(onClick = {}, enabled = false) {
Text("Disabled", maxLines = 1, softWrap = false)
}
SecondaryButton(onClick = {}, enabled = false) {
Text("Disabled", maxLines = 1, softWrap = false)
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
PrimaryButton(onClick = {}, enabled = false) {
Text("Disabled", maxLines = 1, softWrap = false)
}
OutlineButton(onClick = {}) {
Text("Forward", maxLines = 1, softWrap = false)
Icon(
imageVector = Iconsax.IconsaxArrowSquareUp,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlineButton(
onClick = {
liked = !liked
likeCount += if (liked) 1 else -1
},
) {
Icon(
imageVector = Iconsax.IconsaxHeart,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Text(if (liked) "Liked" else "Like", maxLines = 1, softWrap = false)
ButtonBadge(count = likeCount)
}
PrimaryButton(onClick = { following = !following }) {
Text(
if (following) "Following" else "Follow",
maxLines = 1,
softWrap = false,
fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold,
@ -599,22 +664,123 @@ private fun ButtonsRow1Preview() {
}
}
}
}
@BaseUIPhonePreview
@Composable
private fun IconButtonsPreview() {
BaseUIPreview {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = "Icon buttons",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
IconButton(onClick = {}) {
Icon(
imageVector = Iconsax.IconsaxMagic,
contentDescription = "Magic",
modifier = Modifier.size(18.dp),
)
}
GhostIconButton(onClick = {}) {
Icon(
imageVector = Iconsax.IconsaxHeart,
contentDescription = "Favorite",
modifier = Modifier.size(18.dp),
)
}
PrimaryIconButton(onClick = {}) {
Icon(
imageVector = Iconsax.IconsaxAddSquare,
contentDescription = "Add",
modifier = Modifier.size(18.dp),
)
}
SecondaryIconButton(onClick = {}) {
Icon(
imageVector = Iconsax.IconsaxShare,
contentDescription = "Share",
modifier = Modifier.size(18.dp),
)
}
}
Text(
text = "Variable icon button",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
VariableIconButton(
variant = VariableIconButtonVariant.Outline,
onClick = {},
) {
Icon(
imageVector = Iconsax.IconsaxDocumentText,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
}
VariableIconButton(
variant = VariableIconButtonVariant.Ghost,
onClick = {},
) {
Icon(
imageVector = Iconsax.Iconsax3DotsMore,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
}
VariableIconButton(
variant = VariableIconButtonVariant.Primary,
onClick = {},
) {
Icon(
imageVector = Iconsax.IconsaxMagic,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
}
VariableIconButton(
variant = VariableIconButtonVariant.Secondary,
onClick = {},
) {
Icon(
imageVector = Iconsax.User,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
}
}
}
}
}
@Preview(showBackground = true)
@BaseUIDesktopPreview
@Composable
private fun ButtonsRow2Preview() {
MaterialTheme {
val theme = rememberBaseUITheme()
CompositionLocalProvider(LocalBaseUITheme provides theme) {
Surface(
color = MaterialTheme.colorScheme.background,
modifier = Modifier.padding(24.dp),
private fun ButtonGroupPreview() {
BaseUIPreview {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
Text(
text = "Button groups",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
ButtonGroup {
GroupButton(onClick = {}) {
@ -660,133 +826,7 @@ private fun ButtonsRow2Preview() {
)
}
}
}
}
}
}
}
@Preview(showBackground = true)
@Composable
private fun ButtonsRow3Preview() {
MaterialTheme {
val theme = rememberBaseUITheme()
CompositionLocalProvider(LocalBaseUITheme provides theme) {
Surface(
color = MaterialTheme.colorScheme.background,
modifier = Modifier.padding(24.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlineButton(onClick = {}) {
Text("Cancel", maxLines = 1, softWrap = false)
}
PrimaryButton(onClick = {}) {
Text(
"Done",
maxLines = 1,
softWrap = false,
fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold,
)
}
IconButton(onClick = {}) {
Icon(
imageVector = Iconsax.IconsaxMagic,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
}
OutlineButton(onClick = {}) {
Icon(
imageVector = Iconsax.IconsaxHeart,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Text("Like", maxLines = 1, softWrap = false)
ButtonBadge(count = 2)
}
}
}
}
}
}
@Preview(showBackground = true)
@Composable
private fun ButtonsRow4Preview() {
MaterialTheme {
val theme = rememberBaseUITheme()
CompositionLocalProvider(LocalBaseUITheme provides theme) {
Surface(
color = MaterialTheme.colorScheme.background,
modifier = Modifier.padding(24.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
ButtonGroup {
GroupIconButton(onClick = {}) {
Icon(
imageVector = Iconsax.ArrowLeft3,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
}
ButtonGroupDivider()
GroupIconButton(onClick = {}) {
Icon(
imageVector = Iconsax.IconsaxNext,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
}
}
OutlineButton(onClick = {}) {
Text("Forward", maxLines = 1, softWrap = false)
Icon(
imageVector = Iconsax.IconsaxArrowSquareUp,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
}
}
}
}
}
}
@Preview(showBackground = true)
@Composable
private fun ButtonStylesPreview() {
MaterialTheme {
val theme = rememberBaseUITheme()
CompositionLocalProvider(LocalBaseUITheme provides theme) {
Surface(
color = MaterialTheme.colorScheme.background,
modifier = Modifier.padding(24.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlineButton(onClick = {}) {
Text("Outline", maxLines = 1, softWrap = false)
}
SecondaryButton(onClick = {}) {
Text("Secondary", maxLines = 1, softWrap = false)
}
PrimaryButton(onClick = {}) {
Text(
"Primary",
maxLines = 1,
softWrap = false,
fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold,
)
}
}
ButtonBadge(count = 8)
}
}
}

View File

@ -20,18 +20,38 @@ 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.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
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.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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusic
import dev.krtirtho.spotube.resources.iconsax.IconsaxPause
import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay
@Composable
fun Card(
@ -62,18 +82,90 @@ fun Card(
}
}
@BaseUIPhonePreview
@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),
BaseUIPreview {
var connected by remember { mutableStateOf(false) }
var playing by remember { mutableStateOf(false) }
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Card {
Box(modifier = Modifier.padding(16.dp))
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = if (connected) "Spotify connected" else "Connect a music source",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.height(6.dp))
Text(
text = if (connected) {
"Streaming, downloads and scrobbling are ready."
} else {
"Sign in to stream, download and scrobble from your account."
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
OutlineButton(onClick = { connected = false }) {
Text("Disconnect")
}
PrimaryButton(onClick = { connected = !connected }) {
Text(if (connected) "Connected" else "Connect")
}
}
}
}
Card {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(44.dp)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Iconsax.IconsaxMusic,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary,
)
}
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Midnight City",
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
)
Text(
text = "M83 · Hurry Up, We're Dreaming",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
}
PrimaryIconButton(onClick = { playing = !playing }) {
Icon(
imageVector = if (playing) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
contentDescription = if (playing) "Pause" else "Play",
modifier = Modifier.size(18.dp),
)
}
}
}
}

View File

@ -31,17 +31,20 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -56,7 +59,6 @@ import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
enum class CheckBoxState {
@ -207,47 +209,72 @@ fun CheckBox(
}
}
@Preview
@BaseUIPhonePreview
@Composable
fun CheckBoxPreview() {
MaterialTheme {
val theme = rememberBaseUITheme()
CompositionLocalProvider(LocalBaseUITheme provides theme) {
Surface(
color = MaterialTheme.colorScheme.background,
modifier = Modifier.padding(24.dp),
private fun CheckBoxPreview() {
BaseUIPreview {
var wifi by remember { mutableStateOf(true) }
var bluetooth by remember { mutableStateOf(false) }
var downloads by remember { mutableStateOf(true) }
val allEnabled = wifi && bluetooth && downloads
val anyEnabled = wifi || bluetooth || downloads
val allState = when {
allEnabled -> CheckBoxState.SELECTED
anyEnabled -> CheckBoxState.INDETERMINATE
else -> CheckBoxState.UNSELECTED
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.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)
}
}
}
CheckBoxPreviewRow(
state = allState,
label = "All playback sources",
onClick = {
val target = !allEnabled
wifi = target
bluetooth = target
downloads = target
},
)
Spacer(Modifier.height(4.dp))
CheckBoxPreviewRow(
state = if (wifi) CheckBoxState.SELECTED else CheckBoxState.UNSELECTED,
label = "Wi-Fi",
onClick = { wifi = !wifi },
)
CheckBoxPreviewRow(
state = if (bluetooth) CheckBoxState.SELECTED else CheckBoxState.UNSELECTED,
label = "Bluetooth",
onClick = { bluetooth = !bluetooth },
)
CheckBoxPreviewRow(
state = if (downloads) CheckBoxState.SELECTED else CheckBoxState.UNSELECTED,
label = "Download over cellular",
onClick = { downloads = !downloads },
)
CheckBoxPreviewRow(CheckBoxState.SELECTED, "Disabled selected", enabled = false)
CheckBoxPreviewRow(CheckBoxState.UNSELECTED, "Disabled unselected", enabled = false)
}
}
}
@Composable
private fun CheckBoxPreviewRow(
state: CheckBoxState,
label: String,
enabled: Boolean = true,
onClick: () -> Unit = {},
) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
CheckBox(state = state, onClick = onClick, enabled = enabled)
Text(label, style = MaterialTheme.typography.bodyMedium)
}
}

View File

@ -27,10 +27,14 @@ 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
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.Text
@ -38,7 +42,9 @@ import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -50,6 +56,9 @@ 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
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxDocumentDownload
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicLibrary
private val ChipTabMinHeight = 36.dp
@ -169,3 +178,62 @@ fun ChipTab(
)
}
}
@BaseUIPhonePreview
@Composable
private fun ChipTabsPreview() {
BaseUIPreview {
val tabs = listOf("All", "Songs", "Albums", "Playlists")
var selected by remember { mutableStateOf(0) }
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
tabs.forEachIndexed { index, tab ->
ChipTab(
text = tab,
selected = selected == index,
onClick = { selected = index },
)
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ChipTab(
text = "Library",
selected = true,
onClick = {},
leadingIcon = {
Icon(
imageVector = Iconsax.IconsaxMusicLibrary,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
},
)
ChipTab(
text = "Downloads",
selected = false,
onClick = {},
leadingIcon = {
Icon(
imageVector = Iconsax.IconsaxDocumentDownload,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
},
)
ChipTab(
text = "Disabled",
selected = false,
onClick = {},
enabled = false,
)
}
}
}
}

View File

@ -26,11 +26,14 @@ import androidx.compose.animation.scaleOut
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
@ -42,7 +45,6 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -52,7 +54,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
@ -70,8 +72,6 @@ fun ThemedDialog(
properties: DialogProperties = DialogProperties(usePlatformDefaultWidth = false),
content: @Composable () -> Unit,
) {
val dialogTheme = theme ?: LocalBaseUITheme.current.dialog
Dialog(
onDismissRequest = onDismissRequest,
properties = properties,
@ -93,8 +93,35 @@ fun ThemedDialog(
.padding(24.dp),
contentAlignment = Alignment.Center,
) {
ThemedDialogSurface(
theme = theme,
title = title,
actions = actions,
content = content,
)
}
}
}
}
/**
* The visual chrome of [ThemedDialog] without the platform [Dialog] window.
*
* Extracted so the dialog can be rendered inside previews (and reused elsewhere),
* since the platform dialog lives in a separate window the preview canvas cannot show.
*/
@Composable
internal fun ThemedDialogSurface(
modifier: Modifier = Modifier,
theme: BaseUITheme.DialogTheme? = null,
title: @Composable (() -> Unit)? = null,
actions: @Composable (() -> Unit)? = null,
content: @Composable () -> Unit,
) {
val dialogTheme = theme ?: LocalBaseUITheme.current.dialog
Box(
modifier = Modifier
modifier = modifier
.widthIn(max = DefaultDialogMaxWidth)
.shadow(
elevation = dialogTheme.shadow.elevation,
@ -153,30 +180,54 @@ fun ThemedDialog(
}
}
}
}
}
}
}
@Preview
@BaseUIPhonePreview
@Composable
private fun ThemedDialogPreview() {
MaterialTheme {
val theme = rememberBaseUITheme()
CompositionLocalProvider(LocalBaseUITheme provides theme) {
var show by remember { mutableStateOf(true) }
androidx.compose.material3.Surface(
color = MaterialTheme.colorScheme.background,
BaseUIPreview {
var visible by remember { mutableStateOf(true) }
Box(modifier = Modifier.fillMaxSize()) {
PrimaryButton(
onClick = { visible = true },
modifier = Modifier
.align(Alignment.TopStart)
.padding(24.dp),
) {
PrimaryButton(onClick = { show = true }) {
Text("Open Dialog")
Text("Open dialog")
}
if (show) {
ThemedDialog(onDismissRequest = { show = false }) {
if (visible) {
Box(
modifier = Modifier
.fillMaxSize()
.background(LocalBaseUITheme.current.dialog.scrim)
.clickable { visible = false },
contentAlignment = Alignment.Center,
) {
ThemedDialogSurface(
modifier = Modifier.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = {},
),
title = {
Text(
"This is a themed dialog with glass-like gradient, shadow, and scale animation.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
text = "Sign out of Spotube?",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
)
},
actions = {
OutlineButton(onClick = { visible = false }) { Text("Cancel") }
PrimaryButton(onClick = { visible = false }) { Text("Sign out") }
},
) {
Text(
text = "You can sign back in at any time. Your downloaded tracks stay on this device.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}

View File

@ -26,8 +26,10 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
@ -38,7 +40,9 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -47,6 +51,12 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart
import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay
import dev.krtirtho.spotube.resources.iconsax.IconsaxShare
import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash
@Composable
fun DropdownMenu(
@ -63,25 +73,32 @@ fun DropdownMenu(
expanded = expanded,
onDismissRequest = onDismissRequest,
offset = offset,
modifier = modifier
.widthIn(min = 180.dp)
.shadow(
elevation = menuTheme.shadow.elevation,
shape = menuTheme.shape,
ambientColor = menuTheme.shadow.ambientColor,
spotColor = menuTheme.shadow.spotColor,
)
.clip(menuTheme.shape)
.background(menuTheme.background, menuTheme.shape)
.border(
BorderStroke(menuTheme.border.width, menuTheme.border.color),
menuTheme.shape,
),
modifier = modifier.dropdownMenuContainer(menuTheme),
) {
content()
}
}
/**
* The container styling of [DropdownMenu], split out so it can be reused by the
* material3 popup and by previews, which cannot render the popup window itself.
*/
internal fun Modifier.dropdownMenuContainer(theme: BaseUITheme.DropdownMenuTheme): Modifier =
this
.widthIn(min = 180.dp)
.shadow(
elevation = theme.shadow.elevation,
shape = theme.shape,
ambientColor = theme.shadow.ambientColor,
spotColor = theme.shadow.spotColor,
)
.clip(theme.shape)
.background(theme.background, theme.shape)
.border(
BorderStroke(theme.border.width, theme.border.color),
theme.shape,
)
@Composable
fun DropdownMenuItem(
text: String,
@ -170,3 +187,78 @@ fun DropdownMenuDivider(
)
}
}
@BaseUIPhonePreview
@Composable
private fun DropdownMenuPreview() {
BaseUIPreview {
var expanded by remember { mutableStateOf(true) }
var lastAction by remember { mutableStateOf("None") }
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
OutlineButton(onClick = { expanded = !expanded }) {
Text(if (expanded) "Hide menu" else "Show menu")
}
if (expanded) {
Column(
modifier = Modifier.dropdownMenuContainer(
LocalBaseUITheme.current.dropdownMenu,
),
) {
DropdownMenuItem(
text = "Play next",
leadingIcon = Iconsax.IconsaxPlay,
onClick = {
lastAction = "Play next"
expanded = false
},
)
DropdownMenuItem(
text = "Add to queue",
leadingIcon = Iconsax.IconsaxAddSquare,
onClick = {
lastAction = "Add to queue"
expanded = false
},
)
DropdownMenuItem(
text = "Add to favorites",
leadingIcon = Iconsax.IconsaxHeart,
selected = lastAction == "Add to favorites",
onClick = {
lastAction = "Add to favorites"
expanded = false
},
)
DropdownMenuDivider()
DropdownMenuItem(
text = "Share",
leadingIcon = Iconsax.IconsaxShare,
onClick = {
lastAction = "Share"
expanded = false
},
)
DropdownMenuItem(
text = "Remove from playlist",
leadingIcon = Iconsax.IconsaxTrash,
enabled = false,
onClick = {},
)
}
}
Text(
text = "Last action: $lastAction",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}

View File

@ -25,20 +25,31 @@ import androidx.compose.foundation.interaction.collectIsFocusedAsState
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
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
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.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.unit.dp
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusic
@Composable
fun ListRowTile(
@ -88,7 +99,13 @@ fun ListRowTile(
val mod = modifier
.fillMaxWidth()
.shadow(shadowDef.elevation, shape, shadowDef.clip, shadowDef.ambientColor, shadowDef.spotColor)
.shadow(
shadowDef.elevation,
shape,
shadowDef.clip,
shadowDef.ambientColor,
shadowDef.spotColor
)
.clip(shape)
.border(borderDef.width, borderDef.color, shape)
.background(background, shape)
@ -130,3 +147,94 @@ fun ListRowTile(
}
}
}
private data class ListRowPreviewTrack(
val title: String,
val subtitle: String,
val duration: String,
)
private val ListRowPreviewTracks = listOf(
ListRowPreviewTrack("Midnight City", "M83 · Hurry Up, We're Dreaming", "4:03"),
ListRowPreviewTrack("Odoriko", "Vaundy · strobo", "3:37"),
ListRowPreviewTrack("Instant Crush", "Daft Punk · Random Access Memories", "5:37"),
)
@BaseUIPhonePreview
@Composable
private fun ListRowTilePreview() {
BaseUIPreview {
var selectedIndex by remember { mutableStateOf(0) }
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
ListRowPreviewTracks.forEachIndexed { index, track ->
ListRowTile(
selected = selectedIndex == index,
onClick = { selectedIndex = index },
leading = { ListRowTilePreviewArtwork() },
title = {
Text(
text = track.title,
style = MaterialTheme.typography.bodyMedium,
)
},
subtitle = {
Text(
text = track.subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
trailing = {
Text(
text = track.duration,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
)
}
ListRowTile(
enabled = false,
title = {
Text(
text = "Add to queue",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f),
)
},
subtitle = {
Text(
text = "Unavailable offline",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f),
)
},
)
}
}
}
@Composable
private fun ListRowTilePreviewArtwork() {
Box(
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(10.dp))
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Iconsax.IconsaxMusic,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.primary,
)
}
}

View File

@ -31,6 +31,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
@ -38,9 +39,10 @@ import androidx.compose.material3.MaterialTheme
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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -48,7 +50,6 @@ import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
private val RadioSize = 22.dp
@ -145,32 +146,37 @@ fun Radio(
}
}
@Preview
@BaseUIPhonePreview
@Composable
private fun RadioPreview() {
MaterialTheme {
val theme = rememberBaseUITheme()
CompositionLocalProvider(LocalBaseUITheme provides theme) {
androidx.compose.material3.Surface(
color = MaterialTheme.colorScheme.background,
modifier = Modifier.padding(24.dp),
BaseUIPreview {
val options = listOf("Automatic", "High", "Lossless", "Data saver")
var selected by remember { mutableStateOf(0) }
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
options.forEachIndexed { index, label ->
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Radio(selected = true, onClick = {})
Text("Selected", style = MaterialTheme.typography.bodyMedium)
Radio(
selected = selected == index,
onClick = { selected = index },
)
Text(label, style = MaterialTheme.typography.bodyMedium)
}
}
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Radio(selected = false, onClick = {})
Text("Unselected", style = MaterialTheme.typography.bodyMedium)
}
}
Radio(selected = true, onClick = {}, enabled = false)
Text("Disabled", style = MaterialTheme.typography.bodyMedium)
}
}
}

View File

@ -30,6 +30,8 @@ import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
@ -40,10 +42,8 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
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
@ -61,7 +61,6 @@ import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import kotlin.math.roundToInt
@ -311,3 +310,60 @@ fun Slider(
)
}
}
@BaseUIPhonePreview
@Composable
private fun SliderPreview() {
BaseUIPreview {
var volume by remember { mutableFloatStateOf(0.65f) }
var progress by remember { mutableFloatStateOf(0.3f) }
var quality by remember { mutableFloatStateOf(2f) }
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(28.dp),
) {
SliderPreviewField(title = "Volume", value = "${(volume * 100).toInt()}%") {
Slider(value = volume, onValueChange = { volume = it })
}
SliderPreviewField(title = "Progress", value = "${(progress * 100).toInt()}%") {
Slider(value = progress, onValueChange = { progress = it })
}
SliderPreviewField(title = "Quality", value = "${quality.toInt()} / 4") {
Slider(
value = quality,
onValueChange = { quality = it },
valueRange = 0f..4f,
steps = 3,
)
}
SliderPreviewField(title = "Disabled", value = "40%") {
Slider(value = 0.4f, onValueChange = {}, enabled = false)
}
}
}
}
@Composable
private fun SliderPreviewField(
title: String,
value: String,
content: @Composable () -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(title, style = MaterialTheme.typography.bodyMedium)
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
content()
}
}

View File

@ -31,19 +31,26 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -55,11 +62,11 @@ 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
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxSearchBroken
import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash
import dev.krtirtho.spotube.resources.iconsax.User
private val TextFieldMinHeight = 44.dp
@ -201,43 +208,84 @@ fun TextField(
}
}
@Preview
@BaseUIPhonePreview
@Composable
fun TextFieldPreview() {
MaterialTheme {
val theme = rememberBaseUITheme()
CompositionLocalProvider(LocalBaseUITheme provides theme) {
androidx.compose.material3.Surface(
color = MaterialTheme.colorScheme.background,
modifier = Modifier.padding(24.dp),
private fun TextFieldPreview() {
BaseUIPreview {
var email by remember { mutableStateOf("") }
var query by remember { mutableStateOf("Twenty One Pilots") }
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
TextField(
modifier = Modifier.padding(top = 4.dp),
value = "Twenty One Pilots",
value = email,
onValueChange = { email = it },
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("you@example.com") },
label = { TextFieldPreviewLabel("Email address") },
leadingIcon = {
Icon(
imageVector = Iconsax.User,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
},
singleLine = true,
)
TextField(
value = query,
onValueChange = { query = it },
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("Search songs, artists, albums...") },
leadingIcon = {
Icon(
imageVector = Iconsax.IconsaxSearchBroken,
contentDescription = "Search",
modifier = Modifier.size(18.dp),
)
},
trailingIcon = {
Icon(
imageVector = Iconsax.IconsaxTrash,
contentDescription = "Clear",
modifier = Modifier.size(18.dp),
)
},
singleLine = true,
)
TextField(
value = "not-an-email",
onValueChange = {},
placeholder = { Text("Placeholder") },
label = {
modifier = Modifier.fillMaxWidth(),
label = { TextFieldPreviewLabel("Error state") },
isError = true,
singleLine = true,
)
TextField(
value = "Read only value",
onValueChange = {},
modifier = Modifier.fillMaxWidth(),
label = { TextFieldPreviewLabel("Disabled") },
enabled = false,
singleLine = true,
)
}
}
}
@Composable
private fun TextFieldPreviewLabel(text: String) {
Text(
"Search Field",
text = text,
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

@ -31,6 +31,9 @@ import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
@ -38,11 +41,12 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -53,7 +57,6 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.max
@ -239,26 +242,56 @@ fun Toggle(
}
}
@Preview(showBackground = true)
@BaseUIPhonePreview
@Composable
private fun TogglePreview() {
MaterialTheme {
val theme = rememberBaseUITheme()
CompositionLocalProvider(LocalBaseUITheme provides theme) {
Surface(
color = MaterialTheme.colorScheme.background,
modifier = Modifier.padding(24.dp),
) {
BaseUIPreview {
var scrobbling by remember { mutableStateOf(true) }
var offlineMode by remember { mutableStateOf(false) }
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
Toggle(checked = true, onCheckedChange = {})
Toggle(checked = false, onCheckedChange = {})
Toggle(checked = true, onCheckedChange = null)
Toggle(checked = false, onCheckedChange = null, enabled = false)
}
}
TogglePreviewRow(
label = "Scrobble listened tracks",
checked = scrobbling,
onCheckedChange = { scrobbling = it },
)
TogglePreviewRow(
label = "Offline mode",
checked = offlineMode,
onCheckedChange = { offlineMode = it },
)
TogglePreviewRow(
label = "Discord rich presence",
checked = true,
enabled = false,
onCheckedChange = {},
)
}
}
}
@Composable
private fun TogglePreviewRow(
label: String,
checked: Boolean,
enabled: Boolean = true,
onCheckedChange: (Boolean) -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(label, style = MaterialTheme.typography.bodyMedium)
Toggle(
checked = checked,
onCheckedChange = onCheckedChange,
enabled = enabled,
)
}
}

View File

@ -1,9 +1,26 @@
package dev.krtirtho.spotube.core.ui.base
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
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.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.unit.dp
fun Modifier.highlight(color: Color): Modifier =
drawWithCache {
@ -21,3 +38,39 @@ fun Modifier.highlight(color: Color): Modifier =
)
}
}
@BaseUIPhonePreview
@Composable
private fun HighlightModifierPreview() {
BaseUIPreview {
var highlighted by remember { mutableStateOf(true) }
Row(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Box(
modifier = Modifier
.size(96.dp)
.clip(RoundedCornerShape(16.dp))
.background(MaterialTheme.colorScheme.primaryContainer)
.then(
if (highlighted) {
Modifier.highlight(Color.White.copy(alpha = 0.45f))
} else {
Modifier
},
)
.clickable { highlighted = !highlighted },
)
Box(
modifier = Modifier
.size(96.dp)
.clip(RoundedCornerShape(16.dp))
.background(MaterialTheme.colorScheme.primaryContainer),
)
}
}
}