feat(upgrade): Replace upgrade dialog with full-screen upgrade screen

Add dedicated Compose upgrade screens for both FOSS and gplay flavors, presenting pro benefits before the purchase/sponsor action. Migrate all upgrade dialog callers to navigate to the new screen instead.
This commit is contained in:
darken
2026-03-03 20:35:32 +00:00
committed by Matthias Urhahn
parent e80bf1db6c
commit abb189f43c
24 changed files with 725 additions and 173 deletions
@@ -18,6 +18,9 @@ object Nav {
@Serializable
data object TroubleShooter : Main
@Serializable
data object Upgrade : Main
}
sealed interface Settings : NavigationDestination {
@@ -1,14 +1,11 @@
package eu.darken.capod.common.upgrade
import android.app.Activity
import kotlinx.coroutines.flow.Flow
import java.time.Instant
interface UpgradeRepo {
val upgradeInfo: Flow<Info>
fun launchBillingFlow(activity: Activity)
fun getSponsorUrl(): String? = null
interface Info {
@@ -1,10 +1,12 @@
package eu.darken.capod.main.ui
import android.content.Intent
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -53,6 +55,10 @@ class MainActivity : Activity2() {
val backStack = rememberNavBackStack(startDestination)
navCtrl.setup(backStack)
LaunchedEffect(Unit) {
consumeUpgradeExtra(intent)
}
CapodTheme(state = themeState) {
val backgroundColor = MaterialTheme.colorScheme.background
val useDarkIcons = backgroundColor.luminance() > 0.5f
@@ -85,7 +91,22 @@ class MainActivity : Activity2() {
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
consumeUpgradeExtra(intent)
}
private fun consumeUpgradeExtra(intent: Intent?) {
if (intent?.getBooleanExtra(EXTRA_NAVIGATE_TO_UPGRADE, false) == true) {
intent.removeExtra(EXTRA_NAVIGATE_TO_UPGRADE)
if (generalSettings.isOnboardingDone.value) {
navCtrl.goTo(Nav.Main.Upgrade)
}
}
}
companion object {
const val EXTRA_NAVIGATE_TO_UPGRADE = "navigate_to_upgrade"
private val TAG = logTag("MainActivity")
}
}
@@ -1,6 +1,5 @@
package eu.darken.capod.main.ui.overview
import android.app.Activity
import android.content.Intent
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
@@ -67,7 +66,6 @@ fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) {
NavigationEventHandler(vm)
val context = LocalContext.current
val activity = context as? Activity
// Collect workerAutolaunch passively to keep it active
LaunchedEffect(Unit) {
@@ -122,13 +120,6 @@ fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) {
}
}
// Handle upgrade flow events
LaunchedEffect(Unit) {
vm.launchUpgradeFlow.collect { action ->
activity?.let { action(it) }
}
}
val state by vm.state.collectAsStateWithLifecycle(initialValue = null)
val currentState = state ?: return
@@ -1,6 +1,5 @@
package eu.darken.capod.main.ui.overview
import android.app.Activity
import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.coroutine.DispatcherProvider
@@ -50,7 +49,6 @@ class OverviewViewModel @Inject constructor(
) : ViewModel4(dispatcherProvider) {
val requestPermissionEvent = SingleEventFlow<Permission>()
val launchUpgradeFlow = SingleEventFlow<(Activity) -> Unit>()
private val showUnmatchedDevices = MutableStateFlow(false)
@@ -141,8 +139,8 @@ class OverviewViewModel @Inject constructor(
navTo(Nav.Main.DeviceManager)
}
fun onUpgrade() = launch {
launchUpgradeFlow.tryEmit { upgradeRepo.launchBillingFlow(it) }
fun onUpgrade() {
navTo(Nav.Main.Upgrade)
}
fun toggleUnmatchedDevices() {
@@ -94,11 +94,11 @@ fun SettingsScreen(
},
actions = {
val sponsorUrl = state.sponsorUrl
if (sponsorUrl != null) {
if (state.isPro && sponsorUrl != null) {
IconButton(onClick = { onSponsor(sponsorUrl) }) {
Icon(
imageVector = Icons.TwoTone.Favorite,
contentDescription = "Sponsor development",
contentDescription = null,
)
}
}
@@ -190,7 +190,7 @@ fun SettingsScreen(
@Composable
private fun SettingsScreenPreview() = PreviewWrapper {
SettingsScreen(
state = SettingsViewModel.State(sponsorUrl = "https://example.com"),
state = SettingsViewModel.State(isPro = true, sponsorUrl = "https://example.com"),
onNavigateUp = {},
onGeneralSettings = {},
onDeviceManager = {},
@@ -18,11 +18,12 @@ class SettingsViewModel @Inject constructor(
) : ViewModel4(dispatcherProvider) {
data class State(
val isPro: Boolean,
val sponsorUrl: String?,
)
val state = upgradeRepo.upgradeInfo
.map { State(sponsorUrl = upgradeRepo.getSponsorUrl()) }
.map { State(isPro = it.isPro, sponsorUrl = upgradeRepo.getSponsorUrl()) }
.asLiveState()
fun openUrl(url: String) {
@@ -1,6 +1,5 @@
package eu.darken.capod.main.ui.settings.general
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -35,14 +34,12 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
@@ -69,11 +66,6 @@ fun GeneralSettingsScreenHost(vm: GeneralSettingsViewModel = hiltViewModel()) {
ErrorEventHandler(vm)
NavigationEventHandler(vm)
val activity = LocalContext.current as? Activity
LaunchedEffect(Unit) {
vm.launchUpgradeFlow.collect { action -> activity?.let { action(it) } }
}
val state by vm.state.collectAsStateWithLifecycle(initialValue = null)
state?.let {
GeneralSettingsScreen(
@@ -1,11 +1,9 @@
package eu.darken.capod.main.ui.settings.general
import android.app.Activity
import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.capod.common.bluetooth.ScannerMode
import eu.darken.capod.common.coroutine.DispatcherProvider
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.theming.ThemeColor
import eu.darken.capod.common.theming.ThemeMode
@@ -42,8 +40,6 @@ class GeneralSettingsViewModel @Inject constructor(
private val isPro = upgradeRepo.upgradeInfo.map { it.isPro }.asLiveState()
val launchUpgradeFlow = SingleEventFlow<(Activity) -> Unit>()
val state = combine(
combine(
generalSettings.monitorMode.flow,
@@ -110,7 +106,7 @@ class GeneralSettingsViewModel @Inject constructor(
if (isPro.first()) {
generalSettings.themeMode.value = mode
} else {
launchUpgradeFlow.tryEmit { upgradeRepo.launchBillingFlow(it) }
navTo(Nav.Main.Upgrade)
}
}
@@ -118,7 +114,7 @@ class GeneralSettingsViewModel @Inject constructor(
if (isPro.first()) {
generalSettings.themeStyle.value = style
} else {
launchUpgradeFlow.tryEmit { upgradeRepo.launchBillingFlow(it) }
navTo(Nav.Main.Upgrade)
}
}
@@ -126,12 +122,12 @@ class GeneralSettingsViewModel @Inject constructor(
if (isPro.first()) {
generalSettings.themeColor.value = color
} else {
launchUpgradeFlow.tryEmit { upgradeRepo.launchBillingFlow(it) }
navTo(Nav.Main.Upgrade)
}
}
fun launchUpgrade() = launch {
launchUpgradeFlow.tryEmit { upgradeRepo.launchBillingFlow(it) }
fun launchUpgrade() {
navTo(Nav.Main.Upgrade)
}
fun goToDebugSettings() {
@@ -22,7 +22,7 @@ import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.theming.CapodTheme
import eu.darken.capod.common.uix.Activity2
import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.main.ui.MainActivity
import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.main.core.currentThemeState
import eu.darken.capod.main.core.themeState
@@ -34,7 +34,6 @@ class WidgetConfigurationActivity : Activity2() {
private val vm: WidgetConfigurationViewModel by viewModels()
@Inject lateinit var upgradeRepo: UpgradeRepo
@Inject lateinit var generalSettings: GeneralSettings
@ApplicationContext @Inject lateinit var appContext: Context
@@ -87,7 +86,11 @@ class WidgetConfigurationActivity : Activity2() {
if (currentState.isPro) {
confirmSelection()
} else {
upgradeRepo.launchBillingFlow(this@WidgetConfigurationActivity)
startActivity(
Intent(this@WidgetConfigurationActivity, MainActivity::class.java).apply {
putExtra(MainActivity.EXTRA_NAVIGATE_TO_UPGRADE, true)
}
)
}
},
onCancel = { finish() },
@@ -1,6 +1,5 @@
package eu.darken.capod.reaction.ui
import android.app.Activity
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
@@ -16,6 +15,8 @@ import androidx.compose.material.icons.automirrored.twotone.Message
import androidx.compose.material.icons.twotone.PauseCircle
import androidx.compose.material.icons.twotone.PlayCircle
import androidx.compose.material.icons.twotone.QuestionMark
import androidx.compose.material.icons.twotone.Stars
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.twotone.Workspaces
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -35,7 +36,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
@@ -56,19 +56,18 @@ fun ReactionSettingsScreenHost(vm: ReactionSettingsViewModel = hiltViewModel())
NavigationEventHandler(vm)
val state by vm.state.collectAsStateWithLifecycle(initialValue = null)
val activity = LocalContext.current as Activity
state?.let {
ReactionSettingsScreen(
state = it,
onNavigateUp = { vm.navUp() },
onOnePodModeChanged = { enabled -> vm.setOnePodMode(enabled) },
onAutoPlayChanged = { enabled -> vm.setAutoPlay(enabled, activity) },
onAutoPauseChanged = { enabled -> vm.setAutoPause(enabled, activity) },
onAutoConnectChanged = { enabled -> vm.setAutoConnect(enabled, activity) },
onAutoPlayChanged = { enabled -> vm.setAutoPlay(enabled) },
onAutoPauseChanged = { enabled -> vm.setAutoPause(enabled) },
onAutoConnectChanged = { enabled -> vm.setAutoConnect(enabled) },
onAutoConnectConditionSelected = { condition -> vm.setAutoConnectCondition(condition) },
onShowPopUpOnCaseOpenChanged = { enabled -> vm.setShowPopUpOnCaseOpen(enabled, activity) },
onShowPopUpOnConnectionChanged = { enabled -> vm.setShowPopUpOnConnection(enabled, activity) },
onShowPopUpOnCaseOpenChanged = { enabled -> vm.setShowPopUpOnCaseOpen(enabled) },
onShowPopUpOnConnectionChanged = { enabled -> vm.setShowPopUpOnConnection(enabled) },
)
}
}
@@ -131,11 +130,7 @@ fun ReactionSettingsScreen(
icon = Icons.TwoTone.PlayCircle,
onClick = { onAutoPlayChanged(!state.autoPlay) },
trailingContent = {
Switch(
checked = state.autoPlay,
onCheckedChange = onAutoPlayChanged,
modifier = Modifier.padding(start = 16.dp),
)
ProGatedToggle(isPro = state.isPro, checked = state.autoPlay, onCheckedChange = onAutoPlayChanged)
},
)
}
@@ -146,11 +141,7 @@ fun ReactionSettingsScreen(
icon = Icons.TwoTone.PauseCircle,
onClick = { onAutoPauseChanged(!state.autoPause) },
trailingContent = {
Switch(
checked = state.autoPause,
onCheckedChange = onAutoPauseChanged,
modifier = Modifier.padding(start = 16.dp),
)
ProGatedToggle(isPro = state.isPro, checked = state.autoPause, onCheckedChange = onAutoPauseChanged)
},
)
}
@@ -173,11 +164,7 @@ fun ReactionSettingsScreen(
icon = Icons.TwoTone.BluetoothConnected,
onClick = { onAutoConnectChanged(!state.autoConnect) },
trailingContent = {
Switch(
checked = state.autoConnect,
onCheckedChange = onAutoConnectChanged,
modifier = Modifier.padding(start = 16.dp),
)
ProGatedToggle(isPro = state.isPro, checked = state.autoConnect, onCheckedChange = onAutoConnectChanged)
},
)
}
@@ -200,11 +187,7 @@ fun ReactionSettingsScreen(
icon = Icons.AutoMirrored.TwoTone.Message,
onClick = { onShowPopUpOnCaseOpenChanged(!state.showPopUpOnCaseOpen) },
trailingContent = {
Switch(
checked = state.showPopUpOnCaseOpen,
onCheckedChange = onShowPopUpOnCaseOpenChanged,
modifier = Modifier.padding(start = 16.dp),
)
ProGatedToggle(isPro = state.isPro, checked = state.showPopUpOnCaseOpen, onCheckedChange = onShowPopUpOnCaseOpenChanged)
},
)
}
@@ -215,11 +198,7 @@ fun ReactionSettingsScreen(
icon = Icons.AutoMirrored.TwoTone.Message,
onClick = { onShowPopUpOnConnectionChanged(!state.showPopUpOnConnection) },
trailingContent = {
Switch(
checked = state.showPopUpOnConnection,
onCheckedChange = onShowPopUpOnConnectionChanged,
modifier = Modifier.padding(start = 16.dp),
)
ProGatedToggle(isPro = state.isPro, checked = state.showPopUpOnConnection, onCheckedChange = onShowPopUpOnConnectionChanged)
},
)
}
@@ -270,6 +249,28 @@ fun ReactionSettingsScreen(
}
}
@Composable
private fun ProGatedToggle(
isPro: Boolean,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
) {
if (isPro) {
Switch(
checked = checked,
onCheckedChange = onCheckedChange,
modifier = Modifier.padding(start = 16.dp),
)
} else {
Icon(
imageVector = Icons.TwoTone.Stars,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 16.dp).size(24.dp),
)
}
}
@Preview2
@Composable
private fun ReactionSettingsScreenPreview() = PreviewWrapper {
@@ -1,10 +1,9 @@
package eu.darken.capod.reaction.ui
import android.app.Activity
import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.navigation.Nav
import eu.darken.capod.common.uix.ViewModel4
import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.main.core.GeneralSettings
@@ -63,7 +62,7 @@ class ReactionSettingsViewModel @Inject constructor(
reactionSettings.onePodMode.value = enabled
}
fun setAutoPlay(enabled: Boolean, activity: Activity) = launch {
fun setAutoPlay(enabled: Boolean) = launch {
if (!enabled) {
reactionSettings.autoPlay.value = false
return@launch
@@ -71,11 +70,11 @@ class ReactionSettingsViewModel @Inject constructor(
if (isPro.first()) {
reactionSettings.autoPlay.value = true
} else {
upgradeRepo.launchBillingFlow(activity)
navTo(Nav.Main.Upgrade)
}
}
fun setAutoPause(enabled: Boolean, activity: Activity) = launch {
fun setAutoPause(enabled: Boolean) = launch {
if (!enabled) {
reactionSettings.autoPause.value = false
return@launch
@@ -83,11 +82,11 @@ class ReactionSettingsViewModel @Inject constructor(
if (isPro.first()) {
reactionSettings.autoPause.value = true
} else {
upgradeRepo.launchBillingFlow(activity)
navTo(Nav.Main.Upgrade)
}
}
fun setAutoConnect(enabled: Boolean, activity: Activity) = launch {
fun setAutoConnect(enabled: Boolean) = launch {
if (!enabled) {
reactionSettings.autoConnect.value = false
return@launch
@@ -96,7 +95,7 @@ class ReactionSettingsViewModel @Inject constructor(
reactionSettings.autoConnect.value = true
generalSettings.monitorMode.value = MonitorMode.ALWAYS
} else {
upgradeRepo.launchBillingFlow(activity)
navTo(Nav.Main.Upgrade)
}
}
@@ -104,7 +103,7 @@ class ReactionSettingsViewModel @Inject constructor(
reactionSettings.autoConnectCondition.value = condition
}
fun setShowPopUpOnCaseOpen(enabled: Boolean, activity: Activity) = launch {
fun setShowPopUpOnCaseOpen(enabled: Boolean) = launch {
if (!enabled) {
reactionSettings.showPopUpOnCaseOpen.value = false
return@launch
@@ -112,11 +111,11 @@ class ReactionSettingsViewModel @Inject constructor(
if (isPro.first()) {
reactionSettings.showPopUpOnCaseOpen.value = true
} else {
upgradeRepo.launchBillingFlow(activity)
navTo(Nav.Main.Upgrade)
}
}
fun setShowPopUpOnConnection(enabled: Boolean, activity: Activity) = launch {
fun setShowPopUpOnConnection(enabled: Boolean) = launch {
if (!enabled) {
reactionSettings.showPopUpOnConnection.value = false
return@launch
@@ -124,7 +123,7 @@ class ReactionSettingsViewModel @Inject constructor(
if (isPro.first()) {
reactionSettings.showPopUpOnConnection.value = true
} else {
upgradeRepo.launchBillingFlow(activity)
navTo(Nav.Main.Upgrade)
}
}
+7
View File
@@ -19,6 +19,13 @@
<string name="upgrade_capod_label">Upgrade CAPod</string>
<string name="upgrade_capod_description">Get additional features and support the developer.</string>
<string name="upgrade_preamble">CAPod is developed by a single person. Upgrading unlocks extra features and helps keep the app alive.</string>
<string name="upgrade_benefit_themes">Theme customization</string>
<string name="upgrade_benefit_autoplay">Auto-play &amp; auto-pause</string>
<string name="upgrade_benefit_autoconnect">Auto-connect</string>
<string name="upgrade_benefit_popups">Popup on case open &amp; connection</string>
<string name="upgrade_benefit_widgets">Home screen widgets</string>
<string name="upgrade_benefit_support">Support the developer</string>
<string name="settings_monitor_mode_label">Monitor mode</string>
<string name="settings_monitor_mode_description">Under which circumstances this app monitors Bluetooth data.</string>