fix(upgrade): Show the supporter-since date on the FOSS status screen

The upgraded status now renders the date the supporter unlocked, derived in the
same emission as the view so the screen never shows the status without the date
it is supposed to carry.

Guards that date: a return from the recurring-donation button no longer runs
persistUpgrade() when the install is already Pro, which would have rewritten
upgradedAt and visibly reset the displayed date. The sponsor-return tracker is
seeded from the handle-backed pending launch so a process death while the
sponsor page is in front does not swallow the first return.

The status views are titled "CAPod FOSS" instead of "CAPod Pro" -- on FOSS the
flavor name is the brand.
This commit is contained in:
darken
2026-07-30 12:55:31 +02:00
committed by Matthias Urhahn
parent 10317b373f
commit 0c39ca89c8
7 changed files with 322 additions and 44 deletions
@@ -34,6 +34,10 @@ 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
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
// Which presentation the FOSS upgrade screen shows: the classic support pitch, or one of the
// status views behind the settings "upgrade status" entry.
@@ -54,7 +58,12 @@ fun UpgradeScreenHost(
val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() }
val sponsorReturnTracker = remember { SponsorReturnTracker() }
// Seeded from the ViewModel's handle-backed pending launch: after a process death while the
// sponsor page was open, a blank tracker would swallow the very first return. The handle is the
// authority on whether a return is still expected, so it reconstructs the tracker's state.
val sponsorReturnTracker = remember(vm) {
SponsorReturnTracker(wentToBackground = vm.hasPendingSponsorLaunch())
}
LaunchedEffect(Unit) {
vm.snackbarEvents.collect { stringRes ->
@@ -77,12 +86,13 @@ fun UpgradeScreenHost(
}
}
val view by vm.state.collectAsStateWithLifecycle()
val state 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 },
view = state?.view ?: FossUpgradeView.PITCH.takeIf { !route.manage },
supporterSince = state?.supporterSince,
snackbarHostState = snackbarHostState,
onGithubSponsors = vm::goGithubSponsors,
onShowUpgradeOptions = vm::onShowUpgradeOptions,
@@ -93,6 +103,7 @@ fun UpgradeScreenHost(
@Composable
internal fun UpgradeScreen(
view: FossUpgradeView? = FossUpgradeView.PITCH,
supporterSince: Instant? = null,
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
onGithubSponsors: () -> Unit = {},
onShowUpgradeOptions: () -> Unit = {},
@@ -104,7 +115,12 @@ internal fun UpgradeScreen(
title = if (view == FossUpgradeView.PITCH) {
AnnotatedString(stringResource(R.string.settings_upgrade_status_label))
} else {
upgradeScreenTitle(upgraded = view == FossUpgradeView.STATUS_UPGRADED)
// "CAPod FOSS", not "CAPod Pro": on FOSS the flavor name IS the brand. The upgraded
// gate keeps the highlight for supporters only.
upgradeScreenTitle(
upgraded = view == FossUpgradeView.STATUS_UPGRADED,
nameRes = R.string.app_name_foss,
)
},
onNavigateUp = onNavigateUp,
snackbarHostState = snackbarHostState,
@@ -123,6 +139,7 @@ internal fun UpgradeScreen(
FossUpgradeView.STATUS_UPGRADED -> UpgradeStatusUpgradedContent(
paddingValues = paddingValues,
supporterSince = supporterSince,
onGithubSponsors = onGithubSponsors,
)
}
@@ -216,6 +233,7 @@ private fun UpgradeStatusFreeContent(
@Composable
private fun UpgradeStatusUpgradedContent(
paddingValues: PaddingValues,
supporterSince: Instant? = null,
onGithubSponsors: () -> Unit,
) {
UpgradeScreenContent(
@@ -238,6 +256,15 @@ private fun UpgradeStatusUpgradedContent(
text = stringResource(R.string.upgrade_foss_supporter_thanks),
style = MaterialTheme.typography.bodyMedium,
)
supporterSince?.let { since ->
val formatter = remember {
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withZone(ZoneId.systemDefault())
}
Text(
text = stringResource(R.string.upgrade_foss_supporter_since, formatter.format(since)),
style = MaterialTheme.typography.bodySmall,
)
}
}
UpgradeSectionCard(
@@ -257,8 +284,9 @@ private fun UpgradeStatusUpgradedContent(
}
}
internal class SponsorReturnTracker {
private var wentToBackground = false
internal class SponsorReturnTracker(
private var wentToBackground: Boolean = false,
) {
fun onStop() {
wentToBackground = true
@@ -294,6 +322,9 @@ private fun UpgradeScreenStatusFreePreview() {
@Composable
private fun UpgradeScreenStatusUpgradedPreview() {
PreviewWrapper {
UpgradeScreen(view = FossUpgradeView.STATUS_UPGRADED)
UpgradeScreen(
view = FossUpgradeView.STATUS_UPGRADED,
supporterSince = Instant.ofEpochMilli(1_700_000_000_000L),
)
}
}
@@ -19,6 +19,7 @@ import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.take
import java.time.Instant
import javax.inject.Inject
@HiltViewModel
@@ -43,20 +44,34 @@ class UpgradeViewModel @Inject constructor(
// 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(
internal val state: StateFlow<State?> = combine(
routeFlow,
upgradeRepo.upgradeInfo,
handle.getStateFlow(KEY_SHOW_UPGRADE_OPTIONS, false),
) { route, info, showOptions ->
when {
val view = when {
route == null -> null
route.manage && info.isPro -> FossUpgradeView.STATUS_UPGRADED
route.manage && !showOptions -> FossUpgradeView.STATUS_FREE
else -> FossUpgradeView.PITCH
}
// Derived in the same emission as the view on purpose: a sibling flow would let the
// upgraded status render for a frame without the date it is supposed to carry.
view?.let {
State(
view = it,
supporterSince = info.upgradedAt.takeIf { _ -> it == FossUpgradeView.STATUS_UPGRADED },
)
}
}.safeStateIn(
initialValue = null,
onError = { FossUpgradeView.PITCH },
onError = { State(view = FossUpgradeView.PITCH) },
)
// internal like FossUpgradeView: the view enum is a screen-local presentation detail.
internal data class State(
val view: FossUpgradeView,
val supporterSince: Instant? = null,
)
init {
@@ -97,20 +112,32 @@ class UpgradeViewModel @Inject constructor(
upgradeRepo.openGithubSponsorsPage()
}
/**
* Whether a sponsor-page launch is still awaiting its return.
*
* Handle-backed, so it survives process recreation while the browser is in front — the screen's
* in-memory return tracker does not, and gating on that alone drops the first return after a
* recreation.
*/
fun hasPendingSponsorLaunch(): Boolean = handle.contains(KEY_SPONSOR_PRESSED_AT)
fun checkSponsorReturn() = launch {
val pressedAt = handle.remove<Long>(KEY_SPONSOR_PRESSED_AT) ?: return@launch
// Evaluated before the duration: an already upgraded supporter (recurring donation button)
// has nothing left to unlock, and persisting again would rewrite their upgradedAt — visibly
// resetting the "supporter since" date the status screen shows them.
if (upgradeRepo.upgradeInfo.first().isPro) {
log(TAG) { "checkSponsorReturn(): Already upgraded, staying quiet" }
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)
}
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()