feat(onboarding): enhance welcome screen with structured guide steps and improved layout

This commit is contained in:
Kingkor Roy Tirtho 2026-10-13 13:03:27 +06:00
parent caad76ba95
commit 76518a01a1
10 changed files with 237 additions and 348 deletions

View File

@ -246,7 +246,7 @@
<string name="welcome_app_name">Spotube</string>
<string name="welcome_brand_freedom">Music, without the strings.</string>
<string name="welcome_brand_quiet_description">An open-source player for a world of music.\nYours to explore. Yours to make your own.</string>
<string name="welcome_donate_quiet_title">Keep music free.</string>
<string name="welcome_donate_quiet_title">Keep this project alive.</string>
<string name="welcome_donate_letter">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.</string>
<string name="welcome_plugins_setup_guide">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.</string>
<string name="welcome_plugins_discover">Discover plugins</string>
@ -255,12 +255,21 @@
<string name="welcome_plugins_more">More plugins</string>
<string name="welcome_plugins_discovery_empty">No new plugins found. You can also install one from a link or a .smplug file above.</string>
<string name="welcome_plugins_discovery_error">Plugin discovery is unavailable right now. Try again, or install from a link or file.</string>
<string name="welcome_how_quiet_title">A familiar player. A different kind of freedom.</string>
<string name="welcome_how_quiet_description">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.</string>
<string name="welcome_demo_streaming">DEMONSTRATION</string>
<string name="welcome_demo_paused">PAUSED</string>
<string name="welcome_demo_pause_action">Pause demonstration</string>
<string name="welcome_demo_play_action">Play demonstration</string>
<string name="welcome_step_welcome">Welcome</string>
<string name="welcome_step_support">Support</string>
<string name="welcome_step_plugins">Plugins</string>
<string name="welcome_step_guide">Guide</string>
<string name="welcome_guide_title">How to use Spotube</string>
<string name="welcome_guide_subtitle">Four quick steps and you’re listening.</string>
<string name="welcome_guide_signin_title">Sign in</string>
<string name="welcome_guide_signin_desc">Connect your Spotify account, or pick your own catalog plugin.</string>
<string name="welcome_guide_search_title">Search and play</string>
<string name="welcome_guide_search_desc">Find any song, album, or artist, then press play.</string>
<string name="welcome_guide_offline_title">Listen offline</string>
<string name="welcome_guide_offline_desc">Download tracks to keep playing without a connection.</string>
<string name="welcome_guide_lyrics_title">Follow the lyrics</string>
<string name="welcome_guide_lyrics_desc">Open synced lyrics from the player while a track plays.</string>
<!-- Page 1: branding -->
<string name="welcome_brand_tagline">Your music. Your sources. Your rules.</string>

View File

@ -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,

View File

@ -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))
}
}
}

View File

@ -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<StringResource> = listOf(
@ -40,6 +44,13 @@ internal val welcomePrimaryLabels: List<StringResource> = 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<StringResource> = 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<DonationOption> = listOf(
DonationOption(
id = "patreon",

View File

@ -33,5 +33,4 @@ data class WelcomeUiState(
val pageCount: Int,
val isLastPage: Boolean,
val primaryLabel: StringResource,
val donations: List<DonationOption>,
val demoPlaying: Boolean = true)
val donations: List<DonationOption>)

View File

@ -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,33 +91,51 @@ fun WelcomeScreen(viewModel: WelcomeViewModel) {
}
}
})
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
Column(
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(),
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 16.dp),
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())
MillenniumCard(
modifier = Modifier
.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)
}
},
label = "welcome_page") { index ->
}
}
}
}
}
}
@Composable
private fun WelcomeStepSwitcher(
state: WelcomeUiState,
viewModel: WelcomeViewModel,
modifier: Modifier = Modifier) {
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()
@ -132,44 +145,21 @@ fun WelcomeScreen(viewModel: WelcomeViewModel) {
2 -> PluginsPage()
else -> HowItWorksPage(
isPlaying = state.demoPlaying,
onTogglePlayback = viewModel::toggleDemo
)
else -> HowItWorksPage()
}
}
}
Spacer(Modifier.height(10.dp))
// Bottom precision navigation bar: Back | Page Dots | Action Button
@Composable
private fun WelcomeActionBar(
state: WelcomeUiState,
viewModel: WelcomeViewModel) {
Row(
modifier = Modifier
.widthIn(max = 680.dp)
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 6.dp),
.padding(horizontal = 12.dp, vertical = 12.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
Spacer(Modifier.weight(1f))
MillenniumButton(
onClick = viewModel::onPrimary,
style = MillenniumButtonStyle.Secondary) {
@ -191,38 +181,3 @@ fun WelcomeScreen(viewModel: WelcomeViewModel) {
}
}
}
}
}
}
}
}
@Composable
private fun PageIndicator(
pageCount: Int,
currentPageIndex: Int,
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
}
))
}
}
}

View File

@ -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<WelcomeUiState> =
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()

View File

@ -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

View File

@ -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 {
fun HowItWorksPage(modifier: Modifier = Modifier) {
Column(
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(24.dp),
verticalArrangement = Arrangement.spacedBy(32.dp, Alignment.CenterVertically)) {
ListeningIntroduction()
PlayerDemonstration(isPlaying, onTogglePlayback)
}
}
}
}
@Composable
private fun ListeningIntroduction(modifier: Modifier = Modifier) {
Column(modifier, verticalArrangement = Arrangement.spacedBy(20.dp)) {
modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(28.dp),
verticalArrangement = Arrangement.spacedBy(20.dp)) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
stringResource(Res.string.welcome_how_quiet_title),
style = AppTheme.typography.headlineLarge.copy(fontFamily = FontFamily.Serif))
stringResource(Res.string.welcome_guide_title),
style = AppTheme.typography.headlineMedium.copy(fontFamily = FontFamily.Serif))
Text(
stringResource(Res.string.welcome_how_quiet_description),
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)
}
}
}
/** Silent, illustrative player. The ViewModel owns the play/pause interaction. */
@Composable
private fun PlayerDemonstration(
isPlaying: Boolean,
onTogglePlayback: () -> Unit,
modifier: Modifier = Modifier
) {
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
private fun WelcomeGuideStep(
icon: ImageVector,
title: StringResource,
description: StringResource,
) {
MillenniumListTile(
leading = {
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
)
}
}
}
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = AppTheme.colors.primary)
},
title = { Text(stringResource(title)) },
subtitle = { Text(stringResource(description)) })
}

View File

@ -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),