feat(support): implement support reminder dialog and donation options

This commit is contained in:
Kingkor Roy Tirtho 2026-10-14 09:51:07 +06:00
parent 8efcbd4af4
commit d720467338
15 changed files with 485 additions and 128 deletions

View File

@ -31,6 +31,7 @@
<string name="settings_section_caching">Caching</string>
<string name="settings_section_desktop">Desktop</string>
<string name="settings_section_updates">Updates</string>
<string name="settings_section_support">Support</string>
<string name="settings_section_about">About</string>
<string name="settings_action_close">Close</string>
@ -301,6 +302,15 @@
<string name="welcome_donate_action">Sponsor</string>
<string name="welcome_donate_open_content_description">Open %1$s</string>
<!-- Support / donations -->
<string name="support_button_label">Support Spotube</string>
<string name="support_dialog_title">Support Spotube</string>
<string name="support_dialog_message">Spotube is free, libre and open source — no ads, no subscriptions, no tracking. If it brings you joy, consider supporting the project.</string>
<string name="support_dialog_reminder_hint">You can turn these reminders off in Settings → Support.</string>
<string name="support_dialog_dismiss">Maybe later</string>
<string name="settings_support_reminders_title">Support reminders</string>
<string name="settings_support_reminders_subtitle">Show a gentle reminder to support the project every 7 days.</string>
<!-- Page 3: plugins -->
<string name="welcome_plugins_eyebrow">Modular Architecture</string>
<string name="welcome_plugins_title">Choose where your music comes from.</string>

View File

@ -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,6 +192,10 @@ fun App(
}
SpotubeTheme(settings = userSettings) {
CompositionLocalProvider(LocalSupportPrompt provides {
supportDialogIsReminder = false
showSupportDialog = true
}) {
Box(modifier = Modifier.fillMaxSize()) {
val currentRoute =
navigationState.backStacks[navigationState.topLevelRoute]?.last()
@ -197,5 +243,10 @@ fun App(
pluginUpdateService.dismissUpdate()
})
}
SupportDialog(
visible = showSupportDialog,
onDismissRequest = { showSupportDialog = false },
isReminder = supportDialogIsReminder)
}
}
}

View File

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

View File

@ -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<NavigationCommands>()
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 = {
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<MetadataBrowseItem>,
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<MetadataBrowseGenre>,
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)
}
}

View File

@ -87,7 +87,11 @@ data class UserSettings(
// Updates
val autoCheckForUpdates: Boolean = true,
val ignoredAppUpdateVersion: String? = null,
val ignoredPluginUpdateVersions: Map<String, String> = emptyMap())
val ignoredPluginUpdateVersions: Map<String, String> = emptyMap(),
// Support
val supportRemindersEnabled: Boolean = true,
val lastSupportReminderEpochMs: Long = 0L)
/**
* Configuration for the MQTT broker used by Group Jam. The host is a placeholder

View File

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

View File

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

View File

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

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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) })
}
}
})
}

View File

@ -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<String, Boolean>() }
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)
}
}

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<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))
/** 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))
}
}
}

View File

@ -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<StringResource> = listOf(
Res.string.welcome_step_support,
Res.string.welcome_step_plugins,
Res.string.welcome_step_guide)
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))

View File

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

View File

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

View File

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