diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 01b95038..2b2e1674 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -246,7 +246,7 @@ Spotube Music, without the strings. An open-source player for a world of music.\nYours to explore. Yours to make your own. - Keep music free. + Keep this project alive. Spotube is built by people, not a subscription business. If it becomes part of your everyday life, a little support helps us give it the care it deserves.\n\nThank you for making room for independent software. Start with a catalog plugin to find songs and playlists. Choose an audio plugin to play them. Lyrics and listening history are optional. Discover a plugin below, review its permissions, install it, then select it here. Discover plugins @@ -255,12 +255,21 @@ More plugins No new plugins found. You can also install one from a link or a .smplug file above. Plugin discovery is unavailable right now. Try again, or install from a link or file. - A familiar player. A different kind of freedom. - Browse through your chosen catalog. Press play and your audio plugin finds the stream.\n\nYour device handles playback and caching. Add synced lyrics, save tracks for later, or listen together with Group Jam. - DEMONSTRATION - PAUSED - Pause demonstration - Play demonstration + Welcome + Support + Plugins + Guide + + How to use Spotube + Four quick steps and you’re listening. + Sign in + Connect your Spotify account, or pick your own catalog plugin. + Search and play + Find any song, album, or artist, then press play. + Listen offline + Download tracks to keep playing without a connection. + Follow the lyrics + Open synced lyrics from the player while a track plays. Your music. Your sources. Your rules. diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/misc/Shimmer.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/misc/Shimmer.kt index 9cbd742f..39795062 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/misc/Shimmer.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/misc/Shimmer.kt @@ -107,9 +107,12 @@ fun TextWithShimmer( minLines: Int = 1, onTextLayout: ((TextLayoutResult) -> Unit)? = null, style: TextStyle = LocalTextStyle.current) { + // While a skeleton tree is loading, the placeholder glyphs must not be readable: + // only the shimmer bars (from `shimmerApply`) should show through. + val isLoadingSkeleton = LocalSkeletonLoading.current Text( text = text, - color = color, + color = if (isLoadingSkeleton) Color.Transparent else color, modifier = Modifier.shimmerApply().then(modifier), autoSize = autoSize, fontSize = fontSize, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt index a578caf8..2942c39a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt @@ -26,6 +26,9 @@ import org.spotube.millennium.composables.MillenniumIconButton import org.spotube.millennium.composables.MillenniumIconButtonStyle import org.spotube.millennium.composables.MillenniumIndeterminateProgress import org.spotube.millennium.composables.MillenniumSeparator +import org.spotube.millennium.composables.MillenniumSegmentedButton +import org.spotube.millennium.composables.MillenniumSegmentedButtonDefaults +import org.spotube.millennium.composables.MillenniumSegmentedButtonRow import org.spotube.millennium.composables.MillenniumTextField import org.spotube.millennium.theme.MillenniumTheme import org.spotube.millennium.theme.millenniumPanelBrush @@ -285,29 +288,19 @@ internal fun PluginScreenContent( } } item { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.padding(bottom = 12.dp) - ) { - MillenniumButton( + MillenniumSegmentedButtonRow( + modifier = Modifier.padding(bottom = 12.dp)) { + MillenniumSegmentedButton( + selected = data.onboardingDiscover, onClick = { viewModel.showOnboardingDiscovery(true) }, - style = MillenniumButtonStyle.Secondary) { - Text( - stringResource(Res.string.welcome_plugins_discover), - fontWeight = if (data.onboardingDiscover) FontWeight.Bold else FontWeight.Normal - ) + shape = MillenniumSegmentedButtonDefaults.itemShape(0, 2)) { + Text(stringResource(Res.string.welcome_plugins_discover)) } - MillenniumButton( - onClick = { - viewModel.showOnboardingDiscovery( - false - ) - }, - style = MillenniumButtonStyle.Secondary) { - Text( - stringResource(Res.string.welcome_plugins_sources), - fontWeight = if (!data.onboardingDiscover) FontWeight.Bold else FontWeight.Normal - ) + MillenniumSegmentedButton( + selected = !data.onboardingDiscover, + onClick = { viewModel.showOnboardingDiscovery(false) }, + shape = MillenniumSegmentedButtonDefaults.itemShape(1, 2)) { + Text(stringResource(Res.string.welcome_plugins_sources)) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeContent.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeContent.kt index 76e07713..bf36e128 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeContent.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeContent.kt @@ -32,6 +32,10 @@ import spotube.composeapp.generated.resources.welcome_donate_patreon_title import spotube.composeapp.generated.resources.welcome_get_started import spotube.composeapp.generated.resources.welcome_lets_go import spotube.composeapp.generated.resources.welcome_next +import spotube.composeapp.generated.resources.welcome_step_guide +import spotube.composeapp.generated.resources.welcome_step_plugins +import spotube.composeapp.generated.resources.welcome_step_support +import spotube.composeapp.generated.resources.welcome_step_welcome /** Order matches the 4 onboarding pages. */ internal val welcomePrimaryLabels: List = listOf( @@ -40,6 +44,13 @@ internal val welcomePrimaryLabels: List = listOf( Res.string.welcome_next, Res.string.welcome_get_started) +/** Short labels for the step switcher. Order matches the 4 onboarding pages. */ +internal val welcomeStepLabels: List = listOf( + Res.string.welcome_step_welcome, + Res.string.welcome_step_support, + Res.string.welcome_step_plugins, + Res.string.welcome_step_guide) + internal val welcomeDonations: List = listOf( DonationOption( id = "patreon", diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeModels.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeModels.kt index e5a293b6..c41925d5 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeModels.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeModels.kt @@ -33,5 +33,4 @@ data class WelcomeUiState( val pageCount: Int, val isLastPage: Boolean, val primaryLabel: StringResource, - val donations: List, - val demoPlaying: Boolean = true) + val donations: List) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeScreen.kt index 8eaa84c0..0bb19540 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeScreen.kt @@ -20,28 +20,22 @@ package dev.krtirtho.spotube.modules.welcome import org.spotube.millennium.composables.MillenniumButton import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumCard import org.spotube.millennium.composables.MillenniumIcon -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.animation.togetherWith -import androidx.compose.foundation.background +import org.spotube.millennium.composables.MillenniumSeparator +import org.spotube.millennium.composables.MillenniumTabGroup 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.fillMaxHeight 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.widthIn -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape import dev.krtirtho.spotube.core.ui.theming.AppTheme import com.composeunstyled.Text @@ -49,7 +43,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -63,13 +56,15 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxCheckCircle import dev.krtirtho.spotube.resources.iconsax.IconsaxNext import org.jetbrains.compose.resources.stringResource import spotube.composeapp.generated.resources.Res -import spotube.composeapp.generated.resources.welcome_back import spotube.composeapp.generated.resources.welcome_skip /** - * First-run Welcome/Onboarding screen. It is a route ([dev.krtirtho.spotube.core.navigation.Routes.Welcome]) - * rendered inside the app shell. Uses the application's base controls and keeps plugin setup - * in-place, with a neutral palette independent of the user's chosen accent. + * First-run Welcome/Onboarding screen. It is a route + * ([dev.krtirtho.spotube.core.navigation.Routes.Welcome]) rendered inside the app shell. + * + * The steps are exposed through a Millennium tab group rather than ad-hoc dots or a footer + * walk-through, so the screen reads as one deliberate panel on every window size. + * Each step owns its content and the footer only carries the forward action. */ @Composable fun WelcomeScreen(viewModel: WelcomeViewModel) { @@ -96,99 +91,31 @@ fun WelcomeScreen(viewModel: WelcomeViewModel) { } } }) - Box(modifier = Modifier.weight(1f).fillMaxWidth()) { - Column( + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 16.dp), + contentAlignment = Alignment.Center) { + MillenniumCard( modifier = Modifier - .fillMaxSize() - .padding(horizontal = 16.dp, vertical = 8.dp), - horizontalAlignment = Alignment.CenterHorizontally) { - // Central stage (animated page content) - Box( - modifier = Modifier - .weight(1f) - .widthIn(max = 1280.dp) - .fillMaxWidth(), - contentAlignment = Alignment.Center) { - AnimatedContent( - modifier = Modifier.fillMaxSize(), - targetState = state.currentPageIndex, - transitionSpec = { - if (targetState > initialState) { - (slideInHorizontally { it / 4 } + fadeIn()) togetherWith - (slideOutHorizontally { -it / 4 } + fadeOut()) - } else { - (slideInHorizontally { -it / 4 } + fadeIn()) togetherWith - (slideOutHorizontally { it / 4 } + fadeOut()) - } - }, - label = "welcome_page") { index -> - when (index) { - 0 -> BrandingPage() - - 1 -> DonationPage( - donations = state.donations, - onDonate = viewModel::openDonation) - - 2 -> PluginsPage() - - else -> HowItWorksPage( - isPlaying = state.demoPlaying, - onTogglePlayback = viewModel::toggleDemo - ) - } - } - } - - Spacer(Modifier.height(10.dp)) - - // Bottom precision navigation bar: Back | Page Dots | Action Button - Row( - modifier = Modifier - .widthIn(max = 680.dp) - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically) { - // Left: Back button - if (state.currentPageIndex > 0) { - MillenniumButton( - onClick = viewModel::previousPage, - style = MillenniumButtonStyle.Secondary) { - Text( - text = stringResource(Res.string.welcome_back), - color = AppTheme.colors.onSurface) - } - } else { - Spacer(Modifier.width(76.dp)) - } - - // Center: Precision page dots - Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { - PageIndicator( - pageCount = state.pageCount, - currentPageIndex = state.currentPageIndex) - } - - // Right: Primary Next / Let's Go action - MillenniumButton( - onClick = viewModel::onPrimary, - style = MillenniumButtonStyle.Secondary) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp)) { - Text( - text = stringResource(state.primaryLabel), - fontWeight = FontWeight.SemiBold) - MillenniumIcon( - imageVector = if (state.isLastPage) { - Iconsax.IconsaxCheckCircle - } else { - Iconsax.IconsaxNext - }, - contentDescription = null, - modifier = Modifier.size(16.dp)) - } - } + .widthIn(max = 1040.dp) + .fillMaxWidth() + .fillMaxHeight(), + contentPadding = PaddingValues(0.dp)) { + Column(Modifier.fillMaxSize()) { + WelcomeStepSwitcher( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 12.dp), + state = state, + viewModel = viewModel) + MillenniumSeparator() + WelcomeActionBar( + state = state, + viewModel = viewModel) } } } @@ -198,31 +125,59 @@ fun WelcomeScreen(viewModel: WelcomeViewModel) { } @Composable -private fun PageIndicator( - pageCount: Int, - currentPageIndex: Int, +private fun WelcomeStepSwitcher( + state: WelcomeUiState, + viewModel: WelcomeViewModel, modifier: Modifier = Modifier) { - Row( - modifier = modifier - .padding(horizontal = 12.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(7.dp), - verticalAlignment = Alignment.CenterVertically) { - repeat(pageCount) { index -> - val selected = index == currentPageIndex - val width by animateDpAsState( - targetValue = if (selected) 20.dp else 6.dp, - label = "welcome_page_indicator") - Box( - modifier = Modifier - .size(width = width, height = 6.dp) - .clip(CircleShape) - .background( - if (selected) { - AppTheme.colors.onSurface - } else { - AppTheme.colors.outlineVariant - } - )) + val labels = welcomeStepLabels.map { stringResource(it) } + MillenniumTabGroup( + selectedTab = state.currentPageIndex, + onSelectedTabChange = viewModel::goToPage, + tabs = labels.indices.toList(), + modifier = modifier, + tabLabel = { labels[it] }) { index -> + when (index) { + 0 -> BrandingPage() + + 1 -> DonationPage( + donations = state.donations, + onDonate = viewModel::openDonation) + + 2 -> PluginsPage() + + else -> HowItWorksPage() + } + } +} + +@Composable +private fun WelcomeActionBar( + state: WelcomeUiState, + viewModel: WelcomeViewModel) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically) { + Spacer(Modifier.weight(1f)) + MillenniumButton( + onClick = viewModel::onPrimary, + style = MillenniumButtonStyle.Secondary) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + text = stringResource(state.primaryLabel), + fontWeight = FontWeight.SemiBold) + MillenniumIcon( + imageVector = if (state.isLastPage) { + Iconsax.IconsaxCheckCircle + } else { + Iconsax.IconsaxNext + }, + contentDescription = null, + modifier = Modifier.size(16.dp)) + } } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeViewModel.kt index 296c2d11..a2868fb8 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeViewModel.kt @@ -22,7 +22,7 @@ import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -30,18 +30,16 @@ import kotlinx.coroutines.launch class WelcomeViewModel( private val repository: WelcomeRepository) : ViewModel() { private val currentPageIndex = MutableStateFlow(0) - private val demoPlaying = MutableStateFlow(true) val uiState: StateFlow = - combine(currentPageIndex, demoPlaying) { index, playing -> + currentPageIndex.map { index -> val pageCount = welcomePrimaryLabels.size WelcomeUiState( currentPageIndex = index, pageCount = pageCount, isLastPage = index == pageCount - 1, primaryLabel = welcomePrimaryLabels[index.coerceIn(0, pageCount - 1)], - donations = welcomeDonations, - demoPlaying = playing) + donations = welcomeDonations) } .stateIn( scope = viewModelScope, @@ -53,16 +51,16 @@ class WelcomeViewModel( primaryLabel = welcomePrimaryLabels.first(), donations = welcomeDonations)) - fun nextPage() { - currentPageIndex.update { (it + 1).coerceAtMost(welcomePrimaryLabels.lastIndex) } - } - - fun toggleDemo() { - demoPlaying.update { !it } + fun goToPage(index: Int) { + currentPageIndex.update { index.coerceIn(0, welcomePrimaryLabels.lastIndex) } } fun onPrimary() { - if (currentPageIndex.value == welcomePrimaryLabels.lastIndex) completeOnboarding() else nextPage() + if (currentPageIndex.value == welcomePrimaryLabels.lastIndex) { + completeOnboarding() + } else { + goToPage(currentPageIndex.value + 1) + } } fun openDonation(url: String) { @@ -71,10 +69,6 @@ class WelcomeViewModel( runCatching { dev.krtirtho.spotube.openUrlInBrowser(url) } } - fun previousPage() { - currentPageIndex.update { (it - 1).coerceAtLeast(0) } - } - fun completeOnboarding() { viewModelScope.launch { repository.completeOnboarding() diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeVisuals.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeVisuals.kt index cf815c99..5b1d99f4 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeVisuals.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeVisuals.kt @@ -33,13 +33,17 @@ import org.jetbrains.compose.resources.stringResource import org.spotube.millennium.theme.MillenniumTheme import spotube.composeapp.generated.resources.* -/** Installs the Millennium theme for the onboarding pages. */ +/** + * Reapplies the Millennium theme for the onboarding pages using the app's active configuration, + * so onboarding matches the palette and appearance used by the rest of the app. + */ @Composable fun WelcomeTheme(content: @Composable () -> Unit) { val dark = AppTheme.colors.surface.luminance() < 0.5f - MillenniumTheme(dark = dark) { - content() - } + MillenniumTheme( + dark = dark, + config = MillenniumTheme.config, + content = content) } @Composable diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/HowItWorksPage.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/HowItWorksPage.kt index c88cff74..f9453f16 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/HowItWorksPage.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/HowItWorksPage.kt @@ -1,187 +1,98 @@ package dev.krtirtho.spotube.modules.welcome.pages - - - -import androidx.compose.foundation.layout.Box -import com.composeunstyled.currentWindowWidthBreakpoint -import org.spotube.millennium.adaptive.WindowBreakpoints -import org.spotube.millennium.composables.MillenniumCard import org.spotube.millennium.composables.MillenniumIcon -import org.spotube.millennium.composables.MillenniumIconButton -import org.spotube.millennium.composables.MillenniumIconButtonStyle -import androidx.compose.animation.core.* -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* +import org.spotube.millennium.composables.MillenniumListTile +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import dev.krtirtho.spotube.core.ui.theming.AppTheme import com.composeunstyled.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.rotate -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp -import dev.krtirtho.spotube.resources.iconsax.* +import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.krtirtho.spotube.resources.iconsax.IconsaxDocumentDownload +import dev.krtirtho.spotube.resources.iconsax.IconsaxMusic +import dev.krtirtho.spotube.resources.iconsax.IconsaxSearch +import dev.krtirtho.spotube.resources.iconsax.User +import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource -import spotube.composeapp.generated.resources.* -import kotlin.math.PI -import kotlin.math.sin +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.welcome_guide_lyrics_desc +import spotube.composeapp.generated.resources.welcome_guide_lyrics_title +import spotube.composeapp.generated.resources.welcome_guide_offline_desc +import spotube.composeapp.generated.resources.welcome_guide_offline_title +import spotube.composeapp.generated.resources.welcome_guide_search_desc +import spotube.composeapp.generated.resources.welcome_guide_search_title +import spotube.composeapp.generated.resources.welcome_guide_signin_desc +import spotube.composeapp.generated.resources.welcome_guide_signin_title +import spotube.composeapp.generated.resources.welcome_guide_subtitle +import spotube.composeapp.generated.resources.welcome_guide_title +/** + * The onboarding guide: a short, concrete answer to "how do I actually use this app?". + * No marketing prose — just the four things a new user needs to do. + */ @Composable -fun HowItWorksPage( - isPlaying: Boolean, - onTogglePlayback: () -> Unit, - modifier: Modifier = Modifier -) { - Box(modifier.fillMaxSize()) { - val wide = currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Expanded - if (wide) { - Row( - Modifier.fillMaxSize().padding(40.dp), - horizontalArrangement = Arrangement.spacedBy(48.dp), - verticalAlignment = Alignment.CenterVertically) { - PlayerDemonstration(isPlaying, onTogglePlayback, Modifier.weight(1f)) - ListeningIntroduction(Modifier.weight(1f)) - } - } else { - Column( - Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(24.dp), - verticalArrangement = Arrangement.spacedBy(32.dp, Alignment.CenterVertically)) { - ListeningIntroduction() - PlayerDemonstration(isPlaying, onTogglePlayback) - } +fun HowItWorksPage(modifier: Modifier = Modifier) { + Column( + modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(28.dp), + verticalArrangement = Arrangement.spacedBy(20.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + stringResource(Res.string.welcome_guide_title), + style = AppTheme.typography.headlineMedium.copy(fontFamily = FontFamily.Serif)) + Text( + stringResource(Res.string.welcome_guide_subtitle), + style = AppTheme.typography.bodyLarge, + color = AppTheme.colors.onSurfaceVariant) + } + + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + WelcomeGuideStep( + icon = Iconsax.User, + title = Res.string.welcome_guide_signin_title, + description = Res.string.welcome_guide_signin_desc) + WelcomeGuideStep( + icon = Iconsax.IconsaxSearch, + title = Res.string.welcome_guide_search_title, + description = Res.string.welcome_guide_search_desc) + WelcomeGuideStep( + icon = Iconsax.IconsaxDocumentDownload, + title = Res.string.welcome_guide_offline_title, + description = Res.string.welcome_guide_offline_desc) + WelcomeGuideStep( + icon = Iconsax.IconsaxMusic, + title = Res.string.welcome_guide_lyrics_title, + description = Res.string.welcome_guide_lyrics_desc) } } } @Composable -private fun ListeningIntroduction(modifier: Modifier = Modifier) { - Column(modifier, verticalArrangement = Arrangement.spacedBy(20.dp)) { - Text( - stringResource(Res.string.welcome_how_quiet_title), - style = AppTheme.typography.headlineLarge.copy(fontFamily = FontFamily.Serif)) - Text( - stringResource(Res.string.welcome_how_quiet_description), - style = AppTheme.typography.bodyLarge, - color = AppTheme.colors.onSurfaceVariant) - } -} - -/** Silent, illustrative player. The ViewModel owns the play/pause interaction. */ -@Composable -private fun PlayerDemonstration( - isPlaying: Boolean, - onTogglePlayback: () -> Unit, - modifier: Modifier = Modifier +private fun WelcomeGuideStep( + icon: ImageVector, + title: StringResource, + description: StringResource, ) { - val motion = rememberInfiniteTransition(label = "welcome_record") - val rotation by motion.animateFloat( - 0f, 360f, - infiniteRepeatable(tween(12000, easing = LinearEasing)), label = "record_rotation" - ) - val phase by motion.animateFloat( - 0f, (2 * PI).toFloat(), - infiniteRepeatable(tween(2400, easing = LinearEasing)), label = "audio_meter" - ) - val scheme = AppTheme.colors - - MillenniumCard(modifier = modifier.fillMaxWidth()) { - Column(Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(20.dp)) { - Text( - stringResource(if (isPlaying) Res.string.welcome_demo_streaming else Res.string.welcome_demo_paused), - style = AppTheme.typography.labelSmall, - color = scheme.onSurfaceVariant) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(20.dp) - ) { - Canvas(Modifier.size(104.dp).rotate(if (isPlaying) rotation else 0f)) { - val radius = size.minDimension / 2f - drawCircle(Color(0xFF171717)) - for (groove in 1..8) { - drawCircle( - Color(0xFF343434), - radius * (0.4f + groove * 0.065f), - style = Stroke(1f) - ) - } - drawCircle(Color(0xFF334A6B), radius * 0.32f) - drawCircle(Color(0xFFE1DDD5), radius * 0.045f) - // An off-centre label mark makes the turntable's motion visible. - drawCircle( - Color(0xFFD6CEC0), - radius * 0.025f, - Offset(center.x, center.y - radius * 0.2f) - ) - } - Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) { - Text( - stringResource(Res.string.welcome_how_mockup_title), - style = AppTheme.typography.titleLarge - ) - Text( - stringResource(Res.string.welcome_how_mockup_artist), - style = AppTheme.typography.bodySmall, color = scheme.onSurfaceVariant - ) - Canvas(Modifier.fillMaxWidth().height(32.dp)) { - val count = 18 - val barWidth = size.width / (count * 1.7f) - repeat(count) { index -> - val wave = if (isPlaying) (sin(phase + index * 0.6f) + 1f) / 2f else 0f - val height = size.height * (0.12f + wave * 0.88f) - drawRect( - scheme.primary, - Offset(index * size.width / count, size.height - height), - androidx.compose.ui.geometry.Size(barWidth, height) - ) - } - } - } - } - Text( - stringResource(Res.string.welcome_how_mockup_lyric), - style = AppTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Serif), - color = scheme.onSurfaceVariant - ) - Row( - Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically - ) { - MillenniumIcon( - Iconsax.IconsaxPrevious, - null, - Modifier.size(18.dp), - tint = scheme.onSurfaceVariant - ) - Spacer(Modifier.width(24.dp)) - MillenniumIconButton( - onClick = onTogglePlayback, - modifier = Modifier.size(48.dp), - style = MillenniumIconButtonStyle.Quiet) { - MillenniumIcon( - if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, - stringResource(if (isPlaying) Res.string.welcome_demo_pause_action else Res.string.welcome_demo_play_action), - Modifier.size(24.dp) - ) - } - Spacer(Modifier.width(24.dp)) - MillenniumIcon( - Iconsax.IconsaxNext, - null, - Modifier.size(18.dp), - tint = scheme.onSurfaceVariant - ) - } - } - } + MillenniumListTile( + leading = { + MillenniumIcon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = AppTheme.colors.primary) + }, + title = { Text(stringResource(title)) }, + subtitle = { Text(stringResource(description)) }) } diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.jvm.kt b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.jvm.kt index e5012afe..34f67902 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.jvm.kt @@ -25,10 +25,13 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.ui.graphics.SolidColor import androidx.compose.foundation.window.WindowDraggableArea import org.spotube.millennium.composables.MillenniumTopAppBar @@ -53,6 +56,8 @@ import dev.nucleusframework.window.WindowDoubleClickAction import dev.nucleusframework.window.windowDragArea import org.koin.compose.koinInject +private val WindowControlsReservedWidth = 132.dp + @Composable actual fun ApplicationMainBar( title: @Composable (() -> Unit), @@ -68,6 +73,9 @@ actual fun ApplicationMainBar( title = title, actions = { actions() + // Reserve room for the platform window controls instead of shrinking the + // whole bar, so it spans the full width with no seam under the controls. + Spacer(Modifier.width(WindowControlsReservedWidth)) }, navigationIcon = { if (backButton) { @@ -75,7 +83,9 @@ actual fun ApplicationMainBar( } }, subtitle = subtitle, - modifier = Modifier.padding(end = 125.dp) // To avoid overlap with window buttons + background = if (transparent) SolidColor(Color.Transparent) else null, + separatorColor = if (transparent) Color.Transparent else null, + modifier = Modifier.fillMaxWidth() ) LocalWindowScope.current.WindowControls( modifier = Modifier.align(Alignment.TopEnd).heightIn(max = 48.dp),