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 @Composable
internal fun SettingsIndexContent() = PreviewWrapper { internal fun SettingsIndexContent() = PreviewWrapper {
SettingsScreen( SettingsScreen(
state = SettingsViewModel.State(sponsorUrl = "https://example.com"), state = SettingsViewModel.State(isPro = true, sponsorUrl = "https://example.com"),
onNavigateUp = {}, onNavigateUp = {},
onGeneralSettings = {}, onGeneralSettings = {},
onDeviceManager = {}, onDeviceManager = {},
@@ -1,10 +1,5 @@
package eu.darken.capod.common.upgrade.core 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 eu.darken.capod.common.upgrade.UpgradeRepo
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@@ -15,7 +10,6 @@ import javax.inject.Singleton
@Singleton @Singleton
class UpgradeControlFoss @Inject constructor( class UpgradeControlFoss @Inject constructor(
private val fossCache: FossCache, private val fossCache: FossCache,
private val webpageTool: WebpageTool,
) : UpgradeRepo { ) : UpgradeRepo {
override val upgradeInfo: Flow<UpgradeRepo.Info> = fossCache.upgrade.flow.map { data -> override val upgradeInfo: Flow<UpgradeRepo.Info> = fossCache.upgrade.flow.map { data ->
@@ -30,34 +24,11 @@ class UpgradeControlFoss @Inject constructor(
} }
} }
override fun launchBillingFlow(activity: Activity) { fun upgrade(reason: FossUpgrade.Reason) {
MaterialAlertDialogBuilder(activity).apply { fossCache.upgrade.value = FossUpgrade(
setIcon(R.drawable.ic_heart) upgradedAt = Instant.now(),
setTitle(R.string.upgrade_capod_label) reason = reason
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()
} }
data class Info( 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_donate_label">Donate</string>
<string name="foss_upgrade_alreadydonated_label">I already donated</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="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> </resources>
@@ -1,16 +1,9 @@
package eu.darken.capod.common.upgrade.core 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.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.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.log
import eu.darken.capod.common.debug.logging.logTag 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.flow.replayingShare
import eu.darken.capod.common.upgrade.UpgradeRepo import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.common.upgrade.core.data.BillingData 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.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.retryWhen import kotlinx.coroutines.flow.retryWhen
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.time.Instant import java.time.Instant
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -31,7 +22,6 @@ import kotlin.math.pow
@Singleton @Singleton
class UpgradeRepoGplay @Inject constructor( class UpgradeRepoGplay @Inject constructor(
@AppScope private val scope: CoroutineScope, @AppScope private val scope: CoroutineScope,
private val dispatcherProvider: DispatcherProvider,
private val billingDataRepo: BillingDataRepo, private val billingDataRepo: BillingDataRepo,
private val billingCache: BillingCache, private val billingCache: BillingCache,
) : UpgradeRepo { ) : UpgradeRepo {
@@ -76,49 +66,6 @@ class UpgradeRepoGplay @Inject constructor(
} }
.replayingShare(scope) .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( data class Info(
private val gracePeriod: Boolean = false, private val gracePeriod: Boolean = false,
private val billingData: BillingData?, 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_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_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="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> </resources>
@@ -18,6 +18,9 @@ object Nav {
@Serializable @Serializable
data object TroubleShooter : Main data object TroubleShooter : Main
@Serializable
data object Upgrade : Main
} }
sealed interface Settings : NavigationDestination { sealed interface Settings : NavigationDestination {
@@ -1,14 +1,11 @@
package eu.darken.capod.common.upgrade package eu.darken.capod.common.upgrade
import android.app.Activity
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import java.time.Instant import java.time.Instant
interface UpgradeRepo { interface UpgradeRepo {
val upgradeInfo: Flow<Info> val upgradeInfo: Flow<Info>
fun launchBillingFlow(activity: Activity)
fun getSponsorUrl(): String? = null fun getSponsorUrl(): String? = null
interface Info { interface Info {
@@ -1,10 +1,12 @@
package eu.darken.capod.main.ui package eu.darken.capod.main.ui
import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -53,6 +55,10 @@ class MainActivity : Activity2() {
val backStack = rememberNavBackStack(startDestination) val backStack = rememberNavBackStack(startDestination)
navCtrl.setup(backStack) navCtrl.setup(backStack)
LaunchedEffect(Unit) {
consumeUpgradeExtra(intent)
}
CapodTheme(state = themeState) { CapodTheme(state = themeState) {
val backgroundColor = MaterialTheme.colorScheme.background val backgroundColor = MaterialTheme.colorScheme.background
val useDarkIcons = backgroundColor.luminance() > 0.5f 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 { companion object {
const val EXTRA_NAVIGATE_TO_UPGRADE = "navigate_to_upgrade"
private val TAG = logTag("MainActivity") private val TAG = logTag("MainActivity")
} }
} }
@@ -1,6 +1,5 @@
package eu.darken.capod.main.ui.overview package eu.darken.capod.main.ui.overview
import android.app.Activity
import android.content.Intent import android.content.Intent
import android.provider.Settings import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
@@ -67,7 +66,6 @@ fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) {
NavigationEventHandler(vm) NavigationEventHandler(vm)
val context = LocalContext.current val context = LocalContext.current
val activity = context as? Activity
// Collect workerAutolaunch passively to keep it active // Collect workerAutolaunch passively to keep it active
LaunchedEffect(Unit) { 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 state by vm.state.collectAsStateWithLifecycle(initialValue = null)
val currentState = state ?: return val currentState = state ?: return
@@ -1,6 +1,5 @@
package eu.darken.capod.main.ui.overview package eu.darken.capod.main.ui.overview
import android.app.Activity
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.capod.common.bluetooth.BluetoothManager2 import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.coroutine.DispatcherProvider import eu.darken.capod.common.coroutine.DispatcherProvider
@@ -50,7 +49,6 @@ class OverviewViewModel @Inject constructor(
) : ViewModel4(dispatcherProvider) { ) : ViewModel4(dispatcherProvider) {
val requestPermissionEvent = SingleEventFlow<Permission>() val requestPermissionEvent = SingleEventFlow<Permission>()
val launchUpgradeFlow = SingleEventFlow<(Activity) -> Unit>()
private val showUnmatchedDevices = MutableStateFlow(false) private val showUnmatchedDevices = MutableStateFlow(false)
@@ -141,8 +139,8 @@ class OverviewViewModel @Inject constructor(
navTo(Nav.Main.DeviceManager) navTo(Nav.Main.DeviceManager)
} }
fun onUpgrade() = launch { fun onUpgrade() {
launchUpgradeFlow.tryEmit { upgradeRepo.launchBillingFlow(it) } navTo(Nav.Main.Upgrade)
} }
fun toggleUnmatchedDevices() { fun toggleUnmatchedDevices() {
@@ -94,11 +94,11 @@ fun SettingsScreen(
}, },
actions = { actions = {
val sponsorUrl = state.sponsorUrl val sponsorUrl = state.sponsorUrl
if (sponsorUrl != null) { if (state.isPro && sponsorUrl != null) {
IconButton(onClick = { onSponsor(sponsorUrl) }) { IconButton(onClick = { onSponsor(sponsorUrl) }) {
Icon( Icon(
imageVector = Icons.TwoTone.Favorite, imageVector = Icons.TwoTone.Favorite,
contentDescription = "Sponsor development", contentDescription = null,
) )
} }
} }
@@ -190,7 +190,7 @@ fun SettingsScreen(
@Composable @Composable
private fun SettingsScreenPreview() = PreviewWrapper { private fun SettingsScreenPreview() = PreviewWrapper {
SettingsScreen( SettingsScreen(
state = SettingsViewModel.State(sponsorUrl = "https://example.com"), state = SettingsViewModel.State(isPro = true, sponsorUrl = "https://example.com"),
onNavigateUp = {}, onNavigateUp = {},
onGeneralSettings = {}, onGeneralSettings = {},
onDeviceManager = {}, onDeviceManager = {},
@@ -18,11 +18,12 @@ class SettingsViewModel @Inject constructor(
) : ViewModel4(dispatcherProvider) { ) : ViewModel4(dispatcherProvider) {
data class State( data class State(
val isPro: Boolean,
val sponsorUrl: String?, val sponsorUrl: String?,
) )
val state = upgradeRepo.upgradeInfo val state = upgradeRepo.upgradeInfo
.map { State(sponsorUrl = upgradeRepo.getSponsorUrl()) } .map { State(isPro = it.isPro, sponsorUrl = upgradeRepo.getSponsorUrl()) }
.asLiveState() .asLiveState()
fun openUrl(url: String) { fun openUrl(url: String) {
@@ -1,6 +1,5 @@
package eu.darken.capod.main.ui.settings.general package eu.darken.capod.main.ui.settings.general
import android.app.Activity
import android.os.Build import android.os.Build
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -35,14 +34,12 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -69,11 +66,6 @@ fun GeneralSettingsScreenHost(vm: GeneralSettingsViewModel = hiltViewModel()) {
ErrorEventHandler(vm) ErrorEventHandler(vm)
NavigationEventHandler(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) val state by vm.state.collectAsStateWithLifecycle(initialValue = null)
state?.let { state?.let {
GeneralSettingsScreen( GeneralSettingsScreen(
@@ -1,11 +1,9 @@
package eu.darken.capod.main.ui.settings.general package eu.darken.capod.main.ui.settings.general
import android.app.Activity
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.capod.common.bluetooth.ScannerMode import eu.darken.capod.common.bluetooth.ScannerMode
import eu.darken.capod.common.coroutine.DispatcherProvider import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.logging.logTag 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.navigation.Nav
import eu.darken.capod.common.theming.ThemeColor import eu.darken.capod.common.theming.ThemeColor
import eu.darken.capod.common.theming.ThemeMode import eu.darken.capod.common.theming.ThemeMode
@@ -42,8 +40,6 @@ class GeneralSettingsViewModel @Inject constructor(
private val isPro = upgradeRepo.upgradeInfo.map { it.isPro }.asLiveState() private val isPro = upgradeRepo.upgradeInfo.map { it.isPro }.asLiveState()
val launchUpgradeFlow = SingleEventFlow<(Activity) -> Unit>()
val state = combine( val state = combine(
combine( combine(
generalSettings.monitorMode.flow, generalSettings.monitorMode.flow,
@@ -110,7 +106,7 @@ class GeneralSettingsViewModel @Inject constructor(
if (isPro.first()) { if (isPro.first()) {
generalSettings.themeMode.value = mode generalSettings.themeMode.value = mode
} else { } else {
launchUpgradeFlow.tryEmit { upgradeRepo.launchBillingFlow(it) } navTo(Nav.Main.Upgrade)
} }
} }
@@ -118,7 +114,7 @@ class GeneralSettingsViewModel @Inject constructor(
if (isPro.first()) { if (isPro.first()) {
generalSettings.themeStyle.value = style generalSettings.themeStyle.value = style
} else { } else {
launchUpgradeFlow.tryEmit { upgradeRepo.launchBillingFlow(it) } navTo(Nav.Main.Upgrade)
} }
} }
@@ -126,12 +122,12 @@ class GeneralSettingsViewModel @Inject constructor(
if (isPro.first()) { if (isPro.first()) {
generalSettings.themeColor.value = color generalSettings.themeColor.value = color
} else { } else {
launchUpgradeFlow.tryEmit { upgradeRepo.launchBillingFlow(it) } navTo(Nav.Main.Upgrade)
} }
} }
fun launchUpgrade() = launch { fun launchUpgrade() {
launchUpgradeFlow.tryEmit { upgradeRepo.launchBillingFlow(it) } navTo(Nav.Main.Upgrade)
} }
fun goToDebugSettings() { 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.debug.logging.logTag
import eu.darken.capod.common.theming.CapodTheme import eu.darken.capod.common.theming.CapodTheme
import eu.darken.capod.common.uix.Activity2 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.GeneralSettings
import eu.darken.capod.main.core.currentThemeState import eu.darken.capod.main.core.currentThemeState
import eu.darken.capod.main.core.themeState import eu.darken.capod.main.core.themeState
@@ -34,7 +34,6 @@ class WidgetConfigurationActivity : Activity2() {
private val vm: WidgetConfigurationViewModel by viewModels() private val vm: WidgetConfigurationViewModel by viewModels()
@Inject lateinit var upgradeRepo: UpgradeRepo
@Inject lateinit var generalSettings: GeneralSettings @Inject lateinit var generalSettings: GeneralSettings
@ApplicationContext @Inject lateinit var appContext: Context @ApplicationContext @Inject lateinit var appContext: Context
@@ -87,7 +86,11 @@ class WidgetConfigurationActivity : Activity2() {
if (currentState.isPro) { if (currentState.isPro) {
confirmSelection() confirmSelection()
} else { } else {
upgradeRepo.launchBillingFlow(this@WidgetConfigurationActivity) startActivity(
Intent(this@WidgetConfigurationActivity, MainActivity::class.java).apply {
putExtra(MainActivity.EXTRA_NAVIGATE_TO_UPGRADE, true)
}
)
} }
}, },
onCancel = { finish() }, onCancel = { finish() },
@@ -1,6 +1,5 @@
package eu.darken.capod.reaction.ui package eu.darken.capod.reaction.ui
import android.app.Activity
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth 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.PauseCircle
import androidx.compose.material.icons.twotone.PlayCircle import androidx.compose.material.icons.twotone.PlayCircle
import androidx.compose.material.icons.twotone.QuestionMark 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.material.icons.twotone.Workspaces
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@@ -35,7 +36,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -56,19 +56,18 @@ fun ReactionSettingsScreenHost(vm: ReactionSettingsViewModel = hiltViewModel())
NavigationEventHandler(vm) NavigationEventHandler(vm)
val state by vm.state.collectAsStateWithLifecycle(initialValue = null) val state by vm.state.collectAsStateWithLifecycle(initialValue = null)
val activity = LocalContext.current as Activity
state?.let { state?.let {
ReactionSettingsScreen( ReactionSettingsScreen(
state = it, state = it,
onNavigateUp = { vm.navUp() }, onNavigateUp = { vm.navUp() },
onOnePodModeChanged = { enabled -> vm.setOnePodMode(enabled) }, onOnePodModeChanged = { enabled -> vm.setOnePodMode(enabled) },
onAutoPlayChanged = { enabled -> vm.setAutoPlay(enabled, activity) }, onAutoPlayChanged = { enabled -> vm.setAutoPlay(enabled) },
onAutoPauseChanged = { enabled -> vm.setAutoPause(enabled, activity) }, onAutoPauseChanged = { enabled -> vm.setAutoPause(enabled) },
onAutoConnectChanged = { enabled -> vm.setAutoConnect(enabled, activity) }, onAutoConnectChanged = { enabled -> vm.setAutoConnect(enabled) },
onAutoConnectConditionSelected = { condition -> vm.setAutoConnectCondition(condition) }, onAutoConnectConditionSelected = { condition -> vm.setAutoConnectCondition(condition) },
onShowPopUpOnCaseOpenChanged = { enabled -> vm.setShowPopUpOnCaseOpen(enabled, activity) }, onShowPopUpOnCaseOpenChanged = { enabled -> vm.setShowPopUpOnCaseOpen(enabled) },
onShowPopUpOnConnectionChanged = { enabled -> vm.setShowPopUpOnConnection(enabled, activity) }, onShowPopUpOnConnectionChanged = { enabled -> vm.setShowPopUpOnConnection(enabled) },
) )
} }
} }
@@ -131,11 +130,7 @@ fun ReactionSettingsScreen(
icon = Icons.TwoTone.PlayCircle, icon = Icons.TwoTone.PlayCircle,
onClick = { onAutoPlayChanged(!state.autoPlay) }, onClick = { onAutoPlayChanged(!state.autoPlay) },
trailingContent = { trailingContent = {
Switch( ProGatedToggle(isPro = state.isPro, checked = state.autoPlay, onCheckedChange = onAutoPlayChanged)
checked = state.autoPlay,
onCheckedChange = onAutoPlayChanged,
modifier = Modifier.padding(start = 16.dp),
)
}, },
) )
} }
@@ -146,11 +141,7 @@ fun ReactionSettingsScreen(
icon = Icons.TwoTone.PauseCircle, icon = Icons.TwoTone.PauseCircle,
onClick = { onAutoPauseChanged(!state.autoPause) }, onClick = { onAutoPauseChanged(!state.autoPause) },
trailingContent = { trailingContent = {
Switch( ProGatedToggle(isPro = state.isPro, checked = state.autoPause, onCheckedChange = onAutoPauseChanged)
checked = state.autoPause,
onCheckedChange = onAutoPauseChanged,
modifier = Modifier.padding(start = 16.dp),
)
}, },
) )
} }
@@ -173,11 +164,7 @@ fun ReactionSettingsScreen(
icon = Icons.TwoTone.BluetoothConnected, icon = Icons.TwoTone.BluetoothConnected,
onClick = { onAutoConnectChanged(!state.autoConnect) }, onClick = { onAutoConnectChanged(!state.autoConnect) },
trailingContent = { trailingContent = {
Switch( ProGatedToggle(isPro = state.isPro, checked = state.autoConnect, onCheckedChange = onAutoConnectChanged)
checked = state.autoConnect,
onCheckedChange = onAutoConnectChanged,
modifier = Modifier.padding(start = 16.dp),
)
}, },
) )
} }
@@ -200,11 +187,7 @@ fun ReactionSettingsScreen(
icon = Icons.AutoMirrored.TwoTone.Message, icon = Icons.AutoMirrored.TwoTone.Message,
onClick = { onShowPopUpOnCaseOpenChanged(!state.showPopUpOnCaseOpen) }, onClick = { onShowPopUpOnCaseOpenChanged(!state.showPopUpOnCaseOpen) },
trailingContent = { trailingContent = {
Switch( ProGatedToggle(isPro = state.isPro, checked = state.showPopUpOnCaseOpen, onCheckedChange = onShowPopUpOnCaseOpenChanged)
checked = state.showPopUpOnCaseOpen,
onCheckedChange = onShowPopUpOnCaseOpenChanged,
modifier = Modifier.padding(start = 16.dp),
)
}, },
) )
} }
@@ -215,11 +198,7 @@ fun ReactionSettingsScreen(
icon = Icons.AutoMirrored.TwoTone.Message, icon = Icons.AutoMirrored.TwoTone.Message,
onClick = { onShowPopUpOnConnectionChanged(!state.showPopUpOnConnection) }, onClick = { onShowPopUpOnConnectionChanged(!state.showPopUpOnConnection) },
trailingContent = { trailingContent = {
Switch( ProGatedToggle(isPro = state.isPro, checked = state.showPopUpOnConnection, onCheckedChange = onShowPopUpOnConnectionChanged)
checked = state.showPopUpOnConnection,
onCheckedChange = onShowPopUpOnConnectionChanged,
modifier = Modifier.padding(start = 16.dp),
)
}, },
) )
} }
@@ -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 @Preview2
@Composable @Composable
private fun ReactionSettingsScreenPreview() = PreviewWrapper { private fun ReactionSettingsScreenPreview() = PreviewWrapper {
@@ -1,10 +1,9 @@
package eu.darken.capod.reaction.ui package eu.darken.capod.reaction.ui
import android.app.Activity
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.capod.common.coroutine.DispatcherProvider import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.logging.logTag 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.uix.ViewModel4
import eu.darken.capod.common.upgrade.UpgradeRepo import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.main.core.GeneralSettings import eu.darken.capod.main.core.GeneralSettings
@@ -63,7 +62,7 @@ class ReactionSettingsViewModel @Inject constructor(
reactionSettings.onePodMode.value = enabled reactionSettings.onePodMode.value = enabled
} }
fun setAutoPlay(enabled: Boolean, activity: Activity) = launch { fun setAutoPlay(enabled: Boolean) = launch {
if (!enabled) { if (!enabled) {
reactionSettings.autoPlay.value = false reactionSettings.autoPlay.value = false
return@launch return@launch
@@ -71,11 +70,11 @@ class ReactionSettingsViewModel @Inject constructor(
if (isPro.first()) { if (isPro.first()) {
reactionSettings.autoPlay.value = true reactionSettings.autoPlay.value = true
} else { } else {
upgradeRepo.launchBillingFlow(activity) navTo(Nav.Main.Upgrade)
} }
} }
fun setAutoPause(enabled: Boolean, activity: Activity) = launch { fun setAutoPause(enabled: Boolean) = launch {
if (!enabled) { if (!enabled) {
reactionSettings.autoPause.value = false reactionSettings.autoPause.value = false
return@launch return@launch
@@ -83,11 +82,11 @@ class ReactionSettingsViewModel @Inject constructor(
if (isPro.first()) { if (isPro.first()) {
reactionSettings.autoPause.value = true reactionSettings.autoPause.value = true
} else { } else {
upgradeRepo.launchBillingFlow(activity) navTo(Nav.Main.Upgrade)
} }
} }
fun setAutoConnect(enabled: Boolean, activity: Activity) = launch { fun setAutoConnect(enabled: Boolean) = launch {
if (!enabled) { if (!enabled) {
reactionSettings.autoConnect.value = false reactionSettings.autoConnect.value = false
return@launch return@launch
@@ -96,7 +95,7 @@ class ReactionSettingsViewModel @Inject constructor(
reactionSettings.autoConnect.value = true reactionSettings.autoConnect.value = true
generalSettings.monitorMode.value = MonitorMode.ALWAYS generalSettings.monitorMode.value = MonitorMode.ALWAYS
} else { } else {
upgradeRepo.launchBillingFlow(activity) navTo(Nav.Main.Upgrade)
} }
} }
@@ -104,7 +103,7 @@ class ReactionSettingsViewModel @Inject constructor(
reactionSettings.autoConnectCondition.value = condition reactionSettings.autoConnectCondition.value = condition
} }
fun setShowPopUpOnCaseOpen(enabled: Boolean, activity: Activity) = launch { fun setShowPopUpOnCaseOpen(enabled: Boolean) = launch {
if (!enabled) { if (!enabled) {
reactionSettings.showPopUpOnCaseOpen.value = false reactionSettings.showPopUpOnCaseOpen.value = false
return@launch return@launch
@@ -112,11 +111,11 @@ class ReactionSettingsViewModel @Inject constructor(
if (isPro.first()) { if (isPro.first()) {
reactionSettings.showPopUpOnCaseOpen.value = true reactionSettings.showPopUpOnCaseOpen.value = true
} else { } else {
upgradeRepo.launchBillingFlow(activity) navTo(Nav.Main.Upgrade)
} }
} }
fun setShowPopUpOnConnection(enabled: Boolean, activity: Activity) = launch { fun setShowPopUpOnConnection(enabled: Boolean) = launch {
if (!enabled) { if (!enabled) {
reactionSettings.showPopUpOnConnection.value = false reactionSettings.showPopUpOnConnection.value = false
return@launch return@launch
@@ -124,7 +123,7 @@ class ReactionSettingsViewModel @Inject constructor(
if (isPro.first()) { if (isPro.first()) {
reactionSettings.showPopUpOnConnection.value = true reactionSettings.showPopUpOnConnection.value = true
} else { } 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_label">Upgrade CAPod</string>
<string name="upgrade_capod_description">Get additional features and support the developer.</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_label">Monitor mode</string>
<string name="settings_monitor_mode_description">Under which circumstances this app monitors Bluetooth data.</string> <string name="settings_monitor_mode_description">Under which circumstances this app monitors Bluetooth data.</string>