mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-16 11:16:12 -04:00
refactor(upgrade): Converge GPlay billing on the canonical stack
Replaces capod's older billing core, upgrade UI and their tests with the canonical sdmaid-se stack at the pinned revision. Core (gplay): BillingManager/BillingConnection/BillingConnectionProvider on billing 8.3 with the centralized connect loop, merging purchases-listener overlay and the canonical ack pipeline; the dying ack collector, the ackedTokens gate and the in-billing foreground loop are gone. Full canonical exception set (internal/network/offer-unavailable added), OurSku with capod's product ids, BillingCache with snapshot()/episode-guarded stampLastProState. FOSS: UpgradeControlFoss becomes UpgradeRepoFoss and exposes the canonical API surface over capod's RETAINED FossUpgrade/FossCache schema — existing supporter records must keep decoding. Diagnostics: UpgradeDiagnostics + gplay/foss implementations, read by RecorderModule next to CurriculumVitae's Pro history as two independent, isolated header reads. UI: canonical upgrade screens for both flavors under common/upgrade/ui with capod chrome (M3 AlertDialog keeping rotation-safety, capod Scaffold, capod previews). Nav.Main.Upgrade gains `forced`. Entitlement refresh moves to a per-resume, unthrottled MainActivity call. Strings reuse capod's existing translated ids wherever equivalent; only referenced-but-missing ones are authored. mockk 1.12.4 -> 1.14.9: 1.12.4 cannot synthesize a sealed-class return value while recording, which the ported restore tests need.
This commit is contained in:
@@ -4,7 +4,8 @@ import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import eu.darken.capod.common.upgrade.core.UpgradeControlFoss
|
||||
import eu.darken.capod.common.upgrade.core.UpgradeDiagnosticsFoss
|
||||
import eu.darken.capod.common.upgrade.core.UpgradeRepoFoss
|
||||
import javax.inject.Singleton
|
||||
|
||||
@InstallIn(SingletonComponent::class)
|
||||
@@ -12,6 +13,10 @@ import javax.inject.Singleton
|
||||
abstract class UpgradeModule {
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun control(foss: UpgradeControlFoss): UpgradeRepo
|
||||
abstract fun control(foss: UpgradeRepoFoss): UpgradeRepo
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun diagnostics(foss: UpgradeDiagnosticsFoss): UpgradeDiagnostics
|
||||
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package eu.darken.capod.common.upgrade.core
|
||||
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.upgrade.UpgradeRepo
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import eu.darken.capod.common.datastore.valueBlocking
|
||||
|
||||
@Singleton
|
||||
class UpgradeControlFoss @Inject constructor(
|
||||
private val fossCache: FossCache,
|
||||
) : UpgradeRepo {
|
||||
|
||||
override val storeSite: String = STORE_SITE
|
||||
override val upgradeSite: String = UPGRADE_SITE
|
||||
override val betaSite: String = BETA_SITE
|
||||
|
||||
override val upgradeInfo: Flow<UpgradeRepo.Info> = fossCache.upgrade.flow.map { data ->
|
||||
if (data == null) {
|
||||
Info()
|
||||
} else {
|
||||
Info(
|
||||
isPro = true,
|
||||
upgradedAt = data.upgradedAt,
|
||||
upgradeReason = data.reason
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun upgrade(reason: FossUpgrade.Reason) {
|
||||
fossCache.upgrade.valueBlocking = FossUpgrade(
|
||||
upgradedAt = Instant.now(),
|
||||
reason = reason
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun refresh() {
|
||||
log(TAG) { "refresh()" }
|
||||
// The FOSS entitlement is a local cache read that the upgradeInfo flow already observes,
|
||||
// there is no remote state to reconcile.
|
||||
}
|
||||
|
||||
data class Info(
|
||||
override val isPro: Boolean = false,
|
||||
override val upgradedAt: Instant? = null,
|
||||
val upgradeReason: FossUpgrade.Reason? = null,
|
||||
override val error: Throwable? = null,
|
||||
) : UpgradeRepo.Info {
|
||||
override val type: UpgradeRepo.Type = UpgradeRepo.Type.FOSS
|
||||
|
||||
// The FOSS entitlement is a local cache read — authoritative from the first emission,
|
||||
// there is no billing handshake to wait out.
|
||||
override val isSettled: Boolean = true
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val STORE_SITE = "https://github.com/d4rken-org/capod/releases"
|
||||
private const val UPGRADE_SITE = "https://github.com/sponsors/d4rken"
|
||||
private const val BETA_SITE = "https://play.google.com/apps/testing/eu.darken.capod"
|
||||
private val TAG = logTag("Upgrade", "Foss", "Control")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package eu.darken.capod.common.upgrade.core
|
||||
|
||||
import eu.darken.capod.common.upgrade.UpgradeDiagnostics
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* FOSS has no store entitlement to reconcile: the upgrade state is a local sponsor record, already
|
||||
* covered by the existing header fields. Nothing to add.
|
||||
*/
|
||||
@Singleton
|
||||
class UpgradeDiagnosticsFoss @Inject constructor() : UpgradeDiagnostics {
|
||||
|
||||
override suspend fun debugInfo(): String? = null
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package eu.darken.capod.common.upgrade.core
|
||||
|
||||
import eu.darken.capod.common.WebpageTool
|
||||
import eu.darken.capod.common.coroutine.AppScope
|
||||
import eu.darken.capod.common.datastore.value
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.flow.setupCommonEventHandlers
|
||||
import eu.darken.capod.common.upgrade.UpgradeRepo
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.shareIn
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class UpgradeRepoFoss @Inject constructor(
|
||||
@AppScope private val appScope: CoroutineScope,
|
||||
private val fossCache: FossCache,
|
||||
private val webpageTool: WebpageTool,
|
||||
) : UpgradeRepo {
|
||||
|
||||
override val storeSite: String = STORE_SITE
|
||||
override val upgradeSite: String = UPGRADE_SITE
|
||||
override val betaSite: String = BETA_SITE
|
||||
|
||||
private val refreshTrigger = MutableStateFlow(UUID.randomUUID())
|
||||
|
||||
override val upgradeInfo: Flow<UpgradeRepo.Info> = combine(
|
||||
fossCache.upgrade.flow,
|
||||
refreshTrigger
|
||||
) { data, _ ->
|
||||
if (data == null) {
|
||||
Info()
|
||||
} else {
|
||||
Info(
|
||||
isPro = true,
|
||||
upgradedAt = data.upgradedAt,
|
||||
upgradeReason = data.reason,
|
||||
)
|
||||
}
|
||||
}
|
||||
.setupCommonEventHandlers(TAG) { "upgradeInfo" }
|
||||
.shareIn(appScope, SharingStarted.WhileSubscribed(3000L, 0L), replay = 1)
|
||||
|
||||
fun openGithubSponsorsPage() = appScope.launch {
|
||||
log(TAG) { "openGithubSponsorsPage()" }
|
||||
webpageTool.open(upgradeSite)
|
||||
}
|
||||
|
||||
// Writes capod's RETAINED persistence schema: existing supporter records are serialized with
|
||||
// `reason` (foss.upgrade.reason.*). Adopting canonical's `upgradeType` schema would decode
|
||||
// every stored record as null and strip those supporters' entitlement.
|
||||
internal suspend fun persistUpgrade() {
|
||||
log(TAG) { "persistUpgrade()" }
|
||||
fossCache.upgrade.value(
|
||||
FossUpgrade(
|
||||
upgradedAt = Instant.now(),
|
||||
reason = FossUpgrade.Reason.DONATED,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun refresh() {
|
||||
log(TAG) { "refresh()" }
|
||||
refreshTrigger.value = UUID.randomUUID()
|
||||
}
|
||||
|
||||
data class Info(
|
||||
override val isPro: Boolean = false,
|
||||
override val upgradedAt: Instant? = null,
|
||||
val upgradeReason: FossUpgrade.Reason? = null,
|
||||
override val error: Throwable? = null,
|
||||
) : UpgradeRepo.Info {
|
||||
override val type: UpgradeRepo.Type = UpgradeRepo.Type.FOSS
|
||||
|
||||
// The FOSS entitlement is a local cache read — authoritative from the first emission,
|
||||
// there is no billing handshake to wait out.
|
||||
override val isSettled: Boolean = true
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val STORE_SITE = "https://github.com/d4rken-org/capod/releases"
|
||||
private const val UPGRADE_SITE = "https://github.com/sponsors/d4rken"
|
||||
private const val BETA_SITE = "https://play.google.com/apps/testing/eu.darken.capod"
|
||||
private val TAG = logTag("Upgrade", "Foss", "Repo")
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
package eu.darken.capod.upgrade.ui
|
||||
package eu.darken.capod.common.upgrade.ui
|
||||
|
||||
import androidx.navigation3.runtime.EntryProviderScope
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
@@ -13,7 +13,7 @@ import javax.inject.Inject
|
||||
|
||||
class UpgradeNavigation @Inject constructor() : NavigationEntry {
|
||||
override fun EntryProviderScope<NavKey>.setup() {
|
||||
entry<Nav.Main.Upgrade> { key -> UpgradeScreenHost(manage = key.manage) }
|
||||
entry<Nav.Main.Upgrade> { key -> UpgradeScreenHost(route = key) }
|
||||
}
|
||||
|
||||
@Module
|
||||
@@ -0,0 +1,299 @@
|
||||
package eu.darken.capod.common.upgrade.ui
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.twotone.AutoAwesome
|
||||
import androidx.compose.material.icons.twotone.Favorite
|
||||
import androidx.compose.material.icons.twotone.Info
|
||||
import androidx.compose.material.icons.twotone.Verified
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.compose.Preview2
|
||||
import eu.darken.capod.common.compose.PreviewWrapper
|
||||
import eu.darken.capod.common.error.ErrorEventHandler
|
||||
import eu.darken.capod.common.navigation.NavigationEventHandler
|
||||
import eu.darken.capod.common.navigation.Nav
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
// Which presentation the FOSS upgrade screen shows: the classic support pitch, or one of the
|
||||
// status views behind the settings "upgrade status" entry.
|
||||
internal enum class FossUpgradeView {
|
||||
PITCH,
|
||||
STATUS_FREE,
|
||||
STATUS_UPGRADED,
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UpgradeScreenHost(
|
||||
route: Nav.Main.Upgrade = Nav.Main.Upgrade(),
|
||||
vm: UpgradeViewModel = hiltViewModel(),
|
||||
) {
|
||||
LaunchedEffect(route) { vm.bindRoute(route) }
|
||||
ErrorEventHandler(vm)
|
||||
NavigationEventHandler(vm)
|
||||
|
||||
val context = LocalContext.current
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val sponsorReturnTracker = remember { SponsorReturnTracker() }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
vm.snackbarEvents.collect { stringRes ->
|
||||
snackbarHostState.showSnackbar(context.getString(stringRes))
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
vm.toastEvents.collect { stringRes ->
|
||||
Toast.makeText(context, context.getString(stringRes), Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
|
||||
sponsorReturnTracker.onStop()
|
||||
}
|
||||
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
|
||||
if (sponsorReturnTracker.consumeResumeReturn()) {
|
||||
vm.checkSponsorReturn()
|
||||
}
|
||||
}
|
||||
|
||||
val view by vm.state.collectAsStateWithLifecycle()
|
||||
|
||||
UpgradeScreen(
|
||||
// Until the route binding lands (one frame): the default route keeps rendering the pitch
|
||||
// exactly as before, only the manage route waits for the status decision.
|
||||
view = view ?: FossUpgradeView.PITCH.takeIf { !route.manage },
|
||||
snackbarHostState = snackbarHostState,
|
||||
onGithubSponsors = vm::goGithubSponsors,
|
||||
onShowUpgradeOptions = vm::onShowUpgradeOptions,
|
||||
onNavigateUp = vm::navUp,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun UpgradeScreen(
|
||||
view: FossUpgradeView? = FossUpgradeView.PITCH,
|
||||
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
|
||||
onGithubSponsors: () -> Unit = {},
|
||||
onShowUpgradeOptions: () -> Unit = {},
|
||||
onNavigateUp: () -> Unit = {},
|
||||
) {
|
||||
UpgradeScreenScaffold(
|
||||
// Status views describe the existing install, not a support ask — they get the composed
|
||||
// flavor title, with the postfix highlighted for supporters like the dashboard does it.
|
||||
title = if (view == FossUpgradeView.PITCH) {
|
||||
AnnotatedString(stringResource(R.string.settings_upgrade_status_label))
|
||||
} else {
|
||||
upgradeScreenTitle(upgraded = view == FossUpgradeView.STATUS_UPGRADED)
|
||||
},
|
||||
onNavigateUp = onNavigateUp,
|
||||
snackbarHostState = snackbarHostState,
|
||||
) { paddingValues ->
|
||||
when (view) {
|
||||
null -> Unit // Route not bound yet (single frame); content lands with the next state.
|
||||
FossUpgradeView.PITCH -> UpgradePitchContent(
|
||||
paddingValues = paddingValues,
|
||||
onGithubSponsors = onGithubSponsors,
|
||||
)
|
||||
|
||||
FossUpgradeView.STATUS_FREE -> UpgradeStatusFreeContent(
|
||||
paddingValues = paddingValues,
|
||||
onShowUpgradeOptions = onShowUpgradeOptions,
|
||||
)
|
||||
|
||||
FossUpgradeView.STATUS_UPGRADED -> UpgradeStatusUpgradedContent(
|
||||
paddingValues = paddingValues,
|
||||
onGithubSponsors = onGithubSponsors,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UpgradePitchContent(
|
||||
paddingValues: PaddingValues,
|
||||
onGithubSponsors: () -> Unit,
|
||||
) {
|
||||
UpgradeScreenContent(
|
||||
paddingValues = paddingValues,
|
||||
) {
|
||||
UpgradeHeader(
|
||||
mascotSize = 104.dp,
|
||||
)
|
||||
|
||||
UpgradePreambleCard(
|
||||
text = stringResource(R.string.upgrade_foss_preamble),
|
||||
colors = CardDefaults.elevatedCardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
),
|
||||
)
|
||||
|
||||
UpgradeSectionCard(
|
||||
title = stringResource(R.string.upgrade_screen_why_title),
|
||||
icon = Icons.TwoTone.AutoAwesome,
|
||||
) {
|
||||
UpgradeFeatureList(text = upgradeBenefitsText())
|
||||
}
|
||||
|
||||
UpgradeSectionCard(
|
||||
title = stringResource(R.string.upgrade_screen_how_title),
|
||||
icon = Icons.TwoTone.Favorite,
|
||||
) {
|
||||
UpgradeSectionBody(text = stringResource(R.string.upgrade_screen_how_body))
|
||||
}
|
||||
|
||||
UpgradeActionCard(
|
||||
colors = CardDefaults.elevatedCardColors(
|
||||
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
),
|
||||
) {
|
||||
Button(
|
||||
onClick = onGithubSponsors,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(UpgradeScreenTags.FOSS_SPONSOR),
|
||||
) {
|
||||
Text(stringResource(R.string.upgrade_foss_sponsor_action))
|
||||
}
|
||||
|
||||
UpgradeHintText(text = stringResource(R.string.upgrade_foss_sponsor_subtitle))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UpgradeStatusFreeContent(
|
||||
paddingValues: PaddingValues,
|
||||
onShowUpgradeOptions: () -> Unit,
|
||||
) {
|
||||
UpgradeScreenContent(
|
||||
paddingValues = paddingValues,
|
||||
) {
|
||||
UpgradeHeader(
|
||||
mascotSize = 104.dp,
|
||||
)
|
||||
|
||||
UpgradeSectionCard(
|
||||
title = stringResource(R.string.upgrade_screen_status_free_title),
|
||||
icon = Icons.TwoTone.Info,
|
||||
modifier = Modifier.testTag(UpgradeScreenTags.FOSS_STATUS_FREE),
|
||||
) {
|
||||
UpgradeSectionBody(text = stringResource(R.string.upgrade_screen_status_free_body))
|
||||
Button(
|
||||
onClick = onShowUpgradeOptions,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(UpgradeScreenTags.FOSS_SHOW_OPTIONS),
|
||||
) {
|
||||
Text(stringResource(R.string.upgrade_screen_status_free_action))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UpgradeStatusUpgradedContent(
|
||||
paddingValues: PaddingValues,
|
||||
onGithubSponsors: () -> Unit,
|
||||
) {
|
||||
UpgradeScreenContent(
|
||||
paddingValues = paddingValues,
|
||||
) {
|
||||
UpgradeHeader(
|
||||
mascotSize = 104.dp,
|
||||
)
|
||||
|
||||
UpgradeSectionCard(
|
||||
title = stringResource(R.string.upgrade_screen_status_upgraded_title),
|
||||
icon = Icons.TwoTone.Verified,
|
||||
modifier = Modifier.testTag(UpgradeScreenTags.FOSS_STATUS_UPGRADED),
|
||||
colors = CardDefaults.elevatedCardColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.upgrade_foss_supporter_thanks),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
|
||||
UpgradeSectionCard(
|
||||
title = stringResource(R.string.upgrade_screen_recurring_title),
|
||||
icon = Icons.TwoTone.Favorite,
|
||||
) {
|
||||
UpgradeSectionBody(text = stringResource(R.string.upgrade_screen_recurring_body))
|
||||
OutlinedButton(
|
||||
onClick = onGithubSponsors,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(UpgradeScreenTags.FOSS_DONATE),
|
||||
) {
|
||||
Text(stringResource(R.string.upgrade_foss_sponsor_again_action))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal class SponsorReturnTracker {
|
||||
private var wentToBackground = false
|
||||
|
||||
fun onStop() {
|
||||
wentToBackground = true
|
||||
}
|
||||
|
||||
fun consumeResumeReturn(): Boolean {
|
||||
return if (wentToBackground) {
|
||||
wentToBackground = false
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun UpgradeScreenPreview() {
|
||||
PreviewWrapper {
|
||||
UpgradeScreen()
|
||||
}
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun UpgradeScreenStatusFreePreview() {
|
||||
PreviewWrapper {
|
||||
UpgradeScreen(view = FossUpgradeView.STATUS_FREE)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun UpgradeScreenStatusUpgradedPreview() {
|
||||
PreviewWrapper {
|
||||
UpgradeScreen(view = FossUpgradeView.STATUS_UPGRADED)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package eu.darken.capod.common.upgrade.ui
|
||||
|
||||
import android.os.SystemClock
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.flow.SingleEventFlow
|
||||
import eu.darken.capod.common.navigation.Nav
|
||||
import eu.darken.capod.common.uix.ViewModel4
|
||||
import eu.darken.capod.common.upgrade.core.UpgradeRepoFoss
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.take
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class UpgradeViewModel @Inject constructor(
|
||||
private val handle: SavedStateHandle,
|
||||
dispatcherProvider: DispatcherProvider,
|
||||
private val upgradeRepo: UpgradeRepoFoss,
|
||||
) : ViewModel4(dispatcherProvider = dispatcherProvider) {
|
||||
|
||||
// Route is bound from the Host via bindRoute(); SavedStateHandle.toRoute<>() crashes under Nav3.
|
||||
private val routeFlow = MutableStateFlow<Nav.Main.Upgrade?>(null)
|
||||
|
||||
fun bindRoute(route: Nav.Main.Upgrade) {
|
||||
if (routeFlow.value != null) return
|
||||
routeFlow.value = route
|
||||
}
|
||||
|
||||
val snackbarEvents = SingleEventFlow<Int>()
|
||||
val toastEvents = SingleEventFlow<Int>()
|
||||
|
||||
// Which presentation the screen shows. The manage route (settings "upgrade status" entry)
|
||||
// gets a status view first; the pitch only appears once a free user asks for the upgrade
|
||||
// options. Upgrading wins over that choice — completing the sponsor flow from the pitch must
|
||||
// land on the upgraded status, not back on the ask. null until the route is bound.
|
||||
internal val state: StateFlow<FossUpgradeView?> = combine(
|
||||
routeFlow,
|
||||
upgradeRepo.upgradeInfo,
|
||||
handle.getStateFlow(KEY_SHOW_UPGRADE_OPTIONS, false),
|
||||
) { route, info, showOptions ->
|
||||
when {
|
||||
route == null -> null
|
||||
route.manage && info.isPro -> FossUpgradeView.STATUS_UPGRADED
|
||||
route.manage && !showOptions -> FossUpgradeView.STATUS_FREE
|
||||
else -> FossUpgradeView.PITCH
|
||||
}
|
||||
}.safeStateIn(
|
||||
initialValue = null,
|
||||
onError = { FossUpgradeView.PITCH },
|
||||
)
|
||||
|
||||
init {
|
||||
routeFlow
|
||||
.filterNotNull()
|
||||
.take(1)
|
||||
.onEach { route ->
|
||||
// The manage route is the settings "upgrade status" entry — upgraded users must
|
||||
// not be bounced out. Forced routes keep their existing don't-auto-close semantics.
|
||||
if (!route.forced && !route.manage) {
|
||||
upgradeRepo.upgradeInfo
|
||||
.filter { it.isPro }
|
||||
.take(1)
|
||||
.onEach { navUp() }
|
||||
.launchInViewModel()
|
||||
}
|
||||
}
|
||||
.launchInViewModel()
|
||||
|
||||
upgradeRepo.upgradeInfo
|
||||
.filter { !it.isPro && it.error != null }
|
||||
.onEach { current ->
|
||||
@Suppress("UNNECESSARY_NOT_NULL_ASSERTION")
|
||||
errorEvents.tryEmit(current.error!!)
|
||||
}
|
||||
.launchInViewModel()
|
||||
}
|
||||
|
||||
fun onShowUpgradeOptions() {
|
||||
log(TAG) { "onShowUpgradeOptions()" }
|
||||
// Handle-backed: surviving process recreation keeps the user on the pitch they asked for.
|
||||
handle[KEY_SHOW_UPGRADE_OPTIONS] = true
|
||||
}
|
||||
|
||||
fun goGithubSponsors() {
|
||||
log(TAG) { "goGithubSponsors()" }
|
||||
handle[KEY_SPONSOR_PRESSED_AT] = SystemClock.elapsedRealtime()
|
||||
upgradeRepo.openGithubSponsorsPage()
|
||||
}
|
||||
|
||||
fun checkSponsorReturn() = launch {
|
||||
val pressedAt = handle.remove<Long>(KEY_SPONSOR_PRESSED_AT) ?: return@launch
|
||||
val elapsed = SystemClock.elapsedRealtime() - pressedAt
|
||||
log(TAG) { "checkSponsorReturn(): elapsed=${elapsed}ms" }
|
||||
|
||||
if (elapsed < SPONSOR_DELAY_MS) {
|
||||
// The nudge belongs to the unlock heuristic. An already upgraded user (recurring
|
||||
// donation button) has nothing to unlock — peeking at the page needs no feedback.
|
||||
if (upgradeRepo.upgradeInfo.first().isPro) {
|
||||
log(TAG) { "checkSponsorReturn(): Too quick, but already upgraded, staying quiet" }
|
||||
} else {
|
||||
log(TAG) { "checkSponsorReturn(): Too quick, showing snackbar" }
|
||||
snackbarEvents.tryEmit(R.string.upgrade_foss_sponsor_returned_early)
|
||||
}
|
||||
} else {
|
||||
log(TAG) { "checkSponsorReturn(): Delay passed, persisting upgrade" }
|
||||
upgradeRepo.persistUpgrade()
|
||||
toastEvents.tryEmit(R.string.upgrade_foss_supporter_thanks)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val KEY_SPONSOR_PRESSED_AT = "sponsor_pressed_at"
|
||||
private const val KEY_SHOW_UPGRADE_OPTIONS = "show_upgrade_options"
|
||||
private const val SPONSOR_DELAY_MS = 5_000L
|
||||
private val TAG = logTag("Upgrade", "ViewModel")
|
||||
}
|
||||
}
|
||||
@@ -1,445 +0,0 @@
|
||||
package eu.darken.capod.upgrade.ui
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.twotone.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.twotone.Message
|
||||
import androidx.compose.material.icons.twotone.BluetoothConnected
|
||||
import androidx.compose.material.icons.twotone.Favorite
|
||||
import androidx.compose.material.icons.twotone.Palette
|
||||
import androidx.compose.material.icons.twotone.PlayCircle
|
||||
import androidx.compose.material.icons.twotone.Headphones
|
||||
import androidx.compose.material.icons.twotone.Tune
|
||||
import androidx.compose.material.icons.twotone.Widgets
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.compose.Preview2
|
||||
import eu.darken.capod.common.compose.PreviewWrapper
|
||||
import eu.darken.capod.common.error.ErrorEventHandler
|
||||
import eu.darken.capod.common.navigation.NavigationEventHandler
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
|
||||
@Composable
|
||||
fun UpgradeScreenHost(
|
||||
manage: Boolean = false,
|
||||
vm: UpgradeViewModel = hiltViewModel(),
|
||||
) {
|
||||
ErrorEventHandler(vm)
|
||||
NavigationEventHandler(vm)
|
||||
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val returnedEarlyMessage = stringResource(R.string.upgrade_foss_sponsor_returned_early)
|
||||
val state by vm.state.collectAsState()
|
||||
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
vm.onResume()
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
vm.sponsorEvents.collect { event ->
|
||||
when (event) {
|
||||
UpgradeViewModel.SponsorEvent.ReturnedTooEarly -> {
|
||||
snackbarHostState.showSnackbar(returnedEarlyMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val current = state
|
||||
when {
|
||||
// DataStore hasn't answered yet — render nothing rather than flashing the sales route
|
||||
// (with its armable unlock heuristic) at an existing supporter.
|
||||
current == null -> Unit
|
||||
|
||||
manage && current.isPro -> {
|
||||
// Existing supporter checking their status: no sales pitch, and the sponsor link here
|
||||
// must not re-run the unlock heuristic (which would rewrite the supporter-since date).
|
||||
SupporterStatusScreen(
|
||||
upgradedAt = current.upgradedAt,
|
||||
onNavigateUp = { vm.navUp() },
|
||||
onSponsorPage = { vm.openSponsorPage() },
|
||||
)
|
||||
}
|
||||
|
||||
else -> UpgradeScreen(
|
||||
snackbarHostState = snackbarHostState,
|
||||
onNavigateUp = { vm.navUp() },
|
||||
onSponsor = { vm.sponsor() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SupporterStatusScreen(
|
||||
upgradedAt: java.time.Instant?,
|
||||
onNavigateUp: () -> Unit,
|
||||
onSponsorPage: () -> Unit,
|
||||
) {
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
) { paddingValues ->
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(paddingValues)
|
||||
.padding(horizontal = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Surface(
|
||||
modifier = Modifier.size(120.dp),
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f),
|
||||
) {}
|
||||
Image(
|
||||
painter = painterResource(R.drawable.splash_graphic2),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(80.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = buildAnnotatedString {
|
||||
append("CAPod ")
|
||||
withStyle(SpanStyle(color = colorResource(R.color.brand_secondary), fontWeight = FontWeight.Bold)) {
|
||||
append("FOSS")
|
||||
}
|
||||
},
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag("upgrade.foss.supporterCard"),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.Favorite,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = upgradedAt
|
||||
?.let { instant ->
|
||||
stringResource(
|
||||
R.string.upgrade_foss_supporter_since,
|
||||
remember(instant) {
|
||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
|
||||
.withZone(ZoneId.systemDefault())
|
||||
.format(instant)
|
||||
}
|
||||
)
|
||||
}
|
||||
?: stringResource(R.string.upgrade_foss_supporter_thanks),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.upgrade_foss_supporter_thanks),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Button(
|
||||
onClick = onSponsorPage,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(52.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.Favorite,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.upgrade_foss_sponsor_again_action),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = onNavigateUp,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Start))
|
||||
.padding(4.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.TwoTone.ArrowBack,
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class Benefit(val icon: ImageVector, val textRes: Int)
|
||||
|
||||
@Composable
|
||||
fun UpgradeScreen(
|
||||
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
|
||||
onNavigateUp: () -> Unit,
|
||||
onSponsor: () -> Unit,
|
||||
) {
|
||||
val benefits = listOf(
|
||||
Benefit(Icons.TwoTone.Palette, R.string.upgrade_benefit_themes),
|
||||
Benefit(Icons.TwoTone.PlayCircle, R.string.upgrade_benefit_autoplay),
|
||||
|
||||
Benefit(Icons.AutoMirrored.TwoTone.Message, R.string.upgrade_benefit_popups),
|
||||
Benefit(Icons.TwoTone.Widgets, R.string.upgrade_benefit_widgets),
|
||||
Benefit(Icons.TwoTone.Tune, R.string.upgrade_benefit_device_settings),
|
||||
Benefit(Icons.TwoTone.Headphones, R.string.upgrade_benefit_device_controls),
|
||||
Benefit(Icons.TwoTone.Favorite, R.string.upgrade_benefit_support),
|
||||
)
|
||||
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
) { paddingValues ->
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(paddingValues)
|
||||
.padding(horizontal = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Surface(
|
||||
modifier = Modifier.size(120.dp),
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f),
|
||||
) {}
|
||||
Image(
|
||||
painter = painterResource(R.drawable.splash_graphic2),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(80.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = buildAnnotatedString {
|
||||
append("CAPod ")
|
||||
withStyle(SpanStyle(color = colorResource(R.color.brand_secondary), fontWeight = FontWeight.Bold)) {
|
||||
append("FOSS")
|
||||
}
|
||||
},
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.upgrade_foss_preamble),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(vertical = 4.dp)) {
|
||||
benefits.forEach { benefit ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
modifier = Modifier.size(28.dp),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
imageVector = benefit.icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
text = stringResource(benefit.textRes),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.upgrade_benefit_disclaimer),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Button(
|
||||
onClick = onSponsor,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(52.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.Favorite,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.upgrade_foss_sponsor_action),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.upgrade_foss_sponsor_subtitle),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = onNavigateUp,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Start))
|
||||
.padding(4.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.TwoTone.ArrowBack,
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun UpgradeScreenPreview() = PreviewWrapper {
|
||||
UpgradeScreen(
|
||||
onNavigateUp = {},
|
||||
onSponsor = {},
|
||||
)
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun SupporterStatusScreenPreview() = PreviewWrapper {
|
||||
SupporterStatusScreen(
|
||||
upgradedAt = java.time.Instant.parse("2025-11-02T12:00:00Z"),
|
||||
onNavigateUp = {},
|
||||
onSponsorPage = {},
|
||||
)
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
package eu.darken.capod.upgrade.ui
|
||||
|
||||
import android.os.SystemClock
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import eu.darken.capod.common.WebpageTool
|
||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||
import eu.darken.capod.common.flow.SingleEventFlow
|
||||
import eu.darken.capod.common.uix.ViewModel4
|
||||
import eu.darken.capod.common.upgrade.core.FossUpgrade
|
||||
import eu.darken.capod.common.upgrade.core.UpgradeControlFoss
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class UpgradeViewModel @Inject constructor(
|
||||
private val savedStateHandle: SavedStateHandle,
|
||||
dispatcherProvider: DispatcherProvider,
|
||||
private val upgradeControlFoss: UpgradeControlFoss,
|
||||
private val webpageTool: WebpageTool,
|
||||
) : ViewModel4(dispatcherProvider) {
|
||||
|
||||
sealed interface SponsorEvent {
|
||||
data object ReturnedTooEarly : SponsorEvent
|
||||
}
|
||||
|
||||
val sponsorEvents = SingleEventFlow<SponsorEvent>()
|
||||
|
||||
data class State(
|
||||
val isPro: Boolean = false,
|
||||
val upgradedAt: Instant? = null,
|
||||
)
|
||||
|
||||
// Null until DataStore answered: a defaulted isPro=false would flash the sales route (and its
|
||||
// armable unlock heuristic) at an existing supporter opening their status.
|
||||
val state: StateFlow<State?> = upgradeControlFoss.upgradeInfo
|
||||
.map { info -> State(isPro = info.isPro, upgradedAt = info.upgradedAt) }
|
||||
.stateIn(vmScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
fun sponsor() {
|
||||
savedStateHandle[KEY_SPONSOR_OPENED_AT] = SystemClock.elapsedRealtime()
|
||||
webpageTool.open("https://github.com/sponsors/d4rken")
|
||||
}
|
||||
|
||||
// Plain sponsor link for existing supporters: must NOT arm the unlock heuristic — re-running
|
||||
// it would rewrite the "supporter since" date and navigate away from the status view.
|
||||
fun openSponsorPage() {
|
||||
webpageTool.open("https://github.com/sponsors/d4rken")
|
||||
}
|
||||
|
||||
fun onResume() {
|
||||
val openedAt = savedStateHandle.get<Long>(KEY_SPONSOR_OPENED_AT) ?: return
|
||||
savedStateHandle.remove<Long>(KEY_SPONSOR_OPENED_AT)
|
||||
|
||||
if (SystemClock.elapsedRealtime() - openedAt >= 5_000L) {
|
||||
upgradeControlFoss.upgrade(FossUpgrade.Reason.DONATED)
|
||||
navUp()
|
||||
} else {
|
||||
sponsorEvents.tryEmit(SponsorEvent.ReturnedTooEarly)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val KEY_SPONSOR_OPENED_AT = "sponsor_opened_at"
|
||||
}
|
||||
}
|
||||
@@ -13,4 +13,13 @@
|
||||
<string name="upgrade_foss_sponsor_again_action">Open sponsor page</string>
|
||||
<string name="settings_upgrade_status_label">Sponsor CAPod</string>
|
||||
<string name="settings_upgrade_status_description">Your supporter status.</string>
|
||||
<string name="upgrade_screen_why_title">Upgrade benefits</string>
|
||||
<string name="upgrade_screen_how_title">How to help</string>
|
||||
<string name="upgrade_screen_how_body">Become a patron and sponsor development! Tap the button below to activate all extra features and open my GitHub Sponsors profile.</string>
|
||||
<string name="upgrade_screen_status_free_title">Free version</string>
|
||||
<string name="upgrade_screen_status_free_body">You are using the free version of CAPod. Extra features can be unlocked by supporting development.</string>
|
||||
<string name="upgrade_screen_status_free_action">See upgrade options</string>
|
||||
<string name="upgrade_screen_status_upgraded_title">Upgrade active</string>
|
||||
<string name="upgrade_screen_recurring_title">Keep it going</string>
|
||||
<string name="upgrade_screen_recurring_body">CAPod keeps evolving through updates and fixes. If you\'d like to sustain that, consider a recurring donation via GitHub Sponsors.</string>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user