diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index 2ceb48c1..8aee91ce 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -31,6 +31,7 @@
Caching
Desktop
Updates
+ Support
About
Close
@@ -301,6 +302,15 @@
Sponsor
Open %1$s
+
+ Support Spotube
+ Support Spotube
+ Spotube is free, libre and open source — no ads, no subscriptions, no tracking. If it brings you joy, consider supporting the project.
+ You can turn these reminders off in Settings → Support.
+ Maybe later
+ Support reminders
+ Show a gentle reminder to support the project every 7 days.
+
Modular Architecture
Choose where your music comes from.
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt
index 1ab1f561..0cf66c03 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt
@@ -24,12 +24,16 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.ui.NavDisplay
+import dev.krtirtho.spotube.core.DebugConfig
import dev.krtirtho.spotube.core.navigation.Navigator
import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.navigation.TOP_LEVEL_ROUTES
@@ -41,6 +45,9 @@ import dev.krtirtho.spotube.modules.library.LibraryTab
import dev.krtirtho.spotube.modules.settings.SettingsRepository
import dev.krtirtho.spotube.modules.settings.UserSettings
import dev.krtirtho.spotube.modules.shell.AppShell
+import dev.krtirtho.spotube.modules.support.LocalSupportPrompt
+import dev.krtirtho.spotube.modules.support.SupportDialog
+import dev.krtirtho.spotube.modules.support.SupportReminderIntervalMillis
import dev.krtirtho.spotube.modules.update.AppUpdateDialog
import dev.krtirtho.spotube.modules.update.AppUpdateService
import dev.krtirtho.spotube.modules.update.PluginUpdateDialog
@@ -59,6 +66,7 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxSetting2
import dev.krtirtho.spotube.resources.iconsax.IconsaxSoundTwotone
import dev.krtirtho.spotube.resources.iconsax.User
import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlin.time.Clock
import org.koin.compose.koinInject
import org.koin.compose.navigation3.koinEntryProvider
import org.koin.core.annotation.KoinExperimentalAPI
@@ -138,6 +146,40 @@ fun App(
val welcomeRepository: WelcomeRepository = koinInject()
val showOnboarding by welcomeRepository.shouldShowOnboarding.collectAsStateWithLifecycle()
+ var showSupportDialog by remember { mutableStateOf(false) }
+ var supportDialogIsReminder by remember { mutableStateOf(false) }
+
+ // Nudge the user to support the project at most once every seven days, unless they opted
+ // out in Settings. The first eligible launch after onboarding only records the baseline, so
+ // the prompt never appears right after the onboarding donation page.
+ LaunchedEffect(showOnboarding) {
+ if (showOnboarding) return@LaunchedEffect
+ // Read the persisted value rather than the collected state, whose initial value is the
+ // default (0) until DataStore has emitted once.
+ val current = settingsRepository.getCurrentSettings()
+ val now = Clock.System.now().toEpochMilliseconds()
+ // Development override: show the reminder on every launch, regardless of timing or the
+ // "Support reminders" toggle.
+ if (DebugConfig.FORCE_SUPPORT_REMINDER) {
+ supportDialogIsReminder = true
+ showSupportDialog = true
+ settingsRepository.updateSettings(
+ current.copy(lastSupportReminderEpochMs = now))
+ return@LaunchedEffect
+ }
+ if (!current.supportRemindersEnabled) return@LaunchedEffect
+ val last = current.lastSupportReminderEpochMs
+ if (last == 0L) {
+ settingsRepository.updateSettings(
+ current.copy(lastSupportReminderEpochMs = now))
+ } else if (now - last >= SupportReminderIntervalMillis) {
+ supportDialogIsReminder = true
+ showSupportDialog = true
+ settingsRepository.updateSettings(
+ current.copy(lastSupportReminderEpochMs = now))
+ }
+ }
+
// 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
@@ -150,52 +192,61 @@ fun App(
}
SpotubeTheme(settings = userSettings) {
- Box(modifier = Modifier.fillMaxSize()) {
- val currentRoute =
- navigationState.backStacks[navigationState.topLevelRoute]?.last()
+ CompositionLocalProvider(LocalSupportPrompt provides {
+ supportDialogIsReminder = false
+ showSupportDialog = true
+ }) {
+ Box(modifier = Modifier.fillMaxSize()) {
+ val currentRoute =
+ navigationState.backStacks[navigationState.topLevelRoute]?.last()
- Box(Modifier.fillMaxSize()) {
- AppShell(navigator, navigationState) {
- SharedTransitionLayout {
- CompositionLocalProvider(
- LocalSharedTransitionScope provides this@SharedTransitionLayout
- ) {
- Column {
- NavDisplay(
- modifier = Modifier.fillMaxSize(),
- onBack = navigator::pop,
- entries = navigationState.toEntries(koinEntryProvider())
- )
+ Box(Modifier.fillMaxSize()) {
+ AppShell(navigator, navigationState) {
+ SharedTransitionLayout {
+ CompositionLocalProvider(
+ LocalSharedTransitionScope provides this@SharedTransitionLayout
+ ) {
+ Column {
+ NavDisplay(
+ modifier = Modifier.fillMaxSize(),
+ onBack = navigator::pop,
+ entries = navigationState.toEntries(koinEntryProvider())
+ )
+ }
}
}
}
- }
- when (currentRoute) {
- Routes.WebView -> WebViewScreen(koinInject())
- else -> {}
+ when (currentRoute) {
+ Routes.WebView -> WebViewScreen(koinInject())
+ else -> {}
+ }
}
}
- }
- content()
- availableAppUpdate?.let { update ->
- AppUpdateDialog(
- update = update,
- onDismiss = appUpdateService::dismissUpdate,
- onIgnore = appUpdateService::ignoreUpdate,
- onUpdate = {
- runCatching { openUrlInBrowser("https://spotube.cc/downloads/") }
- appUpdateService.dismissUpdate()
- })
- }
- availablePluginUpdate?.let { update ->
- PluginUpdateDialog(
- update = update,
- onDismiss = pluginUpdateService::dismissUpdate,
- onIgnore = pluginUpdateService::ignoreUpdate,
- onUpdate = {
- runCatching { openUrlInBrowser(update.info.directDownloadUrl) }
- pluginUpdateService.dismissUpdate()
- })
+ content()
+ availableAppUpdate?.let { update ->
+ AppUpdateDialog(
+ update = update,
+ onDismiss = appUpdateService::dismissUpdate,
+ onIgnore = appUpdateService::ignoreUpdate,
+ onUpdate = {
+ runCatching { openUrlInBrowser("https://spotube.cc/downloads/") }
+ appUpdateService.dismissUpdate()
+ })
+ }
+ availablePluginUpdate?.let { update ->
+ PluginUpdateDialog(
+ update = update,
+ onDismiss = pluginUpdateService::dismissUpdate,
+ onIgnore = pluginUpdateService::ignoreUpdate,
+ onUpdate = {
+ runCatching { openUrlInBrowser(update.info.directDownloadUrl) }
+ pluginUpdateService.dismissUpdate()
+ })
+ }
+ SupportDialog(
+ visible = showSupportDialog,
+ onDismissRequest = { showSupportDialog = false },
+ isReminder = supportDialogIsReminder)
}
}
}
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/DebugConfig.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/DebugConfig.kt
index 249315bf..5be13cfb 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/DebugConfig.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/DebugConfig.kt
@@ -29,5 +29,14 @@ object DebugConfig {
*
* Keep it `false` when committing.
*/
- const val FORCE_SHOW_ONBOARDING: Boolean = true
+ const val FORCE_SHOW_ONBOARDING: Boolean = false
+
+ /**
+ * Flip to `true` while developing to show the support reminder dialog on the next eligible
+ * app launch, ignoring both the 7-day interval and the "Support reminders" opt-out toggle.
+ * It still waits until onboarding is dismissed, and only fires once per launch.
+ *
+ * Keep it `false` when committing.
+ */
+ const val FORCE_SUPPORT_REMINDER: Boolean = false
}
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt
index 3ab5a299..4cd088ed 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt
@@ -50,11 +50,13 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.composeunstyled.currentWindowWidthBreakpoint
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseGenre
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseItem
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseSection
@@ -75,21 +77,26 @@ import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.getPlatform
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
+import dev.krtirtho.spotube.modules.support.LocalSupportPrompt
import dev.krtirtho.spotube.resources.iconsax.Iconsax
+import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart2
import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen
import dev.krtirtho.spotube.resources.iconsax.User
+import org.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject
+import spotube.composeapp.generated.resources.Res
+import spotube.composeapp.generated.resources.support_button_label
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
+import org.jetbrains.compose.resources.Font
+import org.spotube.millennium.adaptive.WindowBreakpoints
+import spotube.composeapp.generated.resources.cookie_regular
@Composable
fun HomeScreen(viewModel: HomeScreenViewModel) {
val navigationCommands = koinInject()
- val platform = getPlatform()
- val isDesktop = platform.type == PlatformType.Windows ||
- platform.type == PlatformType.Linux ||
- platform.type == PlatformType.MacOS
-
+ val openSupport = LocalSupportPrompt.current
+ val isSmallScreen = currentWindowWidthBreakpoint() isBelow WindowBreakpoints.Medium
val state by viewModel.uiState.collectAsStateWithLifecycle()
val listState = rememberLazyListState()
@@ -124,27 +131,55 @@ fun HomeScreen(viewModel: HomeScreenViewModel) {
Column(
modifier = Modifier
.fillMaxSize()
- .background(MillenniumTheme.colors.background)) {
+ .background(MillenniumTheme.colors.background)
+ ) {
ApplicationMainBar(
backButton = false,
title = {
- Text("Browse")
+ if (isSmallScreen) {
+ Text(
+ "Spotube",
+ style = AppTheme.typography.headlineLarge.copy(
+ fontFamily = FontFamily(
+ listOf(Font(Res.font.cookie_regular, weight = FontWeight.Normal))
+ )
+ ),
+ color = AppTheme.colors.onSurface
+ )
+ } else {
+ Text("Browse")
+ }
+
},
actions = {
- if (!isDesktop) {
+ if (isSmallScreen) {
MillenniumIconButton(
onClick = { navigationCommands.navigateTo(Routes.Devices) },
- style = MillenniumIconButtonStyle.Quiet) {
+ style = MillenniumIconButtonStyle.Quiet
+ ) {
MillenniumIcon(
imageVector = Iconsax.IconsaxMirroringScreen,
- contentDescription = "Devices")
+ contentDescription = "Devices"
+ )
}
MillenniumIconButton(
onClick = { navigationCommands.navigateTo(Routes.Jam) },
- style = MillenniumIconButtonStyle.Quiet) {
+ style = MillenniumIconButtonStyle.Quiet
+ ) {
MillenniumIcon(
imageVector = Iconsax.User,
- contentDescription = "Group Jam")
+ contentDescription = "Group Jam"
+ )
+ }
+ val supportLabel = stringResource(Res.string.support_button_label)
+ MillenniumIconButton(
+ onClick = openSupport,
+ style = MillenniumIconButtonStyle.Quiet
+ ) {
+ MillenniumIcon(
+ imageVector = Iconsax.IconsaxHeart2,
+ contentDescription = supportLabel
+ )
}
}
})
@@ -153,7 +188,7 @@ fun HomeScreen(viewModel: HomeScreenViewModel) {
.fillMaxSize()
.weight(1f)
) {
- if (isDesktop) {
+ if (!isSmallScreen) {
HomeContent(
listState = listState,
state = state,
@@ -165,7 +200,8 @@ fun HomeScreen(viewModel: HomeScreenViewModel) {
onRefresh = {
viewModel.refresh()
},
- modifier = Modifier.fillMaxSize()) {
+ modifier = Modifier.fillMaxSize()
+ ) {
HomeContent(
listState = listState,
state = state,
@@ -188,7 +224,8 @@ private fun HomeContent(
listState: androidx.compose.foundation.lazy.LazyListState,
state: HomeScreenState,
onRetry: () -> Unit,
- onGenreSelected: (String) -> Unit) {
+ onGenreSelected: (String) -> Unit
+) {
val shellBottomInset = LocalAppShellBottomInset.current
val contentPadding = remember(shellBottomInset) {
PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset)
@@ -198,31 +235,37 @@ private fun HomeContent(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = contentPadding,
- verticalArrangement = Arrangement.spacedBy(24.dp)) {
+ verticalArrangement = Arrangement.spacedBy(24.dp)
+ ) {
when (state) {
is HomeScreenState.Loading -> {
item {
Column(
modifier = Modifier.fillMaxWidth(),
- verticalArrangement = Arrangement.spacedBy(16.dp)) {
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
repeat(3) {
SkeletonTree(true) {
Column(
modifier = Modifier.fillMaxWidth(),
- verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(180.dp)
- .padding(horizontal = 16.dp))
+ .padding(horizontal = 16.dp)
+ )
LazyRow(
horizontalArrangement = Arrangement.spacedBy(12.dp),
- contentPadding = PaddingValues(horizontal = 16.dp)) {
+ contentPadding = PaddingValues(horizontal = 16.dp)
+ ) {
items(4) {
PlayableCard(
title = "Item Title",
subtitle = "Subtitle",
- imageURL = "https://placehold.co/600x400")
+ imageURL = "https://placehold.co/600x400"
+ )
}
}
}
@@ -236,7 +279,8 @@ private fun HomeContent(
item {
ErrorDisplay(
errorMessage = state.message,
- onRetry = onRetry)
+ onRetry = onRetry
+ )
}
}
@@ -252,7 +296,8 @@ private fun HomeContent(
GenreTabs(
genres = state.genres,
selectedGenreId = state.selectedGenreId,
- onGenreSelected = onGenreSelected)
+ onGenreSelected = onGenreSelected
+ )
}
}
@@ -263,20 +308,24 @@ private fun HomeContent(
SkeletonTree(true) {
Column(
modifier = Modifier.fillMaxWidth(),
- verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(180.dp)
- .padding(horizontal = 16.dp))
+ .padding(horizontal = 16.dp)
+ )
LazyRow(
horizontalArrangement = Arrangement.spacedBy(12.dp),
- contentPadding = PaddingValues(horizontal = 16.dp)) {
+ contentPadding = PaddingValues(horizontal = 16.dp)
+ ) {
items(4) {
PlayableCard(
title = "Item Title",
subtitle = "Subtitle",
- imageURL = "https://placehold.co/600x400")
+ imageURL = "https://placehold.co/600x400"
+ )
}
}
}
@@ -287,7 +336,8 @@ private fun HomeContent(
HomeSection(
title = section.title,
subtitle = section.description,
- items = section.items)
+ items = section.items
+ )
}
}
@@ -296,20 +346,24 @@ private fun HomeContent(
SkeletonTree(true) {
Column(
modifier = Modifier.fillMaxWidth(),
- verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(180.dp)
- .padding(horizontal = 16.dp))
+ .padding(horizontal = 16.dp)
+ )
LazyRow(
horizontalArrangement = Arrangement.spacedBy(12.dp),
- contentPadding = PaddingValues(horizontal = 16.dp)) {
+ contentPadding = PaddingValues(horizontal = 16.dp)
+ ) {
items(4) {
PlayableCard(
title = "Item Title",
subtitle = "Subtitle",
- imageURL = "https://placehold.co/600x400")
+ imageURL = "https://placehold.co/600x400"
+ )
}
}
}
@@ -327,30 +381,36 @@ private fun HomeSection(
title: String,
subtitle: String? = null,
items: List,
- modifier: Modifier = Modifier) {
+ modifier: Modifier = Modifier
+) {
val rowState = rememberLazyListState()
Column(
modifier = modifier.fillMaxWidth(),
- verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
if (!subtitle.isNullOrEmpty()) Text(
text = subtitle,
style = AppTheme.typography.labelMedium.copy(
color = AppTheme.colors.secondary,
- fontWeight = FontWeight.Medium),
- modifier = Modifier.padding(horizontal = 16.dp))
+ fontWeight = FontWeight.Medium
+ ),
+ modifier = Modifier.padding(horizontal = 16.dp)
+ )
Text(
text = title,
style = AppTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
- modifier = Modifier.padding(horizontal = 16.dp).offset(y = (-12).dp))
+ modifier = Modifier.padding(horizontal = 16.dp).offset(y = (-12).dp)
+ )
LazyRow(
state = rowState,
modifier = Modifier.dragScrollable(rowState),
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
- verticalAlignment = Alignment.Top) {
+ verticalAlignment = Alignment.Top
+ ) {
items(items) { browseItem ->
when (browseItem) {
is MetadataBrowseItem.Album -> AlbumCard(album = browseItem.data)
@@ -368,14 +428,16 @@ private fun HomeSection(
private fun GenreTabs(
genres: List,
selectedGenreId: String?,
- onGenreSelected: (String) -> Unit) {
+ onGenreSelected: (String) -> Unit
+) {
val rowState = rememberLazyListState()
LazyRow(
state = rowState,
modifier = Modifier.dragScrollable(rowState),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 4.dp),
- horizontalArrangement = Arrangement.spacedBy(6.dp)) {
+ horizontalArrangement = Arrangement.spacedBy(6.dp)
+ ) {
items(genres) { genre ->
MillenniumButton(
onClick = { onGenreSelected(genre.id) },
@@ -383,7 +445,8 @@ private fun GenreTabs(
MillenniumButtonStyle.Primary
} else {
MillenniumButtonStyle.Secondary
- }) {
+ }
+ ) {
Text(genre.name)
}
}
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt
index b3dc1c60..8d01591d 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt
@@ -87,7 +87,11 @@ data class UserSettings(
// Updates
val autoCheckForUpdates: Boolean = true,
val ignoredAppUpdateVersion: String? = null,
- val ignoredPluginUpdateVersions: Map = emptyMap())
+ val ignoredPluginUpdateVersions: Map = emptyMap(),
+
+ // Support
+ val supportRemindersEnabled: Boolean = true,
+ val lastSupportReminderEpochMs: Long = 0L)
/**
* Configuration for the MQTT broker used by Group Jam. The host is a placeholder
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt
index 9a059b31..68b23df1 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt
@@ -50,6 +50,7 @@ import dev.krtirtho.spotube.modules.settings.sections.jamSection
import dev.krtirtho.spotube.modules.settings.sections.languageRegionSection
import dev.krtirtho.spotube.modules.settings.sections.playbackSection
import dev.krtirtho.spotube.modules.settings.sections.pluginsSection
+import dev.krtirtho.spotube.modules.settings.sections.supportSection
import dev.krtirtho.spotube.modules.settings.sections.updatesSection
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
import org.jetbrains.compose.resources.stringResource
@@ -154,6 +155,9 @@ private fun CompactSettingsContent(
settings = settings,
settingsViewModel = settingsViewModel)
}
+ supportSection(
+ settings = settings,
+ settingsViewModel = settingsViewModel)
updatesSection(
settings = settings,
settingsViewModel = settingsViewModel)
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsSidebarLayout.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsSidebarLayout.kt
index b64109b0..ec1c5649 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsSidebarLayout.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsSidebarLayout.kt
@@ -71,6 +71,7 @@ import dev.krtirtho.spotube.modules.settings.sections.desktopSection
import dev.krtirtho.spotube.modules.settings.sections.downloadsSection
import dev.krtirtho.spotube.modules.settings.sections.languageRegionSection
import dev.krtirtho.spotube.modules.settings.sections.playbackSection
+import dev.krtirtho.spotube.modules.settings.sections.supportSection
import dev.krtirtho.spotube.modules.settings.sections.SettingsPageTitle
import dev.krtirtho.spotube.modules.settings.sections.updatesSection
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
@@ -81,6 +82,7 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxColorSwatch
import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive
import dev.krtirtho.spotube.resources.iconsax.IconsaxDocumentDownload
import dev.krtirtho.spotube.resources.iconsax.IconsaxForbidden
+import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart2
import dev.krtirtho.spotube.resources.iconsax.IconsaxImportArrow2
import dev.krtirtho.spotube.resources.iconsax.IconsaxInformation
import dev.krtirtho.spotube.resources.iconsax.IconsaxLanguageSquare
@@ -102,6 +104,7 @@ import spotube.composeapp.generated.resources.settings_section_jam
import spotube.composeapp.generated.resources.settings_section_language_region
import spotube.composeapp.generated.resources.settings_section_playback
import spotube.composeapp.generated.resources.settings_section_plugins
+import spotube.composeapp.generated.resources.settings_section_support
private val SettingsSidebarWidth = 236.dp
@@ -123,6 +126,7 @@ internal enum class SettingsTab(
Caching(Res.string.settings_section_caching, Iconsax.IconsaxDocumentDownload),
Downloads(Res.string.settings_section_downloads, Iconsax.IconsaxDirectboxReceive),
Desktop(Res.string.settings_section_desktop, Iconsax.IconsaxImportArrow2),
+ Support(Res.string.settings_section_support, Iconsax.IconsaxHeart2),
About(Res.string.settings_section_about, Iconsax.IconsaxInformation),
}
@@ -317,6 +321,12 @@ private fun SettingsTabContent(
SettingsTab.Desktop ->
desktopSection(settings, settingsViewModel, includeHeader = false)
+ SettingsTab.Support ->
+ supportSection(
+ settings = settings,
+ settingsViewModel = settingsViewModel,
+ includeHeader = false)
+
SettingsTab.About ->
updatesSection(settings, settingsViewModel, includeHeader = false)
}
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/InteractiveSettingCards.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/InteractiveSettingCards.kt
index 557f5639..58fe3c3c 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/InteractiveSettingCards.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/InteractiveSettingCards.kt
@@ -74,7 +74,10 @@ internal fun SwitchSettingCard(
MillenniumSwitch(
checked = checked,
onCheckedChange = onCheckedChange,
- label = title)
+ // The row itself already renders the title/subtitle, so the switch must not
+ // draw a second visible label next to it.
+ label = "",
+ contentSpacing = 0.dp)
},
onClick = {
onCheckedChange(!checked)
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/SupportSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/SupportSection.kt
new file mode 100644
index 00000000..d1482005
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/SupportSection.kt
@@ -0,0 +1,76 @@
+/*
+ * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package dev.krtirtho.spotube.modules.settings.sections
+
+import androidx.compose.foundation.lazy.LazyListScope
+import dev.krtirtho.spotube.modules.settings.SettingsViewModel
+import dev.krtirtho.spotube.modules.settings.UserSettings
+import dev.krtirtho.spotube.modules.settings.components.SettingCardItem
+import dev.krtirtho.spotube.modules.settings.components.SwitchSettingCard
+import dev.krtirtho.spotube.modules.support.donationOptions
+import dev.krtirtho.spotube.modules.support.openDonation
+import dev.krtirtho.spotube.resources.iconsax.Iconsax
+import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart2
+import org.jetbrains.compose.resources.stringResource
+import spotube.composeapp.generated.resources.Res
+import spotube.composeapp.generated.resources.settings_section_support
+import spotube.composeapp.generated.resources.settings_support_reminders_subtitle
+import spotube.composeapp.generated.resources.settings_support_reminders_title
+
+/** Support settings: the periodic reminder toggle plus the donation destinations. */
+internal fun LazyListScope.supportSection(
+ settings: UserSettings,
+ settingsViewModel: SettingsViewModel,
+ includeHeader: Boolean = true) {
+ if (includeHeader) {
+ settingsSectionHeader(Res.string.settings_section_support)
+ }
+ settingsSectionCard(
+ items = buildList {
+ add {
+ SwitchSettingCard(
+ title = stringResource(Res.string.settings_support_reminders_title),
+ subtitle = stringResource(Res.string.settings_support_reminders_subtitle),
+ icon = {
+ SettingsItemIcon(
+ imageVector = Iconsax.IconsaxHeart2,
+ contentDescription = stringResource(
+ Res.string.settings_support_reminders_title))
+ },
+ checked = settings.supportRemindersEnabled,
+ onCheckedChange = { enabled ->
+ settingsViewModel.updateSettings {
+ copy(supportRemindersEnabled = enabled)
+ }
+ })
+ }
+ donationOptions.forEach { option ->
+ add {
+ SettingCardItem(
+ title = stringResource(option.title),
+ subtitle = stringResource(option.subtitle),
+ icon = {
+ SettingsItemIcon(
+ imageVector = option.icon,
+ contentDescription = stringResource(option.title))
+ },
+ onClick = { openDonation(option) })
+ }
+ }
+ })
+}
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt
index 1d28b09e..2a5d0ed8 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt
@@ -46,10 +46,13 @@ import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.ui.theming.AppTheme
import dev.krtirtho.spotube.modules.library.LibraryState
import dev.krtirtho.spotube.modules.library.LibraryTab
+import dev.krtirtho.spotube.modules.support.LocalSupportPrompt
import dev.krtirtho.spotube.resources.iconsax.Iconsax
+import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart2
import dev.krtirtho.spotube.resources.iconsax.IconsaxSetting2
import dev.krtirtho.spotube.sidebarTabs
import org.jetbrains.compose.resources.Font
+import org.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject
import org.spotube.millennium.composables.MillenniumSidebar
import org.spotube.millennium.composables.MillenniumSidebarContent
@@ -63,6 +66,8 @@ import org.spotube.millennium.composables.MillenniumSidebarMenu
import org.spotube.millennium.composables.MillenniumSidebarTrigger
import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.cookie_regular
+import spotube.composeapp.generated.resources.settings_screen_title
+import spotube.composeapp.generated.resources.support_button_label
@Composable
fun AppSidebar(
@@ -74,6 +79,7 @@ fun AppSidebar(
var expanded by rememberSaveable { mutableStateOf(true) }
val groupExpansion = remember { mutableStateMapOf() }
val currentLibraryTab by libraryState.currentTab.collectAsState()
+ val openSupport = LocalSupportPrompt.current
MillenniumSidebar(
modifier = modifier,
@@ -143,10 +149,17 @@ fun AppSidebar(
}
}
MillenniumSidebarFooter {
+ val supportLabel = stringResource(Res.string.support_button_label)
MillenniumSidebarItem(
- label = "Settings",
+ label = supportLabel,
+ onClick = { openSupport() },
+ contentDescription = supportLabel,
+ icon = Iconsax.IconsaxHeart2)
+ MillenniumSidebarItem(
+ label = stringResource(Res.string.settings_screen_title),
onClick = { navigator.navigate(Routes.Settings) },
selected = navigationState.topLevelRoute == Routes.Settings,
+ contentDescription = stringResource(Res.string.settings_screen_title),
icon = Iconsax.IconsaxSetting2)
}
}
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/support/Support.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/support/Support.kt
new file mode 100644
index 00000000..fc1c59a8
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/support/Support.kt
@@ -0,0 +1,151 @@
+/*
+ * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package dev.krtirtho.spotube.modules.support
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.size
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.staticCompositionLocalOf
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.unit.dp
+import com.composeunstyled.Text
+import dev.krtirtho.spotube.core.ui.theming.AppTheme
+import dev.krtirtho.spotube.openUrlInBrowser
+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 org.jetbrains.compose.resources.stringResource
+import org.spotube.millennium.composables.MillenniumButton
+import org.spotube.millennium.composables.MillenniumButtonStyle
+import org.spotube.millennium.composables.MillenniumDialog
+import org.spotube.millennium.composables.MillenniumIcon
+import spotube.composeapp.generated.resources.Res
+import spotube.composeapp.generated.resources.support_dialog_dismiss
+import spotube.composeapp.generated.resources.support_dialog_message
+import spotube.composeapp.generated.resources.support_dialog_reminder_hint
+import spotube.composeapp.generated.resources.support_dialog_title
+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
+
+/** A single donation destination offered across the app. */
+data class DonationOption(
+ val id: String,
+ val title: StringResource,
+ val subtitle: StringResource,
+ val url: String,
+ val icon: ImageVector)
+
+/** The donation destinations shown in onboarding, settings and the support prompt. */
+val donationOptions: List = 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))
+
+/** Minimum time between two automatic support reminders. */
+const val SupportReminderIntervalMillis: Long = 7L * 24 * 60 * 60 * 1000
+
+/**
+ * Opens the support prompt from anywhere inside the app shell. Hosted at the app root so the
+ * sidebar, home top bar and the periodic reminder all drive the same dialog.
+ */
+val LocalSupportPrompt = staticCompositionLocalOf { {} }
+
+/** Opens a donation destination in the user's browser. */
+fun openDonation(option: DonationOption) {
+ runCatching { openUrlInBrowser(option.url) }
+}
+
+/** A dialog that lets the user pick a way to support the project. */
+@Composable
+fun SupportDialog(
+ visible: Boolean,
+ onDismissRequest: () -> Unit,
+ isReminder: Boolean = false) {
+ if (!visible) return
+ MillenniumDialog(
+ visible = true,
+ onDismissRequest = onDismissRequest,
+ title = stringResource(Res.string.support_dialog_title)) {
+ Text(
+ stringResource(Res.string.support_dialog_message),
+ style = AppTheme.typography.bodyMedium,
+ color = AppTheme.colors.onSurfaceVariant)
+ if (isReminder) {
+ Text(
+ stringResource(Res.string.support_dialog_reminder_hint),
+ style = AppTheme.typography.bodySmall,
+ color = AppTheme.colors.onSurfaceVariant)
+ }
+ donationOptions.forEach { option ->
+ MillenniumButton(
+ onClick = {
+ openDonation(option)
+ onDismissRequest()
+ },
+ modifier = Modifier.fillMaxWidth(),
+ style = MillenniumButtonStyle.Secondary) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ MillenniumIcon(
+ imageVector = option.icon,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ tint = if (option.id == "patreon") {
+ AppTheme.colors.onSurface
+ } else {
+ Color.Unspecified
+ })
+ Text(stringResource(option.title))
+ }
+ }
+ }
+ MillenniumButton(
+ onClick = onDismissRequest,
+ modifier = Modifier.fillMaxWidth(),
+ style = MillenniumButtonStyle.Quiet) {
+ Text(stringResource(Res.string.support_dialog_dismiss))
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeContent.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeContent.kt
index bf36e128..b40a78da 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeContent.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeContent.kt
@@ -17,18 +17,8 @@
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
@@ -50,23 +40,3 @@ internal val welcomeStepLabels: List = listOf(
Res.string.welcome_step_support,
Res.string.welcome_step_plugins,
Res.string.welcome_step_guide)
-
-internal val welcomeDonations: List = 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))
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeModels.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeModels.kt
index c41925d5..34d7be18 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeModels.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeModels.kt
@@ -17,17 +17,9 @@
package dev.krtirtho.spotube.modules.welcome
-import androidx.compose.ui.graphics.vector.ImageVector
+import dev.krtirtho.spotube.modules.support.DonationOption
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,
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeViewModel.kt
index a2868fb8..02120e8f 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeViewModel.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeViewModel.kt
@@ -19,6 +19,7 @@ package dev.krtirtho.spotube.modules.welcome
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import dev.krtirtho.spotube.modules.support.donationOptions
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -39,7 +40,7 @@ class WelcomeViewModel(
pageCount = pageCount,
isLastPage = index == pageCount - 1,
primaryLabel = welcomePrimaryLabels[index.coerceIn(0, pageCount - 1)],
- donations = welcomeDonations)
+ donations = donationOptions)
}
.stateIn(
scope = viewModelScope,
@@ -49,7 +50,7 @@ class WelcomeViewModel(
pageCount = welcomePrimaryLabels.size,
isLastPage = welcomePrimaryLabels.size == 1,
primaryLabel = welcomePrimaryLabels.first(),
- donations = welcomeDonations))
+ donations = donationOptions))
fun goToPage(index: Int) {
currentPageIndex.update { index.coerceIn(0, welcomePrimaryLabels.lastIndex) }
@@ -65,7 +66,7 @@ class WelcomeViewModel(
fun openDonation(url: String) {
// Only destinations owned by our static funding list can be opened here.
- if (welcomeDonations.none { it.url == url }) return
+ if (donationOptions.none { it.url == url }) return
runCatching { dev.krtirtho.spotube.openUrlInBrowser(url) }
}
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/DonationPage.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/DonationPage.kt
index 8663aa17..30f516f7 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/DonationPage.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/DonationPage.kt
@@ -17,7 +17,7 @@ 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.modules.welcome.DonationOption
+import dev.krtirtho.spotube.modules.support.DonationOption
import org.jetbrains.compose.resources.stringResource
import spotube.composeapp.generated.resources.*