mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-16 11:16:12 -04:00
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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user