From d720467338617c20201a4a9911bdda984670912f Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Wed, 14 Oct 2026 09:51:07 +0600 Subject: [PATCH] feat(support): implement support reminder dialog and donation options --- .../composeResources/values/strings.xml | 10 ++ .../kotlin/dev/krtirtho/spotube/App.kt | 131 ++++++++++----- .../dev/krtirtho/spotube/core/DebugConfig.kt | 11 +- .../spotube/modules/home/HomeScreen.kt | 145 ++++++++++++----- .../modules/settings/SettingsModels.kt | 6 +- .../modules/settings/SettingsScreen.kt | 4 + .../modules/settings/SettingsSidebarLayout.kt | 10 ++ .../components/InteractiveSettingCards.kt | 5 +- .../settings/sections/SupportSection.kt | 76 +++++++++ .../spotube/modules/shell/AppSidebar.kt | 15 +- .../spotube/modules/support/Support.kt | 151 ++++++++++++++++++ .../spotube/modules/welcome/WelcomeContent.kt | 30 ---- .../spotube/modules/welcome/WelcomeModels.kt | 10 +- .../modules/welcome/WelcomeViewModel.kt | 7 +- .../modules/welcome/pages/DonationPage.kt | 2 +- 15 files changed, 485 insertions(+), 128 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/SupportSection.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/support/Support.kt 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.*