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
@@ -1,16 +1,9 @@
package eu.darken.capod.common.upgrade.core
import android.app.Activity
import android.widget.Toast
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import eu.darken.capod.R
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.error.asErrorDialogBuilder
import eu.darken.capod.common.flow.replayingShare
import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.common.upgrade.core.data.BillingData
@@ -21,8 +14,6 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.retryWhen
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.time.Instant
import javax.inject.Inject
import javax.inject.Singleton
@@ -31,7 +22,6 @@ import kotlin.math.pow
@Singleton
class UpgradeRepoGplay @Inject constructor(
@AppScope private val scope: CoroutineScope,
private val dispatcherProvider: DispatcherProvider,
private val billingDataRepo: BillingDataRepo,
private val billingCache: BillingCache,
) : UpgradeRepo {
@@ -76,49 +66,6 @@ class UpgradeRepoGplay @Inject constructor(
}
.replayingShare(scope)
override fun launchBillingFlow(activity: Activity) {
MaterialAlertDialogBuilder(activity).apply {
setIcon(R.drawable.ic_heart)
setTitle(R.string.upgrade_capod_label)
setMessage(R.string.upgrade_capod_description)
setPositiveButton(R.string.general_upgrade_action) { _, _ ->
scope.launch {
try {
billingDataRepo.startIapFlow(activity, CapodSku.PRO_UPGRADE.sku)
} catch (e: Exception) {
log(TAG) { "startIapFlow failed:${e.asLog()}" }
withContext(dispatcherProvider.Main) {
e.asErrorDialogBuilder(activity).show()
}
}
}
}
setNeutralButton(R.string.general_check_action) { dialog, _ ->
log(TAG) { "recheck()" }
scope.launch {
try {
val data = billingDataRepo.getIapData()
log(TAG) { "Recheck successful: $data" }
withContext(dispatcherProvider.Main) {
if (data.purchases.isEmpty()) {
Toast.makeText(
activity,
R.string.upgrades_no_purchases_found_check_account,
Toast.LENGTH_LONG
).show()
}
}
} catch (e: Exception) {
log(TAG) { "Recheck failed:${e.asLog()}" }
withContext(dispatcherProvider.Main) {
e.asErrorDialogBuilder(activity).show()
}
}
}
}
}.show()
}
data class Info(
private val gracePeriod: Boolean = false,
private val billingData: BillingData?,
@@ -0,0 +1,26 @@
package eu.darken.capod.upgrade.ui
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavKey
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import eu.darken.capod.common.navigation.Nav
import eu.darken.capod.common.navigation.NavigationEntry
import javax.inject.Inject
class UpgradeNavigation @Inject constructor() : NavigationEntry {
override fun EntryProviderScope<NavKey>.setup() {
entry<Nav.Main.Upgrade> { UpgradeScreenHost() }
}
@Module
@InstallIn(SingletonComponent::class)
abstract class Mod {
@Binds
@IntoSet
abstract fun bind(entry: UpgradeNavigation): NavigationEntry
}
}
@@ -0,0 +1,254 @@
package eu.darken.capod.upgrade.ui
import android.app.Activity
import android.widget.Toast
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
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.Stars
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.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
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 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
@Composable
fun UpgradeScreenHost(vm: UpgradeViewModel = hiltViewModel()) {
ErrorEventHandler(vm)
NavigationEventHandler(vm)
val context = LocalContext.current
val activity = context as? Activity
LaunchedEffect(Unit) {
vm.events.collect { event ->
when (event) {
UpgradeViewModel.UpgradeEvent.RestoreFailed -> {
Toast.makeText(
context,
R.string.upgrades_no_purchases_found_check_account,
Toast.LENGTH_LONG
).show()
}
}
}
}
UpgradeScreen(
onNavigateUp = { vm.navUp() },
onUpgrade = { activity?.let { vm.startPurchase(it) } },
onRestore = { vm.restorePurchase() },
)
}
private data class Benefit(val icon: ImageVector, val textRes: Int)
@Composable
fun UpgradeScreen(
onNavigateUp: () -> Unit,
onUpgrade: () -> Unit,
onRestore: () -> Unit,
) {
val benefits = listOf(
Benefit(Icons.TwoTone.Palette, R.string.upgrade_benefit_themes),
Benefit(Icons.TwoTone.PlayCircle, R.string.upgrade_benefit_autoplay),
Benefit(Icons.TwoTone.BluetoothConnected, R.string.upgrade_benefit_autoconnect),
Benefit(Icons.AutoMirrored.TwoTone.Message, R.string.upgrade_benefit_popups),
Benefit(Icons.TwoTone.Widgets, R.string.upgrade_benefit_widgets),
Benefit(Icons.TwoTone.Favorite, R.string.upgrade_benefit_support),
)
Box(modifier = Modifier.windowInsetsPadding(WindowInsets.systemBars)) {
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.height(16.dp))
Box(contentAlignment = Alignment.Center) {
Surface(
modifier = Modifier.size(120.dp),
shape = CircleShape,
color = MaterialTheme.colorScheme.primaryContainer.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_tertiary), fontWeight = FontWeight.Bold)) {
append("Pro")
}
},
style = MaterialTheme.typography.headlineLarge,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.upgrade_capod_description),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(24.dp))
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
),
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = stringResource(R.string.upgrade_preamble),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(16.dp),
)
}
Spacer(modifier = Modifier.height(24.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.primaryContainer,
modifier = Modifier.size(28.dp),
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = benefit.icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(16.dp),
)
}
}
Spacer(modifier = Modifier.width(12.dp))
Text(
text = stringResource(benefit.textRes),
style = MaterialTheme.typography.bodyLarge,
)
}
}
}
}
Spacer(modifier = Modifier.height(32.dp))
Button(
onClick = onUpgrade,
modifier = Modifier
.fillMaxWidth()
.height(52.dp),
shape = RoundedCornerShape(12.dp),
) {
Icon(
imageVector = Icons.TwoTone.Stars,
contentDescription = null,
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.general_upgrade_action),
style = MaterialTheme.typography.titleMedium,
)
}
TextButton(
onClick = onRestore,
modifier = Modifier.padding(top = 4.dp),
) {
Text(text = stringResource(R.string.upgrade_restore_action))
}
Spacer(modifier = Modifier.height(24.dp))
}
IconButton(
onClick = onNavigateUp,
modifier = Modifier.padding(4.dp),
) {
Icon(
imageVector = Icons.AutoMirrored.TwoTone.ArrowBack,
contentDescription = null,
)
}
}
}
@Preview2
@Composable
private fun UpgradeScreenPreview() = PreviewWrapper {
UpgradeScreen(
onNavigateUp = {},
onUpgrade = {},
onRestore = {},
)
}
@@ -0,0 +1,72 @@
package eu.darken.capod.upgrade.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.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.SingleEventFlow
import eu.darken.capod.common.uix.ViewModel4
import eu.darken.capod.common.upgrade.core.CapodSku
import eu.darken.capod.common.upgrade.core.UpgradeRepoGplay
import eu.darken.capod.common.upgrade.core.data.BillingDataRepo
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.withContext
import javax.inject.Inject
@HiltViewModel
class UpgradeViewModel @Inject constructor(
private val dispatcherProvider: DispatcherProvider,
private val upgradeRepo: UpgradeRepoGplay,
private val billingDataRepo: BillingDataRepo,
) : ViewModel4(dispatcherProvider) {
sealed interface UpgradeEvent {
data object RestoreFailed : UpgradeEvent
}
val events = SingleEventFlow<UpgradeEvent>()
init {
upgradeRepo.upgradeInfo
.onEach { info ->
if (info.isPro) {
log(TAG) { "User is now pro, navigating back" }
navUp()
}
}
.launchIn(vmScope)
}
fun startPurchase(activity: Activity) = launch {
try {
withContext(dispatcherProvider.Main) {
billingDataRepo.startIapFlow(activity, CapodSku.PRO_UPGRADE.sku)
}
} catch (e: Exception) {
log(TAG) { "startIapFlow failed: $e" }
errorEvents.emitBlocking(e)
}
}
fun restorePurchase() = launch {
try {
val data = billingDataRepo.getIapData()
log(TAG) { "Restore check: $data" }
if (data.purchasedSkus.any { it.sku == CapodSku.PRO_UPGRADE.sku }) {
log(TAG) { "Pro purchase found" }
} else {
log(TAG) { "No pro purchase found" }
events.tryEmit(UpgradeEvent.RestoreFailed)
}
} catch (e: Exception) {
log(TAG) { "Restore failed: $e" }
errorEvents.emitBlocking(e)
}
}
companion object {
private val TAG = logTag("Upgrade", "VM")
}
}
+1
View File
@@ -6,4 +6,5 @@
<string name="upgrades_gplay_billing_error_description">There was an error in Google Play. Please try again later or reboot your phone.\n\nError: %s</string>
<string name="upgrades_gplay_billing_result_error_label">Google Play Billing Error</string>
<string name="upgrades_gplay_billing_result_error_description">There was an error when asking Google Play for your purchase details. Clear Google Play cache and reboot your phone.\n\nError %s</string>
<string name="upgrade_restore_action">Restore purchase</string>
</resources>