From 5c0652b39507a857a549cd0871cebb1205abc543 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Wed, 7 Oct 2026 10:39:26 +0600 Subject: [PATCH] feat(previews): add comprehensive previews for UI components including sliders, toggles, checkboxes, and text fields --- .../core/ui/base/AutocompleteTextField.kt | 234 ++++++--- .../spotube/core/ui/base/BaseUIPreviews.kt | 89 ++++ .../krtirtho/spotube/core/ui/base/Buttons.kt | 480 ++++++++++-------- .../dev/krtirtho/spotube/core/ui/base/Card.kt | 116 ++++- .../krtirtho/spotube/core/ui/base/CheckBox.kt | 113 +++-- .../krtirtho/spotube/core/ui/base/ChipTabs.kt | 68 +++ .../krtirtho/spotube/core/ui/base/Dialog.kt | 209 +++++--- .../spotube/core/ui/base/DropdownMenu.kt | 120 ++++- .../spotube/core/ui/base/ListRowTile.kt | 110 +++- .../krtirtho/spotube/core/ui/base/Radio.kt | 56 +- .../krtirtho/spotube/core/ui/base/Slider.kt | 62 ++- .../spotube/core/ui/base/TextField.kt | 122 +++-- .../krtirtho/spotube/core/ui/base/Toggle.kt | 75 ++- .../krtirtho/spotube/core/ui/base/Utils.kt | 53 ++ 14 files changed, 1378 insertions(+), 529 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/BaseUIPreviews.kt diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/AutocompleteTextField.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/AutocompleteTextField.kt index ca559005..670524a9 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/AutocompleteTextField.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/AutocompleteTextField.kt @@ -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 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,77 +407,163 @@ fun 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 +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), + ) { + content() + } +} + +private val AutocompletePreviewArtists = listOf( + "Twenty One Pilots", + "The Beatles", + "Adele", + "Drake", + "Taylor Swift", + "Arctic Monkeys", + "Billie Eilish", +) + +@BaseUIPhonePreview @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), - ) { - val artists = remember { - listOf( - "Twenty One Pilots", - "The Beatles", - "Adele", - "Drake", - "Taylor Swift", - "Arctic Monkeys", - "Billie Eilish", - ) - } - var value by remember { mutableStateOf("") } + BaseUIPreview { + var value by remember { mutableStateOf("") } - AutocompleteTextField( - value = value, - onValueChange = { - value = it - }, - items = artists.filter { - it.contains(value, ignoreCase = true) && value.isNotEmpty() - }, - onItemSelected = { selected -> - value = selected - }, - placeholder = { Text("Search artists...") }, - leadingIcon = { - androidx.compose.material3.Icon( - imageVector = Iconsax.IconsaxSearchBroken, - contentDescription = "Search", - ) - }, - modifier = Modifier.fillMaxWidth(), - itemContent = { item, isSelected -> - Row( - modifier = Modifier - .fillMaxWidth() - .background( - color = if (isSelected) { - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f) - } else { - Color.Transparent - }, - ) - .padding(horizontal = 14.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - androidx.compose.material3.Icon( - imageVector = Iconsax.IconsaxSearchBroken, - contentDescription = null, - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = item, - style = MaterialTheme.typography.bodyMedium, - ) - } - }, - ) - } + 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 = "", + onValueChange = {}, + items = emptyList(), + itemContent = { _, _ -> }, + placeholder = { Text("Search artists...") }, + leadingIcon = { + 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() + .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), + ) { + Icon( + imageVector = Iconsax.IconsaxSearchBroken, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/BaseUIPreviews.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/BaseUIPreviews.kt new file mode 100644 index 00000000..1a5cc11c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/BaseUIPreviews.kt @@ -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 . + */ + +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 diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Buttons.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Buttons.kt index 885f6357..430b2899 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Buttons.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Buttons.kt @@ -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,244 +552,281 @@ 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, + horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), + OutlineButton(onClick = {}) { + Icon( + imageVector = Iconsax.IconsaxShare, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Text("Share", 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, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Text( + "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 + }, ) { - OutlineButton(onClick = {}) { + 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, + ) + } + } + } + } +} + +@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), + ) + } + } + } + } +} + +@BaseUIDesktopPreview +@Composable +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(16.dp), + ) { + ButtonGroup { + GroupButton(onClick = {}) { + Icon( + imageVector = Iconsax.IconsaxDocumentText, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Text("Documents", maxLines = 1, softWrap = false) + } + ButtonGroupDivider() + GroupButton(onClick = {}) { Icon( imageVector = Iconsax.IconsaxShare, contentDescription = null, modifier = Modifier.size(18.dp), ) - Text( - "Copy link", - maxLines = 1, - softWrap = false, - ) + Text("Export", maxLines = 1, softWrap = false) } - OutlineButton(onClick = {}) { + ButtonGroupDivider() + GroupIconButton(onClick = {}) { Icon( - imageVector = Iconsax.User, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Text("Login", maxLines = 1, softWrap = false) - } - PrimaryButton(onClick = {}) { - Icon( - imageVector = Iconsax.IconsaxAddSquare, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Text( - "Sign Up", - maxLines = 1, - softWrap = false, - fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold, - ) - } - } - } - } - } -} - -@Preview(showBackground = true) -@Composable -private fun ButtonsRow2Preview() { - 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 { - GroupButton(onClick = {}) { - Icon( - imageVector = Iconsax.IconsaxDocumentText, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Text("Documents", maxLines = 1, softWrap = false) - } - ButtonGroupDivider() - GroupButton(onClick = {}) { - Icon( - imageVector = Iconsax.IconsaxShare, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Text("Export", maxLines = 1, softWrap = false) - } - ButtonGroupDivider() - GroupIconButton(onClick = {}) { - Icon( - imageVector = Iconsax.Iconsax3DotsMore, - contentDescription = null, - modifier = Modifier.size(18.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), - ) - } - } - } - } - } - } -} - -@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, + imageVector = Iconsax.Iconsax3DotsMore, 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) + ButtonGroup { + GroupIconButton(onClick = {}) { + Icon( + imageVector = Iconsax.ArrowLeft3, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) } - SecondaryButton(onClick = {}) { - Text("Secondary", maxLines = 1, softWrap = false) - } - PrimaryButton(onClick = {}) { - Text( - "Primary", - maxLines = 1, - softWrap = false, - fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold, + ButtonGroupDivider() + GroupIconButton(onClick = {}) { + Icon( + imageVector = Iconsax.IconsaxNext, + contentDescription = null, + modifier = Modifier.size(18.dp), ) } } + ButtonBadge(count = 8) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Card.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Card.kt index 61e23930..cdb7f54e 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Card.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Card.kt @@ -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), - ) { - Card { - Box(modifier = Modifier.padding(16.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 { + 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), + ) + } } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/CheckBox.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/CheckBox.kt index 23d66e55..af1f0643 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/CheckBox.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/CheckBox.kt @@ -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), - ) { - 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) - } - } - } +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), + ) { + 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) + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/ChipTabs.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/ChipTabs.kt index 38963781..e8042ee0 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/ChipTabs.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/ChipTabs.kt @@ -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, + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Dialog.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Dialog.kt index 67dd0abe..b7cd5613 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Dialog.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Dialog.kt @@ -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,90 +93,141 @@ fun ThemedDialog( .padding(24.dp), contentAlignment = Alignment.Center, ) { - Box( - modifier = Modifier - .widthIn(max = DefaultDialogMaxWidth) - .shadow( - elevation = dialogTheme.shadow.elevation, - shape = dialogTheme.shadow.let { if (it.clip) dialogTheme.shape else RoundedCornerShape(0.dp) }, - ambientColor = dialogTheme.shadow.ambientColor, - spotColor = dialogTheme.shadow.spotColor, - ) - .clip(dialogTheme.shape) - .background(dialogTheme.background, dialogTheme.shape) - .border( - BorderStroke(dialogTheme.border.width, dialogTheme.border.color), - dialogTheme.shape - ) - .highlight(Color.White.copy(alpha = 0.08f)), - ) { - Column(modifier = Modifier.widthIn(max = DefaultDialogMaxWidth)) { - if (title != null) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(start = 24.dp, end = 24.dp, top = 24.dp), - ) { - title() - } - Spacer(Modifier.height(8.dp)) - } - - Column( - modifier = Modifier - .weight(1f, fill = false) - .verticalScroll(rememberScrollState()) - .padding( - start = 24.dp, - end = 24.dp, - top = if (title == null) 24.dp else 0.dp, - ), - ) { - content() - } - - if (actions != null) { - Spacer(Modifier.height(16.dp)) - Column(modifier = Modifier.fillMaxWidth()) { - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), - ) { - actions() - } - } - } else { - Spacer(Modifier.height(24.dp)) - } - } - } + ThemedDialogSurface( + theme = theme, + title = title, + actions = actions, + content = content, + ) } } } } -@Preview +/** + * 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 + .widthIn(max = DefaultDialogMaxWidth) + .shadow( + elevation = dialogTheme.shadow.elevation, + shape = dialogTheme.shadow.let { if (it.clip) dialogTheme.shape else RoundedCornerShape(0.dp) }, + ambientColor = dialogTheme.shadow.ambientColor, + spotColor = dialogTheme.shadow.spotColor, + ) + .clip(dialogTheme.shape) + .background(dialogTheme.background, dialogTheme.shape) + .border( + BorderStroke(dialogTheme.border.width, dialogTheme.border.color), + dialogTheme.shape + ) + .highlight(Color.White.copy(alpha = 0.08f)), + ) { + Column(modifier = Modifier.widthIn(max = DefaultDialogMaxWidth)) { + if (title != null) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 24.dp, end = 24.dp, top = 24.dp), + ) { + title() + } + Spacer(Modifier.height(8.dp)) + } + + Column( + modifier = Modifier + .weight(1f, fill = false) + .verticalScroll(rememberScrollState()) + .padding( + start = 24.dp, + end = 24.dp, + top = if (title == null) 24.dp else 0.dp, + ), + ) { + content() + } + + if (actions != null) { + Spacer(Modifier.height(16.dp)) + Column(modifier = Modifier.fillMaxWidth()) { + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + ) { + actions() + } + } + } else { + Spacer(Modifier.height(24.dp)) + } + } + } +} + +@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") - } - if (show) { - ThemedDialog(onDismissRequest = { show = false }) { + Text("Open dialog") + } + + 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( + 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( - "This is a themed dialog with glass-like gradient, shadow, and scale animation.", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, + text = "You can sign back in at any time. Your downloaded tracks stay on this device.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/DropdownMenu.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/DropdownMenu.kt index 3ac8cc56..63dc38ec 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/DropdownMenu.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/DropdownMenu.kt @@ -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, + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/ListRowTile.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/ListRowTile.kt index 4969bc4b..6111f2a4 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/ListRowTile.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/ListRowTile.kt @@ -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, + ) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Radio.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Radio.kt index 5007db43..0a159766 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Radio.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Radio.kt @@ -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,33 +146,38 @@ 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), - ) { - Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Radio(selected = true, onClick = {}) - Text("Selected", style = MaterialTheme.typography.bodyMedium) - } - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Radio(selected = false, onClick = {}) - Text("Unselected", style = MaterialTheme.typography.bodyMedium) - } + 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), + ) { + options.forEachIndexed { index, label -> + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Radio( + selected = selected == index, + onClick = { selected = index }, + ) + Text(label, style = MaterialTheme.typography.bodyMedium) } } + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Radio(selected = true, onClick = {}, enabled = false) + Text("Disabled", style = MaterialTheme.typography.bodyMedium) + } } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Slider.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Slider.kt index 63de1990..2d059068 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Slider.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Slider.kt @@ -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() + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/TextField.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/TextField.kt index 0c016aba..0309f2a4 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/TextField.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/TextField.kt @@ -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), - ) { - 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", - ) - }, - ) - } +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( + 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 = {}, + 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( + text = text, + modifier = Modifier.padding(bottom = 6.dp), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + ) +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Toggle.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Toggle.kt index 446a3fe9..ef3ddb83 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Toggle.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Toggle.kt @@ -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), - ) { - Column( - verticalArrangement = Arrangement.spacedBy(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Toggle(checked = true, onCheckedChange = {}) - Toggle(checked = false, onCheckedChange = {}) - Toggle(checked = true, onCheckedChange = null) - Toggle(checked = false, onCheckedChange = null, enabled = false) - } - } + BaseUIPreview { + var scrobbling by remember { mutableStateOf(true) } + var offlineMode by remember { mutableStateOf(false) } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + 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, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Utils.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Utils.kt index fc8ce272..19e986eb 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Utils.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Utils.kt @@ -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), + ) + } + } +}