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
@@ -116,7 +116,7 @@ internal fun AddProfileContent() = PreviewWrapper {
@Composable
internal fun SettingsIndexContent() = PreviewWrapper {
SettingsScreen(
state = SettingsViewModel.State(sponsorUrl = "https://example.com"),
state = SettingsViewModel.State(isPro = true, sponsorUrl = "https://example.com"),
onNavigateUp = {},
onGeneralSettings = {},
onDeviceManager = {},
@@ -1,10 +1,5 @@
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.WebpageTool
import eu.darken.capod.common.upgrade.UpgradeRepo
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
@@ -15,7 +10,6 @@ import javax.inject.Singleton
@Singleton
class UpgradeControlFoss @Inject constructor(
private val fossCache: FossCache,
private val webpageTool: WebpageTool,
) : UpgradeRepo {
override val upgradeInfo: Flow<UpgradeRepo.Info> = fossCache.upgrade.flow.map { data ->
@@ -30,34 +24,11 @@ class UpgradeControlFoss @Inject constructor(
}
}
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.foss_upgrade_donate_label) { _, _ ->
fossCache.upgrade.value = FossUpgrade(
upgradedAt = Instant.now(),
reason = FossUpgrade.Reason.DONATED
)
webpageTool.open("https://github.com/sponsors/d4rken")
Toast.makeText(activity, R.string.general_thank_you_label, Toast.LENGTH_SHORT).show()
}
setNegativeButton(R.string.foss_upgrade_alreadydonated_label) { _, _ ->
fossCache.upgrade.value = FossUpgrade(
upgradedAt = Instant.now(),
reason = FossUpgrade.Reason.ALREADY_DONATED
)
Toast.makeText(activity, R.string.general_thank_you_label, Toast.LENGTH_SHORT).show()
}
setNeutralButton(R.string.foss_upgrade_no_money_label) { _, _ ->
fossCache.upgrade.value = FossUpgrade(
upgradedAt = Instant.now(),
reason = FossUpgrade.Reason.NO_MONEY
)
Toast.makeText(activity, "¯\\_(ツ)_/¯", Toast.LENGTH_SHORT).show()
}
}.show()
fun upgrade(reason: FossUpgrade.Reason) {
fossCache.upgrade.value = FossUpgrade(
upgradedAt = Instant.now(),
reason = reason
)
}
data class Info(
@@ -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,221 @@
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.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.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.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
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)
UpgradeScreen(
onNavigateUp = { vm.navUp() },
onSponsor = { vm.sponsor() },
)
}
private data class Benefit(val icon: ImageVector, val textRes: Int)
@Composable
fun UpgradeScreen(
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.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.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,
)
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_foss_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.secondaryContainer,
modifier = Modifier.size(28.dp),
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = benefit.icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.secondary,
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 = 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,
)
}
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 = {},
onSponsor = {},
)
}
@@ -0,0 +1,23 @@
package eu.darken.capod.upgrade.ui
import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.capod.common.WebpageTool
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.uix.ViewModel4
import eu.darken.capod.common.upgrade.core.FossUpgrade
import eu.darken.capod.common.upgrade.core.UpgradeControlFoss
import javax.inject.Inject
@HiltViewModel
class UpgradeViewModel @Inject constructor(
dispatcherProvider: DispatcherProvider,
private val upgradeControlFoss: UpgradeControlFoss,
private val webpageTool: WebpageTool,
) : ViewModel4(dispatcherProvider) {
fun sponsor() {
upgradeControlFoss.upgrade(FossUpgrade.Reason.DONATED)
webpageTool.open("https://github.com/sponsors/d4rken")
navUp()
}
}
+2
View File
@@ -3,4 +3,6 @@
<string name="foss_upgrade_donate_label">Donate</string>
<string name="foss_upgrade_alreadydonated_label">I already donated</string>
<string name="foss_upgrade_no_money_label">I spend all my money on AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS is free and open source. If you find it useful, consider sponsoring development to help keep the project going.</string>
<string name="upgrade_foss_sponsor_action">Sponsor development</string>
</resources>
@@ -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>
@@ -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>