mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
feat(welcome): implement onboarding flow with multiple pages and donation options
This commit is contained in:
parent
fab60107e3
commit
71d7649db6
Binary file not shown.
|
After Width: | Height: | Size: 144 KiB |
@ -202,8 +202,98 @@
|
||||
<string name="update_action_ignore">Ignore</string>
|
||||
<string name="update_action_update">Update</string>
|
||||
<string name="app_update_dialog_title">Spotube %1$s is available</string>
|
||||
<string name="app_update_dialog_no_notes">See what\'s new in this release on the Spotube downloads page.</string>
|
||||
<string name="app_update_dialog_no_notes">See what’s new in this release on the Spotube downloads page.</string>
|
||||
<string name="plugin_update_dialog_title">%1$s %2$s is available</string>
|
||||
<string name="plugin_update_dialog_current_version">Currently installed: %1$s</string>
|
||||
<string name="plugin_update_dialog_no_notes">See what\'s new in this update from the plugin\'s page.</string>
|
||||
<string name="plugin_update_dialog_no_notes">See what’s new in this update from the plugin’s page.</string>
|
||||
|
||||
<string name="welcome_skip">Skip</string>
|
||||
<string name="welcome_next">Next</string>
|
||||
<string name="welcome_back">Back</string>
|
||||
<string name="welcome_get_started">Get Started</string>
|
||||
<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_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>
|
||||
<string name="welcome_plugins_sources">Your sources</string>
|
||||
<string name="welcome_plugins_retry">Try again</string>
|
||||
<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>
|
||||
|
||||
<!-- Page 1: branding -->
|
||||
<string name="welcome_brand_tagline">Your music. Your sources. Your rules.</string>
|
||||
<string name="welcome_brand_subtitle">A free, open-source music player built by a community that simply loves music.</string>
|
||||
<string name="welcome_brand_chip_open_source">Open source</string>
|
||||
<string name="welcome_brand_chip_no_ads">No ads</string>
|
||||
<string name="welcome_brand_chip_no_tracking">No telemetry</string>
|
||||
<string name="welcome_brand_chip_plugin_powered">Plugin powered</string>
|
||||
<string name="welcome_lets_go">Let’s Go</string>
|
||||
|
||||
<!-- Page 2: donations -->
|
||||
<string name="welcome_donate_eyebrow">Supporters keep us going</string>
|
||||
<string name="welcome_donate_title">Built with passion. Kept alive by you.</string>
|
||||
<string name="welcome_donate_message">Spotube is 100% free, libre, and open source — no ads, no subscriptions, no tracking. Every feature exists because people like you chose to help keep the code flowing.</string>
|
||||
<string name="welcome_donate_patreon_title">Patreon</string>
|
||||
<string name="welcome_donate_patreon_tag">Monthly Sponsor</string>
|
||||
<string name="welcome_donate_patreon_subtitle">Support recurring development and infrastructure</string>
|
||||
<string name="welcome_donate_bmac_title">Buy Me a Coffee</string>
|
||||
<string name="welcome_donate_bmac_tag">One-Time Coffee</string>
|
||||
<string name="welcome_donate_bmac_subtitle">Fuel a late-night coding session with a quick tip</string>
|
||||
<string name="welcome_donate_opencollective_title">Open Collective</string>
|
||||
<string name="welcome_donate_opencollective_tag">Community Fund</string>
|
||||
<string name="welcome_donate_opencollective_subtitle">Transparent, community-governed financial backing</string>
|
||||
<string name="welcome_donate_thanks">Thank you from the bottom of our hearts for believing in open music.</string>
|
||||
<string name="welcome_donate_optional">Supporting is completely optional — Spotube stays free either way.</string>
|
||||
<string name="welcome_donate_action">Sponsor</string>
|
||||
<string name="welcome_donate_open_content_description">Open %1$s</string>
|
||||
|
||||
<!-- Page 3: plugins -->
|
||||
<string name="welcome_plugins_eyebrow">Modular Architecture</string>
|
||||
<string name="welcome_plugins_title">Choose where your music comes from.</string>
|
||||
<string name="welcome_plugins_subtitle">Spotube separates catalog discovery, audio streaming, and synced lyrics. Configure your engines below or switch them anytime.</string>
|
||||
<string name="welcome_plugins_metadata_title">Metadata Engine</string>
|
||||
<string name="welcome_plugins_metadata_description">Powers music discovery, search, track listings, and playlists.</string>
|
||||
<string name="welcome_plugins_audio_title">Audio Engine</string>
|
||||
<string name="welcome_plugins_audio_description">Fetches and decodes the audio streams that play.</string>
|
||||
<string name="welcome_plugins_lyrics_title">Lyrics Engine</string>
|
||||
<string name="welcome_plugins_lyrics_description">Streams time-synced karaoke lyrics in real time.</string>
|
||||
<string name="welcome_plugins_scrobble_title">Scrobble Engine</string>
|
||||
<string name="welcome_plugins_scrobble_description">Records listening history to Last.fm or ListenBrainz.</string>
|
||||
<string name="welcome_plugins_badge_active">Active</string>
|
||||
<string name="welcome_plugins_badge_builtin">Built-in</string>
|
||||
<string name="welcome_plugins_badge_ready">Ready</string>
|
||||
<string name="welcome_plugins_badge_optional">Optional</string>
|
||||
<string name="welcome_plugins_none_selected">Select an engine</string>
|
||||
<string name="welcome_plugins_installed_section">Installed Engines</string>
|
||||
<string name="welcome_plugins_set_default">Set as default</string>
|
||||
<string name="welcome_plugins_builtin_note">NewPipe YouTube and LRCLib Lyrics are built-in and ready to stream out of the box.</string>
|
||||
<string name="welcome_plugins_tip">Manage or add new community plugins anytime in Settings → Plugins.</string>
|
||||
|
||||
<!-- Page 4: how it works -->
|
||||
<string name="welcome_how_eyebrow">Retro-Modern Architecture</string>
|
||||
<string name="welcome_how_title">Pure Hi-Fi, zero middlemen</string>
|
||||
<string name="welcome_how_subtitle">Here’s how Spotube orchestrates decentralized playback directly on your machine.</string>
|
||||
<string name="welcome_how_status_badge">DIRECT PEER-TO-PEER • 256 KBPS OPUS</string>
|
||||
<string name="welcome_how_mockup_title">Blue hour</string>
|
||||
<string name="welcome_how_mockup_artist">Spotube · player demonstration</string>
|
||||
<string name="welcome_how_mockup_lyric">A little music for the moments in between.</string>
|
||||
<string name="welcome_how_step_search_title">1. Decentralized Discovery</string>
|
||||
<string name="welcome_how_step_search_desc">Queries go straight to your chosen catalog plugin. Spotube never profiles your taste.</string>
|
||||
<string name="welcome_how_step_stream_title">2. Direct Stream Resolution</string>
|
||||
<string name="welcome_how_step_stream_desc">Audio streams directly from content servers to your machine. No proxy hops or remote data harvesting.</string>
|
||||
<string name="welcome_how_step_playback_title">3. Local DSP & Playback</string>
|
||||
<string name="welcome_how_step_playback_desc">Decoded and cached locally with equalizer controls and optional offline download.</string>
|
||||
<string name="welcome_how_step_lyrics_title">4. Synced Lyrics & Jam</string>
|
||||
<string name="welcome_how_step_lyrics_desc">Karaoke lyrics track the playback timestamp, with LAN remote control and Group Jam.</string>
|
||||
<string name="welcome_how_footer">No middleman servers. Playback happens right on your device — private by design.</string>
|
||||
</resources>
|
||||
|
||||
@ -48,6 +48,7 @@ import dev.krtirtho.spotube.modules.update.AppUpdateService
|
||||
import dev.krtirtho.spotube.modules.update.PluginUpdateDialog
|
||||
import dev.krtirtho.spotube.modules.update.PluginUpdateService
|
||||
import dev.krtirtho.spotube.modules.webview.WebViewScreen
|
||||
import dev.krtirtho.spotube.modules.welcome.WelcomeRepository
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxCd
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive
|
||||
@ -140,10 +141,17 @@ fun App(
|
||||
val availableAppUpdate by appUpdateService.availableUpdate.collectAsStateWithLifecycle()
|
||||
val availablePluginUpdate by pluginUpdateService.availableUpdate.collectAsStateWithLifecycle()
|
||||
|
||||
val welcomeRepository: WelcomeRepository = koinInject()
|
||||
val showOnboarding by welcomeRepository.shouldShowOnboarding.collectAsStateWithLifecycle()
|
||||
|
||||
// The app always renders the shell + NavDisplay; onboarding is just the start destination.
|
||||
// When it is completed the destination flips to Home and the navigation state is rebuilt.
|
||||
val startRoute = if (showOnboarding) Routes.Welcome else Routes.Home
|
||||
|
||||
val navigationState = rememberNavigationState(
|
||||
startRoute = Routes.Home, topLevelRoutes = TOP_LEVEL_ROUTES
|
||||
startRoute = startRoute, topLevelRoutes = TOP_LEVEL_ROUTES
|
||||
)
|
||||
val navigator = remember {
|
||||
val navigator = remember(navigationState) {
|
||||
Navigator(navigationState)
|
||||
}
|
||||
|
||||
@ -151,7 +159,8 @@ fun App(
|
||||
val baseUITheme = rememberBaseUITheme()
|
||||
CompositionLocalProvider(LocalBaseUITheme provides baseUITheme) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
val currentRoute = navigationState.backStacks[navigationState.topLevelRoute]?.last()
|
||||
val currentRoute =
|
||||
navigationState.backStacks[navigationState.topLevelRoute]?.last()
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
AppShell(navigator, navigationState) {
|
||||
|
||||
@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.core
|
||||
|
||||
/**
|
||||
* Development-only switches. These are intentionally kept out of [dev.krtirtho.spotube.modules.settings.UserSettings]
|
||||
* so they can never leak into user-facing configuration.
|
||||
*/
|
||||
object DebugConfig {
|
||||
/**
|
||||
* Flip to `true` while developing to always open the Welcome/Onboarding screen, even after
|
||||
* it has been completed (so you don't have to wipe app data to see it again). It still
|
||||
* dismisses for the session when you finish or skip it.
|
||||
*
|
||||
* Keep it `false` when committing.
|
||||
*/
|
||||
const val FORCE_SHOW_ONBOARDING: Boolean = true
|
||||
}
|
||||
@ -23,10 +23,25 @@ import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import dev.krtirtho.spotube.core.paths.Paths
|
||||
import okio.FileSystem
|
||||
import okio.Path.Companion.toPath
|
||||
import okio.SYSTEM
|
||||
|
||||
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
|
||||
class Database(val paths: Paths) {
|
||||
/**
|
||||
* True when Spotube had already written its own data before this process started. Captured
|
||||
* synchronously at construction time — the earliest point, before any eager service can
|
||||
* create a fresh store — so it reliably tells a brand-new install apart from an upgrade of
|
||||
* an existing one (used to skip the first-run onboarding on upgrades).
|
||||
*/
|
||||
val isExistingInstall: Boolean = runCatching {
|
||||
val dataDirPath = paths.getApplicationDataDirPath()
|
||||
EXISTING_INSTALL_PROBE_FILES.any { fileName ->
|
||||
FileSystem.SYSTEM.exists("$dataDirPath/$fileName".toPath())
|
||||
}
|
||||
}.getOrDefault(false)
|
||||
|
||||
val settingsDataStore: DataStore<Preferences> by lazy {
|
||||
PreferenceDataStoreFactory.createWithPath(
|
||||
produceFile = { "${paths.getApplicationDataDirPath()}/spotube.preferences_pb".toPath() }
|
||||
@ -61,6 +76,19 @@ class Database(val paths: Paths) {
|
||||
produceFile = { "${paths.getApplicationDataDirPath()}/spotube_blacklist.preferences_pb".toPath() }
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Preference files written by previous launches; their presence means an upgrade. */
|
||||
val EXISTING_INSTALL_PROBE_FILES = listOf(
|
||||
"spotube.preferences_pb",
|
||||
"spotube_plugins.preferences_pb",
|
||||
"spotube_matched_tracks.preferences_pb",
|
||||
"spotube_audio_player_queue.preferences_pb",
|
||||
"spotube_local_media.preferences_pb",
|
||||
"spotube_blacklist.preferences_pb",
|
||||
"spotube_cookies.preferences_pb",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
object DatabaseKeys {
|
||||
|
||||
@ -86,6 +86,8 @@ import dev.krtirtho.spotube.modules.shell.AppShellViewModel
|
||||
import dev.krtirtho.spotube.modules.shell.PlayerOptionsViewModel
|
||||
import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContentViewModel
|
||||
import dev.krtirtho.spotube.modules.shell.player_queue.PlayerQueueContentViewModel
|
||||
import dev.krtirtho.spotube.modules.welcome.WelcomeRepository
|
||||
import dev.krtirtho.spotube.modules.welcome.WelcomeViewModel
|
||||
import org.koin.core.module.Module
|
||||
import org.koin.core.module.dsl.bind
|
||||
import org.koin.core.module.dsl.createdAtStart
|
||||
@ -141,6 +143,10 @@ val sharedModules = module {
|
||||
viewModelOf(::SettingsViewModel) { bind<SettingsProvider>() }
|
||||
viewModelOf(::JamSettingsViewModel)
|
||||
|
||||
// Welcome / Onboarding
|
||||
singleOf(::WelcomeRepository)
|
||||
viewModelOf(::WelcomeViewModel)
|
||||
|
||||
// Updates
|
||||
single { AppUpdateService(get()) } withOptions { createdAtStart() }
|
||||
single { PluginUpdateService(get(), get()) } withOptions { createdAtStart() }
|
||||
|
||||
@ -34,6 +34,7 @@ import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksScreen
|
||||
import dev.krtirtho.spotube.modules.search.SearchScreen
|
||||
import dev.krtirtho.spotube.modules.settings.SettingsScreen
|
||||
import dev.krtirtho.spotube.modules.settings.JamSettingsScreen
|
||||
import dev.krtirtho.spotube.modules.welcome.WelcomeScreen
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.annotation.KoinExperimentalAPI
|
||||
@ -91,6 +92,9 @@ sealed interface Routes : NavKey {
|
||||
|
||||
@Serializable
|
||||
data object JamSettings : Routes
|
||||
|
||||
@Serializable
|
||||
data object Welcome : Routes
|
||||
}
|
||||
|
||||
@OptIn(KoinExperimentalAPI::class)
|
||||
@ -178,4 +182,7 @@ val navigationModule = module {
|
||||
navigation<Routes.JamSettings> {
|
||||
JamSettingsScreen(viewModel = koinViewModel())
|
||||
}
|
||||
navigation<Routes.Welcome> {
|
||||
WelcomeScreen(viewModel = koinViewModel())
|
||||
}
|
||||
}
|
||||
|
||||
@ -26,7 +26,6 @@ import androidx.compose.runtime.saveable.rememberSerializable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
import androidx.compose.runtime.toMutableStateList
|
||||
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||
import androidx.navigation3.runtime.NavBackStack
|
||||
import androidx.navigation3.runtime.NavEntry
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
@ -45,7 +44,9 @@ val TOP_LEVEL_ROUTES = setOf(
|
||||
Routes.Home,
|
||||
Routes.Search,
|
||||
Routes.Library,
|
||||
Routes.Settings
|
||||
Routes.Settings,
|
||||
// Bootstrap destination: needs its own back stack when the app starts on it.
|
||||
Routes.Welcome,
|
||||
)
|
||||
|
||||
val serializersConfig = SavedStateConfiguration {
|
||||
@ -63,6 +64,7 @@ val serializersConfig = SavedStateConfiguration {
|
||||
subclass(Routes.Blacklist::class, Routes.Blacklist.serializer())
|
||||
subclass(Routes.Devices::class, Routes.Devices.serializer())
|
||||
subclass(Routes.Jam::class, Routes.Jam.serializer())
|
||||
subclass(Routes.Welcome::class, Routes.Welcome.serializer())
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -122,7 +124,6 @@ fun NavigationState.toEntries(
|
||||
val decoratedEntries = backStacks.mapValues { (_, stack) ->
|
||||
val decorators = listOf(
|
||||
rememberSaveableStateHolderNavEntryDecorator<NavKey>(),
|
||||
rememberViewModelStoreNavEntryDecorator()
|
||||
)
|
||||
rememberDecoratedNavEntries(
|
||||
backStack = stack,
|
||||
|
||||
@ -47,5 +47,6 @@ expect fun ApplicationMainBar(
|
||||
title: @Composable () -> Unit = {},
|
||||
subtitle: @Composable () -> Unit = {},
|
||||
actions: @Composable (RowScope.() -> Unit) = {},
|
||||
backButton: Boolean = true
|
||||
backButton: Boolean = true,
|
||||
transparent: Boolean = false,
|
||||
)
|
||||
@ -102,6 +102,14 @@ import okio.Path
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import spotube.composeapp.generated.resources.Res
|
||||
import spotube.composeapp.generated.resources.welcome_plugins_title
|
||||
import spotube.composeapp.generated.resources.welcome_plugins_setup_guide
|
||||
import spotube.composeapp.generated.resources.welcome_plugins_discover
|
||||
import spotube.composeapp.generated.resources.welcome_plugins_retry
|
||||
import spotube.composeapp.generated.resources.welcome_plugins_more
|
||||
import spotube.composeapp.generated.resources.welcome_plugins_discovery_empty
|
||||
import spotube.composeapp.generated.resources.welcome_plugins_discovery_error
|
||||
import spotube.composeapp.generated.resources.welcome_plugins_sources
|
||||
import spotube.composeapp.generated.resources.plugin_action_download
|
||||
import spotube.composeapp.generated.resources.plugin_action_install_from_file
|
||||
import spotube.composeapp.generated.resources.plugin_configure_title
|
||||
@ -138,6 +146,7 @@ private val VERIFIED_PLUGIN_OWNERS = setOf<String>()
|
||||
@Composable
|
||||
fun PluginScreen(
|
||||
viewModel: PluginViewModel = koinViewModel(),
|
||||
onboarding: Boolean = false,
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val shellBottomInset = LocalAppShellBottomInset.current
|
||||
@ -215,8 +224,11 @@ fun PluginScreen(
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
if (!onboarding) {
|
||||
ApplicationMainBar(title = { Text(stringResource(Res.string.plugin_screen_title)) })
|
||||
}
|
||||
},
|
||||
containerColor = if (onboarding) Color.Transparent else MaterialTheme.colorScheme.surface,
|
||||
) { innerPadding ->
|
||||
when (val data = uiState) {
|
||||
is PluginUiState.Loading -> {
|
||||
@ -244,6 +256,52 @@ fun PluginScreen(
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
if (onboarding) {
|
||||
item {
|
||||
Column(
|
||||
modifier = Modifier.padding(
|
||||
horizontal = 4.dp,
|
||||
vertical = 16.dp
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
stringResource(Res.string.welcome_plugins_title),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
stringResource(Res.string.welcome_plugins_setup_guide),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
item {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(bottom = 12.dp)
|
||||
) {
|
||||
OutlineButton(onClick = { viewModel.showOnboardingDiscovery(true) }) {
|
||||
Text(
|
||||
stringResource(Res.string.welcome_plugins_discover),
|
||||
fontWeight = if (data.onboardingDiscover) FontWeight.Bold else FontWeight.Normal
|
||||
)
|
||||
}
|
||||
OutlineButton(onClick = {
|
||||
viewModel.showOnboardingDiscovery(
|
||||
false
|
||||
)
|
||||
}) {
|
||||
Text(
|
||||
stringResource(Res.string.welcome_plugins_sources),
|
||||
fontWeight = if (!data.onboardingDiscover) FontWeight.Bold else FontWeight.Normal
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!onboarding || !data.onboardingDiscover) {
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@ -260,7 +318,7 @@ fun PluginScreen(
|
||||
PrimaryButton(onClick = { viewModel.showInstallSheet() }) {
|
||||
Icon(
|
||||
Iconsax.IconsaxAdd,
|
||||
contentDescription = "Install a plugin",
|
||||
contentDescription = stringResource(Res.string.plugin_install_section_title),
|
||||
)
|
||||
Text(stringResource(Res.string.plugin_install_section_title))
|
||||
}
|
||||
@ -289,7 +347,10 @@ fun PluginScreen(
|
||||
DefaultAbilityPluginSelector(
|
||||
selection = selection,
|
||||
onSelected = { plugin ->
|
||||
viewModel.selectPlugin(selection.ability, plugin)
|
||||
viewModel.selectPlugin(
|
||||
selection.ability,
|
||||
plugin
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
@ -300,7 +361,8 @@ fun PluginScreen(
|
||||
if (data.plugins.isEmpty()) {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 48.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.padding(vertical = 48.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
@ -349,7 +411,10 @@ fun PluginScreen(
|
||||
),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp)
|
||||
modifier = Modifier.padding(
|
||||
horizontal = 4.dp,
|
||||
vertical = 4.dp
|
||||
)
|
||||
)
|
||||
}
|
||||
item {
|
||||
@ -392,7 +457,15 @@ fun PluginScreen(
|
||||
}
|
||||
}
|
||||
|
||||
if (data.discover.isLoading || data.discover.repos.isNotEmpty()) {
|
||||
}
|
||||
if ((!onboarding || data.onboardingDiscover) && (onboarding || data.discover.isLoading || data.discover.repos.isNotEmpty() || data.discover.error != null)) {
|
||||
if (onboarding) {
|
||||
item {
|
||||
OutlineButton(onClick = viewModel::showInstallSheet) {
|
||||
Text(stringResource(Res.string.plugin_install_section_title))
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@ -408,12 +481,31 @@ fun PluginScreen(
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
"Discover Plugins",
|
||||
stringResource(Res.string.welcome_plugins_discover),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
if (data.discover.isLoading) {
|
||||
item {
|
||||
Box(
|
||||
Modifier.fillMaxWidth().padding(16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(Modifier.size(24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (onboarding && data.discover.isInitialLoaded && !data.discover.isLoading && data.discover.repos.isEmpty() && data.discover.error == null) {
|
||||
item {
|
||||
Text(
|
||||
stringResource(Res.string.welcome_plugins_discovery_empty),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
items(
|
||||
data.discover.repos,
|
||||
key = { it.id }
|
||||
@ -440,8 +532,9 @@ fun PluginScreen(
|
||||
|
||||
data.discover.error?.let { error ->
|
||||
item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
error,
|
||||
stringResource(Res.string.welcome_plugins_discovery_error),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(
|
||||
@ -449,6 +542,17 @@ fun PluginScreen(
|
||||
vertical = 8.dp
|
||||
)
|
||||
)
|
||||
OutlineButton(onClick = viewModel::retryDiscovery) {
|
||||
Text(stringResource(Res.string.welcome_plugins_retry))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (onboarding && data.discover.hasMore && !data.discover.isLoading && !data.discover.isLoadingMore && data.discover.error == null) {
|
||||
item {
|
||||
OutlineButton(onClick = viewModel::loadNextDiscoverPage) {
|
||||
Text(stringResource(Res.string.welcome_plugins_more))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -468,8 +572,8 @@ fun PluginScreen(
|
||||
// Pagination trigger stays in the composable because it is driven by
|
||||
// LazyList layout information, which only exists in UI scope.
|
||||
// The actual loading call is forwarded to the ViewModel.
|
||||
LaunchedEffect(shouldLoadMore.value) {
|
||||
if (shouldLoadMore.value) {
|
||||
LaunchedEffect(shouldLoadMore.value, onboarding) {
|
||||
if (!onboarding && shouldLoadMore.value) {
|
||||
viewModel.loadNextDiscoverPage()
|
||||
}
|
||||
}
|
||||
|
||||
@ -87,6 +87,7 @@ sealed interface PluginUiState {
|
||||
data object Loading : PluginUiState
|
||||
|
||||
data class Data(
|
||||
val onboardingDiscover: Boolean = true,
|
||||
val plugins: List<PluginListItem>,
|
||||
val abilitySelections: List<AbilitySelection>,
|
||||
val pendingPlugin: PluginManager.PendingPlugin? = null,
|
||||
@ -118,6 +119,7 @@ private data class PluginManagerSnapshot(
|
||||
)
|
||||
|
||||
private data class PluginScreenState(
|
||||
val onboardingDiscover: Boolean = true,
|
||||
val urlInput: String = "",
|
||||
val urlError: UrlError? = null,
|
||||
val isLoadingUrl: Boolean = false,
|
||||
@ -239,6 +241,7 @@ class PluginViewModel(
|
||||
when (val state = snapshot.state) {
|
||||
is PluginManagerStates.Loading -> PluginUiState.Loading
|
||||
is PluginManagerStates.Data -> PluginUiState.Data(
|
||||
onboardingDiscover = screen.onboardingDiscover,
|
||||
plugins = state.plugins.map { plugin ->
|
||||
PluginListItem(
|
||||
plugin = plugin,
|
||||
@ -468,6 +471,10 @@ class PluginViewModel(
|
||||
pluginManager.setSelectedPlugin(ability, plugin)
|
||||
}
|
||||
|
||||
fun showOnboardingDiscovery(show: Boolean) {
|
||||
_screenState.update { it.copy(onboardingDiscover = show) }
|
||||
}
|
||||
|
||||
fun removePlugin(plugin: PluginEntry) {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
@ -512,6 +519,11 @@ class PluginViewModel(
|
||||
return repos.filter { it.htmlUrl !in installedUrls }
|
||||
}
|
||||
|
||||
fun retryDiscovery() {
|
||||
if (_screenState.value.discover.isLoading) return
|
||||
loadFirstDiscoverPage()
|
||||
}
|
||||
|
||||
private fun loadFirstDiscoverPage() {
|
||||
viewModelScope.launch {
|
||||
_screenState.update {
|
||||
@ -590,7 +602,8 @@ class PluginViewModel(
|
||||
}
|
||||
|
||||
fun installPlugin(release: GitHubRelease, repoId: Long) {
|
||||
val smplugUrl = release.assets.firstOrNull { it.name.endsWith(".smplug") }?.browserDownloadUrl
|
||||
val smplugUrl =
|
||||
release.assets.firstOrNull { it.name.endsWith(".smplug") }?.browserDownloadUrl
|
||||
if (smplugUrl != null) {
|
||||
installPluginFromUrl(smplugUrl, repoId)
|
||||
}
|
||||
|
||||
@ -129,6 +129,13 @@ fun AppShell(
|
||||
}
|
||||
}
|
||||
|
||||
// Onboarding is a bootstrap, full-screen destination. It draws its own app bar (and thus
|
||||
// the desktop window controls), so render it without the sidebar, players or bottom bar.
|
||||
if (navigationState.topLevelRoute == Routes.Welcome) {
|
||||
content()
|
||||
return
|
||||
}
|
||||
|
||||
ConnectionRequestDialogHost()
|
||||
PlayDestinationPickerHost()
|
||||
|
||||
|
||||
@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.modules.welcome
|
||||
|
||||
import dev.krtirtho.spotube.resources.iconsax.BrandBuyMeACoffee
|
||||
import dev.krtirtho.spotube.resources.iconsax.BrandOpencollective
|
||||
import dev.krtirtho.spotube.resources.iconsax.BrandPatreon
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import spotube.composeapp.generated.resources.Res
|
||||
import spotube.composeapp.generated.resources.welcome_donate_bmac_subtitle
|
||||
import spotube.composeapp.generated.resources.welcome_donate_bmac_title
|
||||
import spotube.composeapp.generated.resources.welcome_donate_opencollective_subtitle
|
||||
import spotube.composeapp.generated.resources.welcome_donate_opencollective_title
|
||||
import spotube.composeapp.generated.resources.welcome_donate_patreon_subtitle
|
||||
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
|
||||
|
||||
/** Order matches the 4 onboarding pages. */
|
||||
internal val welcomePrimaryLabels: List<StringResource> = listOf(
|
||||
Res.string.welcome_lets_go,
|
||||
Res.string.welcome_next,
|
||||
Res.string.welcome_next,
|
||||
Res.string.welcome_get_started,
|
||||
)
|
||||
|
||||
internal val welcomeDonations: List<DonationOption> = listOf(
|
||||
DonationOption(
|
||||
id = "patreon",
|
||||
title = Res.string.welcome_donate_patreon_title,
|
||||
subtitle = Res.string.welcome_donate_patreon_subtitle,
|
||||
url = "https://patreon.com/krtirtho",
|
||||
icon = Iconsax.BrandPatreon,
|
||||
),
|
||||
DonationOption(
|
||||
id = "buymeacoffee",
|
||||
title = Res.string.welcome_donate_bmac_title,
|
||||
subtitle = Res.string.welcome_donate_bmac_subtitle,
|
||||
url = "https://www.buymeacoffee.com/krtirtho",
|
||||
icon = Iconsax.BrandBuyMeACoffee,
|
||||
),
|
||||
DonationOption(
|
||||
id = "opencollective",
|
||||
title = Res.string.welcome_donate_opencollective_title,
|
||||
subtitle = Res.string.welcome_donate_opencollective_subtitle,
|
||||
url = "https://opencollective.com/spotube",
|
||||
icon = Iconsax.BrandOpencollective,
|
||||
),
|
||||
)
|
||||
@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.modules.welcome
|
||||
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
|
||||
/** A single donation destination shown on the support page. */
|
||||
data class DonationOption(
|
||||
val id: String,
|
||||
val title: StringResource,
|
||||
val subtitle: StringResource,
|
||||
val url: String,
|
||||
val icon: ImageVector,
|
||||
)
|
||||
|
||||
data class WelcomeUiState(
|
||||
val currentPageIndex: Int,
|
||||
val pageCount: Int,
|
||||
val isLastPage: Boolean,
|
||||
val primaryLabel: StringResource,
|
||||
val donations: List<DonationOption>,
|
||||
val demoPlaying: Boolean = true,
|
||||
)
|
||||
@ -0,0 +1,112 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.modules.welcome
|
||||
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import dev.krtirtho.spotube.core.DebugConfig
|
||||
import dev.krtirtho.spotube.core.db.Database
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.IO
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
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.launch
|
||||
|
||||
/**
|
||||
* Owns whether the Welcome/Onboarding flow should be the app's start destination.
|
||||
*
|
||||
* This lives in its own preference rather than [dev.krtirtho.spotube.modules.settings.UserSettings]
|
||||
* so the very first launch can be distinguished from "settings not read yet". It is a Koin
|
||||
* singleton so the app-level start route and the routed [WelcomeScreen] observe the same state.
|
||||
*/
|
||||
class WelcomeRepository(private val database: Database) {
|
||||
companion object {
|
||||
private val ONBOARDING_COMPLETED_KEY = booleanPreferencesKey("onboarding_completed")
|
||||
}
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
/** Set when onboarding is finished for this process only; does not persist (debug forcing). */
|
||||
private val sessionDismissed = MutableStateFlow(false)
|
||||
|
||||
/** Synchronous signal, available before the persisted flag loads; true on upgrades. */
|
||||
val isExistingInstall: Boolean get() = database.isExistingInstall
|
||||
|
||||
private val onboardingCompleted: StateFlow<Boolean?> = database.settingsDataStore.data
|
||||
.map { prefs -> prefs[ONBOARDING_COMPLETED_KEY] ?: database.isExistingInstall }
|
||||
.stateIn(scope, SharingStarted.Eagerly, null)
|
||||
|
||||
/**
|
||||
* Whether the app should start on the Welcome/Onboarding route instead of the main shell.
|
||||
*
|
||||
* - `true` on a fresh install, or whenever [DebugConfig.FORCE_SHOW_ONBOARDING] is enabled.
|
||||
* - `false` on an existing install (upgrade) or once onboarding is completed or skipped,
|
||||
* including within a forced session.
|
||||
*
|
||||
* The initial value is derived from [isExistingInstall] so the start route can be chosen on
|
||||
* the very first frame without flashing the wrong screen.
|
||||
*/
|
||||
val shouldShowOnboarding: StateFlow<Boolean> = combine(
|
||||
onboardingCompleted,
|
||||
sessionDismissed,
|
||||
) { completed, dismissed ->
|
||||
when {
|
||||
dismissed -> false
|
||||
DebugConfig.FORCE_SHOW_ONBOARDING -> true
|
||||
else -> !(completed ?: database.isExistingInstall)
|
||||
}
|
||||
}.stateIn(
|
||||
scope = scope,
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = if (DebugConfig.FORCE_SHOW_ONBOARDING) true else !database.isExistingInstall,
|
||||
)
|
||||
|
||||
init {
|
||||
// One-time migration. When the flag was never written this is either a brand-new install
|
||||
// (show onboarding) or an upgrade of an existing install (skip it). Persisting the
|
||||
// decision also stops the fresh install's own data writes from being mistaken for an
|
||||
// upgrade on the next launch.
|
||||
scope.launch {
|
||||
database.settingsDataStore.edit { prefs ->
|
||||
if (ONBOARDING_COMPLETED_KEY !in prefs) {
|
||||
prefs[ONBOARDING_COMPLETED_KEY] = database.isExistingInstall
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun completeOnboarding() {
|
||||
sessionDismissed.value = true
|
||||
database.settingsDataStore.edit { prefs ->
|
||||
prefs[ONBOARDING_COMPLETED_KEY] = true
|
||||
}
|
||||
}
|
||||
|
||||
/** Development helper to surface the welcome screen again on the next launch. */
|
||||
suspend fun resetOnboarding() {
|
||||
database.settingsDataStore.edit { prefs ->
|
||||
prefs[ONBOARDING_COMPLETED_KEY] = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,245 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.modules.welcome
|
||||
|
||||
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 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
|
||||
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 androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.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.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import dev.krtirtho.spotube.core.ui.base.GhostButton
|
||||
import dev.krtirtho.spotube.core.ui.base.OutlineButton
|
||||
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
|
||||
import dev.krtirtho.spotube.modules.welcome.pages.BrandingPage
|
||||
import dev.krtirtho.spotube.modules.welcome.pages.DonationPage
|
||||
import dev.krtirtho.spotube.modules.welcome.pages.HowItWorksPage
|
||||
import dev.krtirtho.spotube.modules.welcome.pages.PluginsPage
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
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.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun WelcomeScreen(viewModel: WelcomeViewModel) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
WelcomeTheme {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
WelcomeBackground()
|
||||
Scaffold(
|
||||
containerColor = Color.Transparent,
|
||||
topBar = {
|
||||
ApplicationMainBar(
|
||||
title = {
|
||||
WelcomeWordmark(
|
||||
fontSize = MaterialTheme.typography.titleLarge.fontSize,
|
||||
)
|
||||
},
|
||||
backButton = false,
|
||||
transparent = true,
|
||||
actions = {
|
||||
if (!state.isLastPage) {
|
||||
GhostButton(onClick = viewModel::completeOnboarding) {
|
||||
Text(
|
||||
text = stringResource(Res.string.welcome_skip),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.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) {
|
||||
OutlineButton(
|
||||
onClick = viewModel::previousPage,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.welcome_back),
|
||||
color = MaterialTheme.colorScheme.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
|
||||
OutlineButton(
|
||||
onClick = viewModel::onPrimary,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(state.primaryLabel),
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Icon(
|
||||
imageVector = if (state.isLastPage) {
|
||||
Iconsax.IconsaxCheckCircle
|
||||
} else {
|
||||
Iconsax.IconsaxNext
|
||||
},
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.outlineVariant
|
||||
}
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,87 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.modules.welcome
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
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.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
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 ->
|
||||
val pageCount = welcomePrimaryLabels.size
|
||||
WelcomeUiState(
|
||||
currentPageIndex = index,
|
||||
pageCount = pageCount,
|
||||
isLastPage = index == pageCount - 1,
|
||||
primaryLabel = welcomePrimaryLabels[index.coerceIn(0, pageCount - 1)],
|
||||
donations = welcomeDonations,
|
||||
demoPlaying = playing,
|
||||
)
|
||||
}
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = WelcomeUiState(
|
||||
currentPageIndex = 0,
|
||||
pageCount = welcomePrimaryLabels.size,
|
||||
isLastPage = welcomePrimaryLabels.size == 1,
|
||||
primaryLabel = welcomePrimaryLabels.first(),
|
||||
donations = welcomeDonations,
|
||||
),
|
||||
)
|
||||
|
||||
fun nextPage() {
|
||||
currentPageIndex.update { (it + 1).coerceAtMost(welcomePrimaryLabels.lastIndex) }
|
||||
}
|
||||
|
||||
fun toggleDemo() {
|
||||
demoPlaying.update { !it }
|
||||
}
|
||||
|
||||
fun onPrimary() {
|
||||
if (currentPageIndex.value == welcomePrimaryLabels.lastIndex) completeOnboarding() else nextPage()
|
||||
}
|
||||
|
||||
fun openDonation(url: String) {
|
||||
// Only destinations owned by our static funding list can be opened here.
|
||||
if (welcomeDonations.none { it.url == url }) return
|
||||
runCatching { dev.krtirtho.spotube.openUrlInBrowser(url) }
|
||||
}
|
||||
|
||||
fun previousPage() {
|
||||
currentPageIndex.update { (it - 1).coerceAtLeast(0) }
|
||||
}
|
||||
|
||||
fun completeOnboarding() {
|
||||
viewModelScope.launch {
|
||||
repository.completeOnboarding()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,91 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.modules.welcome
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
|
||||
import dev.krtirtho.spotube.core.ui.base.rememberBaseUITheme
|
||||
import org.jetbrains.compose.resources.Font
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import spotube.composeapp.generated.resources.*
|
||||
|
||||
/** App-native controls and surfaces, in neutral charcoal/ivory instead of the user's accent. */
|
||||
@Composable
|
||||
fun WelcomeTheme(content: @Composable () -> Unit) {
|
||||
val dark = MaterialTheme.colorScheme.surface.luminance() < 0.5f
|
||||
val scheme = if (dark) darkColorScheme(
|
||||
primary = Color(0xFF8BA8DD), onPrimary = Color(0xFF10213E),
|
||||
primaryContainer = Color(0xFF263754), onPrimaryContainer = Color(0xFFD9E4F7),
|
||||
secondary = Color(0xFFBDBDBD), secondaryContainer = Color(0xFF343434),
|
||||
onSecondaryContainer = Color(0xFFF0F0F0),
|
||||
surface = Color(0xFF181818), background = Color(0xFF181818),
|
||||
surfaceContainer = Color(0xFF222222), surfaceContainerHigh = Color(0xFF292929),
|
||||
surfaceContainerHighest = Color(0xFF323232),
|
||||
onSurface = Color(0xFFEDEBE7), onSurfaceVariant = Color(0xFFABA9A5),
|
||||
outlineVariant = Color(0xFF414141),
|
||||
) else lightColorScheme(
|
||||
primary = Color(0xFF244879), onPrimary = Color.White,
|
||||
primaryContainer = Color(0xFFDDE5F1), onPrimaryContainer = Color(0xFF173252),
|
||||
secondaryContainer = Color(0xFFE5E3DF), onSecondaryContainer = Color(0xFF292929),
|
||||
surface = Color(0xFFF4F3F0), background = Color(0xFFF4F3F0),
|
||||
surfaceContainer = Color(0xFFEDEBE7), surfaceContainerHigh = Color(0xFFFDFCFA),
|
||||
surfaceContainerHighest = Color(0xFFE1DFDB),
|
||||
onSurface = Color(0xFF252525), onSurfaceVariant = Color(0xFF696762),
|
||||
outlineVariant = Color(0xFFD2D0CC),
|
||||
)
|
||||
MaterialTheme(colorScheme = scheme) {
|
||||
CompositionLocalProvider(LocalBaseUITheme provides rememberBaseUITheme()) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WelcomeBackground(modifier: Modifier = Modifier) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
Box(
|
||||
modifier.fillMaxSize().background(
|
||||
Brush.verticalGradient(listOf(scheme.surfaceContainer, scheme.surface)),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WelcomeWordmark(
|
||||
modifier: Modifier = Modifier,
|
||||
fontSize: TextUnit = MaterialTheme.typography.displaySmall.fontSize
|
||||
) {
|
||||
Text(
|
||||
stringResource(Res.string.welcome_app_name),
|
||||
modifier = modifier,
|
||||
fontFamily = FontFamily(Font(Res.font.cookie_regular)),
|
||||
fontSize = fontSize,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,46 @@
|
||||
package dev.krtirtho.spotube.modules.welcome.pages
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import dev.krtirtho.spotube.modules.welcome.WelcomeWordmark
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import spotube.composeapp.generated.resources.*
|
||||
|
||||
@Composable
|
||||
fun BrandingPage(modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Image(painterResource(Res.drawable.spotube_logo), null, Modifier.size(88.dp))
|
||||
Spacer(Modifier.height(24.dp))
|
||||
WelcomeWordmark(fontSize = 48.sp)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Text(
|
||||
stringResource(Res.string.welcome_brand_freedom),
|
||||
style = MaterialTheme.typography.displaySmall.copy(fontFamily = FontFamily.Serif),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
stringResource(Res.string.welcome_brand_quiet_description),
|
||||
modifier = Modifier.widthIn(max = 440.dp),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,70 @@
|
||||
package dev.krtirtho.spotube.modules.welcome.pages
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.krtirtho.spotube.core.ui.base.OutlineButton
|
||||
import dev.krtirtho.spotube.modules.welcome.DonationOption
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import spotube.composeapp.generated.resources.*
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun DonationPage(
|
||||
donations: List<DonationOption>,
|
||||
onDonate: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(
|
||||
stringResource(Res.string.welcome_donate_quiet_title),
|
||||
style = MaterialTheme.typography.displaySmall.copy(fontFamily = FontFamily.Serif),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Text(
|
||||
stringResource(Res.string.welcome_donate_letter),
|
||||
modifier = Modifier.widthIn(max = 540.dp),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(32.dp))
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
donations.forEach { option ->
|
||||
OutlineButton(onClick = { onDonate(option.url) }) {
|
||||
Icon(
|
||||
option.icon, null, Modifier.size(24.dp),
|
||||
tint = if (option.id == "patreon") MaterialTheme.colorScheme.onSurface else Color.Unspecified,
|
||||
)
|
||||
Text(stringResource(option.title))
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Text(
|
||||
stringResource(Res.string.welcome_donate_optional),
|
||||
modifier = Modifier.widthIn(max = 500.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,181 @@
|
||||
package dev.krtirtho.spotube.modules.welcome.pages
|
||||
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
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.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.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.krtirtho.spotube.core.ui.base.Card
|
||||
import dev.krtirtho.spotube.core.ui.base.GhostIconButton
|
||||
import dev.krtirtho.spotube.resources.iconsax.*
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import spotube.composeapp.generated.resources.*
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.sin
|
||||
|
||||
@Composable
|
||||
fun HowItWorksPage(
|
||||
isPlaying: Boolean,
|
||||
onTogglePlayback: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
BoxWithConstraints(modifier.fillMaxSize()) {
|
||||
val wide = maxWidth >= 760.dp
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ListeningIntroduction(modifier: Modifier = Modifier) {
|
||||
Column(modifier, verticalArrangement = Arrangement.spacedBy(20.dp)) {
|
||||
Text(
|
||||
stringResource(Res.string.welcome_how_quiet_title),
|
||||
style = MaterialTheme.typography.headlineLarge.copy(fontFamily = FontFamily.Serif),
|
||||
)
|
||||
Text(
|
||||
stringResource(Res.string.welcome_how_quiet_description),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 = MaterialTheme.colorScheme
|
||||
|
||||
Card(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 = MaterialTheme.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 = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
Text(
|
||||
stringResource(Res.string.welcome_how_mockup_artist),
|
||||
style = MaterialTheme.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 = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Serif),
|
||||
color = scheme.onSurfaceVariant
|
||||
)
|
||||
Row(
|
||||
Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Iconsax.IconsaxPrevious,
|
||||
null,
|
||||
Modifier.size(18.dp),
|
||||
tint = scheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.width(24.dp))
|
||||
GhostIconButton(onClick = onTogglePlayback, modifier = Modifier.size(48.dp)) {
|
||||
Icon(
|
||||
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))
|
||||
Icon(
|
||||
Iconsax.IconsaxNext,
|
||||
null,
|
||||
Modifier.size(18.dp),
|
||||
tint = scheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
package dev.krtirtho.spotube.modules.welcome.pages
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import dev.krtirtho.spotube.modules.plugin.PluginScreen
|
||||
|
||||
/** The same installation, discovery, permissions and sign-in flow as Settings, in-place. */
|
||||
@Composable
|
||||
fun PluginsPage() {
|
||||
PluginScreen(onboarding = true)
|
||||
}
|
||||
@ -0,0 +1,264 @@
|
||||
package dev.krtirtho.spotube.resources.iconsax
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.PathData
|
||||
import androidx.compose.ui.graphics.vector.group
|
||||
import androidx.compose.ui.graphics.vector.path
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
val Iconsax.BrandBuyMeACoffee: ImageVector
|
||||
get() {
|
||||
if (_BrandBuyMeACoffee != null) {
|
||||
return _BrandBuyMeACoffee!!
|
||||
}
|
||||
_BrandBuyMeACoffee = ImageVector.Builder(
|
||||
name = "BrandBuyMeACoffee",
|
||||
defaultWidth = 512.dp,
|
||||
defaultHeight = 512.dp,
|
||||
viewportWidth = 512f,
|
||||
viewportHeight = 512f
|
||||
).apply {
|
||||
group(
|
||||
clipPathData = PathData {
|
||||
moveTo(0f, 0f)
|
||||
horizontalLineToRelative(512f)
|
||||
verticalLineToRelative(512f)
|
||||
horizontalLineTo(0f)
|
||||
close()
|
||||
}
|
||||
) {
|
||||
path(fill = SolidColor(Color(0xFF0D0C22))) {
|
||||
moveTo(256.17f, 256.45f)
|
||||
lineToRelative(-0.35f, -0.21f)
|
||||
lineTo(255f, 256f)
|
||||
arcToRelative(1.93f, 1.93f, 0f, isMoreThanHalf = false, isPositiveArc = false, 1.16f, 0.45f)
|
||||
close()
|
||||
moveTo(256.15f, 256.04f)
|
||||
arcToRelative(0.75f, 0.75f, 0f, isMoreThanHalf = false, isPositiveArc = true, -0.15f, -0.04f)
|
||||
arcToRelative(0.54f, 0.54f, 0f, isMoreThanHalf = false, isPositiveArc = false, 0f, 0.1f)
|
||||
arcToRelative(0.3f, 0.3f, 0f, isMoreThanHalf = false, isPositiveArc = false, 0.15f, -0.06f)
|
||||
close()
|
||||
}
|
||||
path(fill = SolidColor(Color(0xFF0D0C22))) {
|
||||
moveTo(256f, 256.03f)
|
||||
horizontalLineToRelative(0.05f)
|
||||
verticalLineTo(256f)
|
||||
lineToRelative(-0.05f, 0.03f)
|
||||
close()
|
||||
moveTo(255f, 256.67f)
|
||||
lineToRelative(0.59f, -0.34f)
|
||||
lineToRelative(0.22f, -0.12f)
|
||||
lineToRelative(0.2f, -0.21f)
|
||||
arcToRelative(3.39f, 3.39f, 0f, isMoreThanHalf = false, isPositiveArc = false, -1.01f, 0.68f)
|
||||
close()
|
||||
}
|
||||
path(fill = SolidColor(Color(0xFF0D0C22))) {
|
||||
moveTo(255.97f, 256.77f)
|
||||
lineToRelative(-0.58f, -0.55f)
|
||||
lineTo(255f, 256f)
|
||||
curveToRelative(0.21f, 0.37f, 0.56f, 0.65f, 0.97f, 0.76f)
|
||||
close()
|
||||
}
|
||||
path(fill = SolidColor(Color(0xFF0D0C22))) {
|
||||
moveTo(256.18f, 256f)
|
||||
curveToRelative(-0.46f, 0.2f, -0.87f, 0.51f, -1.18f, 0.91f)
|
||||
lineToRelative(0.37f, -0.23f)
|
||||
curveToRelative(0.25f, -0.23f, 0.6f, -0.5f, 0.81f, -0.68f)
|
||||
close()
|
||||
}
|
||||
path(fill = SolidColor(Color(0xFF0D0C22))) {
|
||||
moveTo(256.2f, 255.27f)
|
||||
curveToRelative(0f, -0.52f, -0.25f, -0.43f, -0.19f, 1.43f)
|
||||
curveToRelative(0f, -0.15f, 0.06f, -0.3f, 0.09f, -0.44f)
|
||||
curveToRelative(0.04f, -0.33f, 0.06f, -0.66f, 0.1f, -0.99f)
|
||||
close()
|
||||
}
|
||||
path(fill = SolidColor(Color(0xFF0D0C22))) {
|
||||
moveTo(256.18f, 256f)
|
||||
arcToRelative(3.02f, 3.02f, 0f, isMoreThanHalf = false, isPositiveArc = false, -1.18f, 0.91f)
|
||||
lineToRelative(0.37f, -0.23f)
|
||||
curveToRelative(0.25f, -0.23f, 0.6f, -0.5f, 0.81f, -0.68f)
|
||||
close()
|
||||
}
|
||||
path(fill = SolidColor(Color(0xFF0D0C22))) {
|
||||
moveTo(256.24f, 256.58f)
|
||||
arcTo(2.52f, 2.52f, 0f, isMoreThanHalf = false, isPositiveArc = false, 255f, 256f)
|
||||
curveToRelative(0.37f, 0.18f, 0.74f, 0.36f, 0.99f, 0.5f)
|
||||
lineToRelative(0.25f, 0.08f)
|
||||
close()
|
||||
moveTo(256.49f, 256.54f)
|
||||
arcTo(3.96f, 3.96f, 0f, isMoreThanHalf = false, isPositiveArc = false, 256f, 255f)
|
||||
curveToRelative(0.19f, 0.49f, 0.35f, 1f, 0.47f, 1.51f)
|
||||
lineToRelative(0.01f, 0.03f)
|
||||
close()
|
||||
}
|
||||
path(fill = SolidColor(Color(0xFFFFDD00))) {
|
||||
moveTo(267.82f, 145.08f)
|
||||
curveToRelative(-18.4f, 7.87f, -39.28f, 16.8f, -66.33f, 16.8f)
|
||||
arcTo(125.64f, 125.64f, 0f, isMoreThanHalf = false, isPositiveArc = true, 168f, 157.26f)
|
||||
lineToRelative(18.71f, 192.07f)
|
||||
arcToRelative(32.09f, 32.09f, 0f, isMoreThanHalf = false, isPositiveArc = false, 32f, 29.45f)
|
||||
reflectiveCurveToRelative(26.53f, 1.38f, 35.39f, 1.38f)
|
||||
curveToRelative(9.53f, 0f, 38.1f, -1.38f, 38.1f, -1.38f)
|
||||
arcToRelative(32.1f, 32.1f, 0f, isMoreThanHalf = false, isPositiveArc = false, 31.99f, -29.45f)
|
||||
lineToRelative(20.04f, -212.25f)
|
||||
curveToRelative(-8.96f, -3.06f, -18f, -5.09f, -28.19f, -5.09f)
|
||||
curveToRelative(-17.63f, -0.01f, -31.83f, 6.06f, -48.23f, 13.08f)
|
||||
close()
|
||||
}
|
||||
path(fill = SolidColor(Color(0xFF0D0C22))) {
|
||||
moveTo(256f, 256f)
|
||||
lineToRelative(0.32f, 0.3f)
|
||||
lineToRelative(0.21f, 0.12f)
|
||||
arcTo(3.21f, 3.21f, 0f, isMoreThanHalf = false, isPositiveArc = false, 256f, 256f)
|
||||
close()
|
||||
}
|
||||
path(fill = SolidColor(Color(0xFF0D0C22))) {
|
||||
moveTo(431.23f, 136.85f)
|
||||
lineToRelative(-2.82f, -14.21f)
|
||||
curveToRelative(-2.53f, -12.75f, -8.27f, -24.8f, -21.36f, -29.41f)
|
||||
curveToRelative(-4.2f, -1.47f, -8.96f, -2.11f, -12.18f, -5.16f)
|
||||
curveToRelative(-3.22f, -3.05f, -4.17f, -7.79f, -4.91f, -12.18f)
|
||||
curveToRelative(-1.38f, -8.07f, -2.67f, -16.14f, -4.09f, -24.19f)
|
||||
curveToRelative(-1.22f, -6.92f, -2.18f, -14.7f, -5.36f, -21.05f)
|
||||
curveToRelative(-4.13f, -8.53f, -12.71f, -13.51f, -21.24f, -16.81f)
|
||||
arcToRelative(122.38f, 122.38f, 0f, isMoreThanHalf = false, isPositiveArc = false, -13.36f, -4.13f)
|
||||
curveToRelative(-21.31f, -5.62f, -43.72f, -7.69f, -65.64f, -8.86f)
|
||||
arcToRelative(551.32f, 551.32f, 0f, isMoreThanHalf = false, isPositiveArc = false, -78.96f, 1.31f)
|
||||
curveToRelative(-19.54f, 1.78f, -40.12f, 3.93f, -58.69f, 10.68f)
|
||||
curveToRelative(-6.79f, 2.47f, -13.78f, 5.44f, -18.94f, 10.68f)
|
||||
curveToRelative(-6.33f, 6.44f, -8.4f, 16.4f, -3.78f, 24.43f)
|
||||
curveToRelative(3.29f, 5.7f, 8.85f, 9.73f, 14.76f, 12.4f)
|
||||
arcToRelative(119.72f, 119.72f, 0f, isMoreThanHalf = false, isPositiveArc = false, 23.97f, 7.8f)
|
||||
curveToRelative(22.94f, 5.07f, 46.71f, 7.06f, 70.15f, 7.91f)
|
||||
arcToRelative(534.38f, 534.38f, 0f, isMoreThanHalf = false, isPositiveArc = false, 77.86f, -2.54f)
|
||||
arcToRelative(443.55f, 443.55f, 0f, isMoreThanHalf = false, isPositiveArc = false, 19.15f, -2.53f)
|
||||
curveToRelative(7.5f, -1.15f, 12.32f, -10.96f, 10.11f, -17.79f)
|
||||
curveToRelative(-2.65f, -8.17f, -9.76f, -11.34f, -17.8f, -10.1f)
|
||||
curveToRelative(-1.18f, 0.19f, -2.36f, 0.36f, -3.55f, 0.53f)
|
||||
lineToRelative(-0.85f, 0.12f)
|
||||
curveToRelative(-2.72f, 0.34f, -5.45f, 0.67f, -8.17f, 0.96f)
|
||||
arcToRelative(433.76f, 433.76f, 0f, isMoreThanHalf = false, isPositiveArc = true, -16.92f, 1.49f)
|
||||
curveToRelative(-12.66f, 0.88f, -25.36f, 1.29f, -38.06f, 1.31f)
|
||||
curveToRelative(-12.47f, 0f, -24.95f, -0.35f, -37.39f, -1.17f)
|
||||
arcToRelative(475.74f, 475.74f, 0f, isMoreThanHalf = false, isPositiveArc = true, -16.99f, -1.42f)
|
||||
curveToRelative(-2.57f, -0.27f, -5.13f, -0.55f, -7.7f, -0.87f)
|
||||
lineToRelative(-2.44f, -0.31f)
|
||||
lineToRelative(-0.53f, -0.08f)
|
||||
lineToRelative(-2.53f, -0.37f)
|
||||
curveToRelative(-5.17f, -0.78f, -10.34f, -1.67f, -15.45f, -2.76f)
|
||||
arcToRelative(2.32f, 2.32f, 0f, isMoreThanHalf = false, isPositiveArc = true, 0f, -4.53f)
|
||||
horizontalLineToRelative(0.1f)
|
||||
arcToRelative(250.82f, 250.82f, 0f, isMoreThanHalf = false, isPositiveArc = true, 13.37f, -2.45f)
|
||||
curveToRelative(1.49f, -0.23f, 2.99f, -0.46f, 4.49f, -0.69f)
|
||||
horizontalLineToRelative(0.04f)
|
||||
curveToRelative(2.81f, -0.19f, 5.62f, -0.69f, 8.41f, -1.02f)
|
||||
arcToRelative(535.29f, 535.29f, 0f, isMoreThanHalf = false, isPositiveArc = true, 73.1f, -2.58f)
|
||||
curveToRelative(11.84f, 0.34f, 23.68f, 1.04f, 35.47f, 2.24f)
|
||||
arcToRelative(391.9f, 391.9f, 0f, isMoreThanHalf = false, isPositiveArc = true, 7.58f, 0.85f)
|
||||
curveToRelative(0.96f, 0.12f, 1.94f, 0.25f, 2.91f, 0.37f)
|
||||
lineToRelative(1.96f, 0.28f)
|
||||
curveToRelative(5.7f, 0.85f, 11.38f, 1.88f, 17.03f, 3.09f)
|
||||
curveToRelative(8.36f, 1.82f, 19.11f, 2.41f, 22.83f, 11.57f)
|
||||
curveToRelative(1.18f, 2.91f, 1.72f, 6.14f, 2.38f, 9.19f)
|
||||
lineToRelative(0.83f, 3.89f)
|
||||
curveToRelative(0.02f, 0.07f, 0.04f, 0.14f, 0.05f, 0.21f)
|
||||
curveToRelative(1.97f, 9.18f, 3.94f, 18.37f, 5.92f, 27.55f)
|
||||
arcToRelative(5.05f, 5.05f, 0f, isMoreThanHalf = false, isPositiveArc = true, -4.24f, 6.05f)
|
||||
horizontalLineToRelative(-0.05f)
|
||||
lineToRelative(-1.21f, 0.17f)
|
||||
lineToRelative(-1.19f, 0.16f)
|
||||
curveToRelative(-3.78f, 0.49f, -7.56f, 0.95f, -11.34f, 1.38f)
|
||||
arcToRelative(732.52f, 732.52f, 0f, isMoreThanHalf = false, isPositiveArc = true, -22.4f, 2.2f)
|
||||
arcToRelative(784.15f, 784.15f, 0f, isMoreThanHalf = false, isPositiveArc = true, -44.67f, 2.43f)
|
||||
arcToRelative(802.63f, 802.63f, 0f, isMoreThanHalf = false, isPositiveArc = true, -22.8f, 0.28f)
|
||||
arcToRelative(791.3f, 791.3f, 0f, isMoreThanHalf = false, isPositiveArc = true, -90.5f, -5.26f)
|
||||
arcToRelative(944.16f, 944.16f, 0f, isMoreThanHalf = false, isPositiveArc = true, -9.76f, -1.22f)
|
||||
curveToRelative(2.52f, 0.32f, -1.83f, -0.25f, -2.71f, -0.37f)
|
||||
curveToRelative(-2.07f, -0.29f, -4.13f, -0.59f, -6.2f, -0.9f)
|
||||
curveToRelative(-6.94f, -1.04f, -13.84f, -2.32f, -20.76f, -3.44f)
|
||||
curveToRelative(-8.37f, -1.38f, -16.38f, -0.69f, -23.95f, 3.44f)
|
||||
arcToRelative(34.83f, 34.83f, 0f, isMoreThanHalf = false, isPositiveArc = false, -14.42f, 14.95f)
|
||||
curveToRelative(-3.27f, 6.75f, -4.24f, 14.1f, -5.7f, 21.35f)
|
||||
curveToRelative(-1.46f, 7.25f, -3.73f, 15.06f, -2.87f, 22.5f)
|
||||
curveToRelative(1.85f, 16.07f, 13.09f, 29.13f, 29.26f, 32.05f)
|
||||
curveToRelative(15.21f, 2.76f, 30.5f, 4.99f, 45.83f, 6.89f)
|
||||
arcToRelative(847.2f, 847.2f, 0f, isMoreThanHalf = false, isPositiveArc = false, 196.22f, 1.13f)
|
||||
arcToRelative(10.34f, 10.34f, 0f, isMoreThanHalf = false, isPositiveArc = true, 11.41f, 11.32f)
|
||||
lineToRelative(-1.53f, 14.86f)
|
||||
lineToRelative(-9.25f, 90.1f)
|
||||
arcToRelative(70942.69f, 70942.69f, 0f, isMoreThanHalf = false, isPositiveArc = true, -9.71f, 94.6f)
|
||||
lineToRelative(-2.76f, 26.64f)
|
||||
curveToRelative(-0.88f, 8.74f, -1.01f, 17.76f, -2.67f, 26.4f)
|
||||
curveToRelative(-2.62f, 13.58f, -11.82f, 21.93f, -25.24f, 24.98f)
|
||||
arcToRelative(175.93f, 175.93f, 0f, isMoreThanHalf = false, isPositiveArc = true, -37.47f, 4.38f)
|
||||
curveToRelative(-13.98f, 0.08f, -27.95f, -0.54f, -41.94f, -0.47f)
|
||||
curveToRelative(-14.93f, 0.08f, -33.2f, -1.29f, -44.72f, -12.4f)
|
||||
curveToRelative(-10.12f, -9.75f, -11.52f, -25.02f, -12.9f, -38.23f)
|
||||
arcToRelative(29492.9f, 29492.9f, 0f, isMoreThanHalf = false, isPositiveArc = true, -5.46f, -52.44f)
|
||||
lineToRelative(-10.13f, -97.18f)
|
||||
lineToRelative(-6.55f, -62.88f)
|
||||
curveToRelative(-0.11f, -1.04f, -0.22f, -2.07f, -0.32f, -3.11f)
|
||||
curveToRelative(-0.79f, -7.5f, -6.1f, -14.84f, -14.47f, -14.46f)
|
||||
curveToRelative(-7.17f, 0.32f, -15.31f, 6.41f, -14.47f, 14.46f)
|
||||
lineToRelative(4.86f, 46.62f)
|
||||
lineToRelative(10.05f, 96.43f)
|
||||
curveToRelative(2.86f, 27.39f, 5.72f, 54.79f, 8.56f, 82.19f)
|
||||
curveToRelative(0.55f, 5.25f, 1.07f, 10.51f, 1.65f, 15.76f)
|
||||
curveToRelative(3.15f, 28.68f, 25.06f, 44.14f, 52.19f, 48.49f)
|
||||
curveToRelative(15.85f, 2.55f, 32.08f, 3.08f, 48.16f, 3.34f)
|
||||
curveToRelative(20.62f, 0.33f, 41.44f, 1.13f, 61.72f, -2.61f)
|
||||
curveToRelative(30.05f, -5.51f, 52.6f, -25.57f, 55.81f, -56.68f)
|
||||
lineToRelative(2.76f, -26.95f)
|
||||
curveToRelative(3.05f, -29.72f, 6.11f, -59.44f, 9.15f, -89.17f)
|
||||
lineToRelative(9.96f, -97.12f)
|
||||
lineToRelative(4.57f, -44.51f)
|
||||
arcToRelative(10.34f, 10.34f, 0f, isMoreThanHalf = false, isPositiveArc = true, 8.32f, -9.09f)
|
||||
curveToRelative(8.59f, -1.67f, 16.81f, -4.53f, 22.92f, -11.07f)
|
||||
curveToRelative(9.73f, -10.41f, 11.66f, -23.98f, 8.23f, -37.66f)
|
||||
close()
|
||||
moveTo(108.01f, 146.45f)
|
||||
curveToRelative(0.13f, -0.06f, -0.11f, 1.06f, -0.21f, 1.58f)
|
||||
curveToRelative(-0.02f, -0.79f, 0.02f, -1.49f, 0.21f, -1.58f)
|
||||
close()
|
||||
moveTo(108.84f, 152.9f)
|
||||
curveToRelative(0.07f, -0.05f, 0.28f, 0.23f, 0.49f, 0.56f)
|
||||
curveToRelative(-0.32f, -0.3f, -0.53f, -0.53f, -0.5f, -0.56f)
|
||||
horizontalLineToRelative(0.01f)
|
||||
close()
|
||||
moveTo(109.66f, 153.98f)
|
||||
curveToRelative(0.45f, 0.82f, 0.3f, 0.5f, 0f, 0f)
|
||||
close()
|
||||
moveTo(111.31f, 155.31f)
|
||||
horizontalLineToRelative(0.04f)
|
||||
curveToRelative(0f, 0.05f, 0.08f, 0.1f, 0.1f, 0.14f)
|
||||
arcToRelative(1.04f, 1.04f, 0f, isMoreThanHalf = false, isPositiveArc = false, -0.15f, -0.14f)
|
||||
horizontalLineToRelative(0.01f)
|
||||
close()
|
||||
moveTo(399.68f, 153.32f)
|
||||
curveToRelative(-3.09f, 2.93f, -7.74f, 4.3f, -12.33f, 4.98f)
|
||||
curveToRelative(-51.54f, 7.64f, -103.83f, 11.52f, -155.94f, 9.81f)
|
||||
curveToRelative(-37.29f, -1.27f, -74.19f, -5.41f, -111.11f, -10.63f)
|
||||
curveToRelative(-3.62f, -0.51f, -7.54f, -1.17f, -10.02f, -3.84f)
|
||||
curveToRelative(-4.69f, -5.03f, -2.38f, -15.15f, -1.16f, -21.23f)
|
||||
curveToRelative(1.12f, -5.57f, 3.25f, -12.98f, 9.87f, -13.78f)
|
||||
curveToRelative(10.34f, -1.21f, 22.34f, 3.15f, 32.56f, 4.7f)
|
||||
arcToRelative(616.95f, 616.95f, 0f, isMoreThanHalf = false, isPositiveArc = false, 37.07f, 4.51f)
|
||||
curveToRelative(52.93f, 4.82f, 106.75f, 4.07f, 159.45f, -2.98f)
|
||||
arcToRelative(666.2f, 666.2f, 0f, isMoreThanHalf = false, isPositiveArc = false, 28.71f, -4.5f)
|
||||
curveToRelative(8.5f, -1.52f, 17.92f, -4.38f, 23.05f, 4.41f)
|
||||
curveToRelative(3.52f, 5.99f, 3.99f, 14.01f, 3.44f, 20.78f)
|
||||
arcToRelative(11.59f, 11.59f, 0f, isMoreThanHalf = false, isPositiveArc = true, -3.6f, 7.76f)
|
||||
horizontalLineToRelative(0.01f)
|
||||
close()
|
||||
}
|
||||
}
|
||||
}.build()
|
||||
|
||||
return _BrandBuyMeACoffee!!
|
||||
}
|
||||
|
||||
@Suppress("ObjectPropertyName")
|
||||
private var _BrandBuyMeACoffee: ImageVector? = null
|
||||
@ -0,0 +1,58 @@
|
||||
package dev.krtirtho.spotube.resources.iconsax
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.PathFillType
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.path
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
val Iconsax.BrandOpencollective: ImageVector
|
||||
get() {
|
||||
if (_BrandOpencollective != null) {
|
||||
return _BrandOpencollective!!
|
||||
}
|
||||
_BrandOpencollective = ImageVector.Builder(
|
||||
name = "BrandOpencollective",
|
||||
defaultWidth = 64.dp,
|
||||
defaultHeight = 64.dp,
|
||||
viewportWidth = 64f,
|
||||
viewportHeight = 64f
|
||||
).apply {
|
||||
path(
|
||||
fill = SolidColor(Color(0xFFB8D3F4)),
|
||||
pathFillType = PathFillType.EvenOdd
|
||||
) {
|
||||
moveTo(52.402f, 31.916f)
|
||||
curveToRelative(0f, 4.03f, -1.17f, 7.895f, -3.178f, 11.087f)
|
||||
lineToRelative(8.196f, 8.23f)
|
||||
curveToRelative(4.014f, -5.375f, 6.523f, -12.094f, 6.523f, -19.318f)
|
||||
reflectiveCurveToRelative(-2.51f, -13.942f, -6.523f, -19.318f)
|
||||
lineToRelative(-8.196f, 8.23f)
|
||||
curveToRelative(2.007f, 3.192f, 3.178f, 6.887f, 3.178f, 11.087f)
|
||||
close()
|
||||
}
|
||||
path(
|
||||
fill = SolidColor(Color(0xFF3385FF)),
|
||||
pathFillType = PathFillType.EvenOdd
|
||||
) {
|
||||
moveTo(32.004f, 52.41f)
|
||||
curveToRelative(-11.207f, 0f, -20.406f, -9.24f, -20.406f, -20.493f)
|
||||
reflectiveCurveToRelative(9.2f, -20.493f, 20.406f, -20.493f)
|
||||
curveToRelative(4.182f, 0f, 7.86f, 1.176f, 11.04f, 3.36f)
|
||||
lineToRelative(8.196f, -8.23f)
|
||||
curveTo(45.887f, 2.52f, 39.197f, 0f, 32.004f, 0f)
|
||||
curveTo(14.44f, 0f, 0.057f, 14.278f, 0.057f, 32.084f)
|
||||
reflectiveCurveTo(14.44f, 64f, 32.004f, 64f)
|
||||
curveToRelative(7.36f, 0f, 14.05f, -2.52f, 19.403f, -6.55f)
|
||||
lineToRelative(-8.196f, -8.23f)
|
||||
curveToRelative(-3.178f, 2.016f, -7.025f, 3.192f, -11.207f, 3.192f)
|
||||
close()
|
||||
}
|
||||
}.build()
|
||||
|
||||
return _BrandOpencollective!!
|
||||
}
|
||||
|
||||
@Suppress("ObjectPropertyName")
|
||||
private var _BrandOpencollective: ImageVector? = null
|
||||
@ -0,0 +1,54 @@
|
||||
package dev.krtirtho.spotube.resources.iconsax
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.StrokeJoin
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.PathData
|
||||
import androidx.compose.ui.graphics.vector.group
|
||||
import androidx.compose.ui.graphics.vector.path
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
val Iconsax.BrandPatreon: ImageVector
|
||||
get() {
|
||||
if (_BrandPatreon != null) {
|
||||
return _BrandPatreon!!
|
||||
}
|
||||
_BrandPatreon = ImageVector.Builder(
|
||||
name = "BrandPatreon",
|
||||
defaultWidth = 512.dp,
|
||||
defaultHeight = 512.dp,
|
||||
viewportWidth = 512f,
|
||||
viewportHeight = 512f
|
||||
).apply {
|
||||
group(
|
||||
clipPathData = PathData {
|
||||
moveTo(0.38f, 0.42f)
|
||||
lineToRelative(512f, 0f)
|
||||
lineToRelative(0f, 512f)
|
||||
lineTo(0.38f, 512.42f)
|
||||
close()
|
||||
}
|
||||
) {
|
||||
path(
|
||||
fill = SolidColor(Color.Black),
|
||||
strokeLineJoin = StrokeJoin.Round
|
||||
) {
|
||||
moveTo(490.12f, 154.23f)
|
||||
curveToRelative(-0.09f, -65.37f, -51.01f, -118.95f, -110.74f, -138.29f)
|
||||
curveToRelative(-74.18f, -24.01f, -172.02f, -20.53f, -242.86f, 12.89f)
|
||||
curveToRelative(-85.85f, 40.51f, -112.82f, 129.26f, -113.83f, 217.77f)
|
||||
curveToRelative(-0.82f, 72.77f, 6.44f, 264.43f, 114.54f, 265.8f)
|
||||
curveToRelative(80.33f, 1.02f, 92.29f, -102.48f, 129.45f, -152.33f)
|
||||
curveToRelative(26.44f, -35.47f, 60.49f, -45.48f, 102.4f, -55.85f)
|
||||
curveToRelative(72.03f, -17.83f, 121.13f, -74.68f, 121.03f, -149.99f)
|
||||
close()
|
||||
}
|
||||
}
|
||||
}.build()
|
||||
|
||||
return _BrandPatreon!!
|
||||
}
|
||||
|
||||
@Suppress("ObjectPropertyName")
|
||||
private var _BrandPatreon: ImageVector? = null
|
||||
@ -31,6 +31,8 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
@ -59,6 +61,7 @@ actual fun ApplicationMainBar(
|
||||
subtitle: @Composable () -> Unit,
|
||||
actions: @Composable (RowScope.() -> Unit),
|
||||
backButton: Boolean,
|
||||
transparent: Boolean,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
@ -75,6 +78,14 @@ actual fun ApplicationMainBar(
|
||||
}
|
||||
},
|
||||
subtitle = subtitle,
|
||||
colors = if (transparent) {
|
||||
TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = Color.Transparent,
|
||||
scrolledContainerColor = Color.Transparent,
|
||||
)
|
||||
} else {
|
||||
TopAppBarDefaults.topAppBarColors()
|
||||
},
|
||||
modifier = Modifier.padding(end = 125.dp) // To avoid overlap with window buttons
|
||||
)
|
||||
LocalWindowScope.current.WindowControls(
|
||||
|
||||
@ -21,6 +21,8 @@ import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||
@ -29,12 +31,21 @@ actual fun ApplicationMainBar(
|
||||
title: @Composable (() -> Unit),
|
||||
subtitle: @Composable () -> Unit,
|
||||
actions: @Composable (RowScope.() -> Unit),
|
||||
backButton: Boolean
|
||||
backButton: Boolean,
|
||||
transparent: Boolean,
|
||||
) {
|
||||
return TopAppBar(
|
||||
title = title,
|
||||
subtitle = subtitle,
|
||||
actions = actions,
|
||||
colors = if (transparent) {
|
||||
TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = Color.Transparent,
|
||||
scrolledContainerColor = Color.Transparent,
|
||||
)
|
||||
} else {
|
||||
TopAppBarDefaults.topAppBarColors()
|
||||
},
|
||||
navigationIcon = {
|
||||
if (backButton) {
|
||||
ApplicationBackButton()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user