diff --git a/app/src/main/java/org/tasks/auth/SignInActivity.kt b/app/src/main/java/org/tasks/auth/SignInActivity.kt index 5b6997293..22799a9a2 100644 --- a/app/src/main/java/org/tasks/auth/SignInActivity.kt +++ b/app/src/main/java/org/tasks/auth/SignInActivity.kt @@ -43,7 +43,7 @@ import org.tasks.TasksApplication.Companion.IS_GENERIC import org.tasks.analytics.Firebase import org.tasks.billing.Inventory import org.tasks.billing.PurchaseActivity -import org.tasks.billing.PurchaseActivity.Companion.EXTRA_GITHUB +import org.tasks.billing.PurchaseActivityViewModel.Companion.EXTRA_GITHUB import org.tasks.compose.ConsentDialog import org.tasks.compose.SignInDialog import org.tasks.dialogs.DialogBuilder diff --git a/app/src/main/java/org/tasks/billing/PurchaseActivity.kt b/app/src/main/java/org/tasks/billing/PurchaseActivity.kt index c9bb71d7b..def647a8d 100644 --- a/app/src/main/java/org/tasks/billing/PurchaseActivity.kt +++ b/app/src/main/java/org/tasks/billing/PurchaseActivity.kt @@ -1,138 +1,55 @@ package org.tasks.billing -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent import android.os.Bundle import androidx.activity.compose.BackHandler import androidx.activity.compose.setContent import androidx.appcompat.app.AppCompatActivity -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.mutableStateOf -import androidx.lifecycle.lifecycleScope +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.launch -import org.tasks.LocalBroadcastManager -import org.tasks.R -import org.tasks.analytics.Firebase import org.tasks.compose.PurchaseText.SubscriptionScreen -import org.tasks.extensions.Context.toast -import org.tasks.preferences.Preferences +import org.tasks.extensions.Context.findActivity import org.tasks.themes.TasksTheme import org.tasks.themes.Theme -import java.util.Locale import javax.inject.Inject @AndroidEntryPoint class PurchaseActivity : AppCompatActivity(), OnPurchasesUpdated { @Inject lateinit var theme: Theme - @Inject lateinit var billingClient: BillingClient - @Inject lateinit var localBroadcastManager: LocalBroadcastManager - @Inject lateinit var inventory: Inventory - @Inject lateinit var preferences: Preferences - @Inject lateinit var firebase: Firebase - - private var currentSubscription: Purchase? = null - private val purchaseReceiver: BroadcastReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - setup() - } - } - private val nameYourPrice = mutableStateOf(false) - private val sliderPosition = mutableStateOf(-1f) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val github = intent?.extras?.getBoolean(EXTRA_GITHUB) ?: false - theme.applyToContext(this) - if (savedInstanceState == null) { - nameYourPrice.value = intent?.extras?.getBoolean(EXTRA_NAME_YOUR_PRICE) ?: firebase.nameYourPrice - } else { - nameYourPrice.value = savedInstanceState.getBoolean(EXTRA_NAME_YOUR_PRICE) - sliderPosition.value = savedInstanceState.getFloat(EXTRA_PRICE) - } - setContent { TasksTheme(theme = theme.themeBase.index) { BackHandler { finish() } + val viewModel: PurchaseActivityViewModel = viewModel() + val state = viewModel.viewState.collectAsStateWithLifecycle().value + val context = LocalContext.current SubscriptionScreen( - nameYourPrice = nameYourPrice, - sliderPosition = sliderPosition, - github = github, - subscribe = this::purchase, + nameYourPrice = state.nameYourPrice, + sliderPosition = state.price, + github = state.isGithub, + subscribe = { price, isMonthly -> + context.findActivity()?.let { viewModel.purchase(it, price, isMonthly) } + }, + setPrice = { viewModel.setPrice(it) }, + setNameYourPrice = { viewModel.setNameYourPrice(it) }, onBack = { finish() }, ) - LaunchedEffect(key1 = Unit) { - firebase.logEvent(R.string.event_showed_purchase_dialog) - } - } - } - } - - override fun onStart() { - super.onStart() - - localBroadcastManager.registerPurchaseReceiver(purchaseReceiver) - lifecycleScope.launch { - try { - billingClient.queryPurchases(throwError = true) - } catch (e: Exception) { - toast(e.message) } } } - override fun onStop() { - super.onStop() - localBroadcastManager.unregisterReceiver(purchaseReceiver) - } - - private fun setup() { - currentSubscription = inventory.subscription.value - if (sliderPosition.value < 0) { - sliderPosition.value = - currentSubscription - ?.subscriptionPrice - ?.coerceAtMost(25) - ?.toFloat() ?: 10f - } - } - - private fun purchase(price: Int, monthly: Boolean) = lifecycleScope.launch { - val newSku = String.format(Locale.US, "%s_%02d", if (monthly) "monthly" else "annual", price) - try { - billingClient.initiatePurchaseFlow( - this@PurchaseActivity, - newSku, - BillingClientImpl.TYPE_SUBS, - currentSubscription?.takeIf { it.sku != newSku } - ) - } catch (e: Exception) { - this@PurchaseActivity.toast(e.message) - } - } - override fun onPurchasesUpdated(success: Boolean) { if (success) { setResult(RESULT_OK) finish() } } - - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - outState.putFloat(EXTRA_PRICE, sliderPosition.value) - outState.putBoolean(EXTRA_NAME_YOUR_PRICE, nameYourPrice.value) - } - - companion object { - const val EXTRA_GITHUB = "extra_github" - private const val EXTRA_PRICE = "extra_price" - const val EXTRA_NAME_YOUR_PRICE = "extra_name_your_price" - } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/tasks/billing/PurchaseActivityViewModel.kt b/app/src/main/java/org/tasks/billing/PurchaseActivityViewModel.kt new file mode 100644 index 000000000..417bb789c --- /dev/null +++ b/app/src/main/java/org/tasks/billing/PurchaseActivityViewModel.kt @@ -0,0 +1,109 @@ +package org.tasks.billing + +import android.app.Activity +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.tasks.LocalBroadcastManager +import org.tasks.R +import org.tasks.analytics.Firebase +import java.util.Locale +import javax.inject.Inject + +@HiltViewModel +class PurchaseActivityViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val inventory: Inventory, + private val billingClient: BillingClient, + private val localBroadcastManager: LocalBroadcastManager, + firebase: Firebase, +) : ViewModel() { + + data class ViewState( + val nameYourPrice: Boolean, + val isGithub: Boolean, + val price: Float = -1f, + val subscription: Purchase? = null, + val error: String? = null, + ) + + private val purchaseReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val subscription = inventory.subscription.value + _viewState.update { state -> + state.copy( + subscription = subscription, + price = state.price.takeIf { it > 0 } + ?: subscription?.subscriptionPrice?.coerceAtMost(25)?.toFloat() + ?: 10f + ) + } + } + } + + fun setPrice(price: Float) { + _viewState.update { it.copy(price = price) } + } + + private val _viewState = MutableStateFlow( + ViewState( + nameYourPrice = savedStateHandle.get(EXTRA_NAME_YOUR_PRICE) ?: firebase.nameYourPrice, + isGithub = savedStateHandle.get(EXTRA_GITHUB) ?: false, + ) + ) + val viewState: StateFlow = _viewState + + init { + localBroadcastManager.registerPurchaseReceiver(purchaseReceiver) + + viewModelScope.launch { + try { + billingClient.queryPurchases(throwError = true) + } catch (e: Exception) { + _viewState.update { + it.copy(error = e.message) + } + } + } + + firebase.logEvent(R.string.event_showed_purchase_dialog) + } + + override fun onCleared() { + super.onCleared() + localBroadcastManager.unregisterReceiver(purchaseReceiver) + } + + fun purchase(activity: Activity, price: Int, monthly: Boolean) = viewModelScope.launch { + val newSku = String.format(Locale.US, "%s_%02d", if (monthly) "monthly" else "annual", price) + try { + billingClient.initiatePurchaseFlow( + activity, + newSku, + BillingClientImpl.TYPE_SUBS, + _viewState.value.subscription?.takeIf { it.sku != newSku }, + ) + } catch (e: Exception) { + _viewState.update { + it.copy(error = e.message) + } + } + } + + fun setNameYourPrice(nameYourPrice: Boolean) { + _viewState.update { it.copy(nameYourPrice = nameYourPrice) } + } + + companion object { + const val EXTRA_GITHUB = "extra_github" + const val EXTRA_NAME_YOUR_PRICE = "extra_name_your_price" + } +} diff --git a/app/src/main/java/org/tasks/compose/IconPickerActivity.kt b/app/src/main/java/org/tasks/compose/IconPickerActivity.kt index e5dd1ded1..36149b32c 100644 --- a/app/src/main/java/org/tasks/compose/IconPickerActivity.kt +++ b/app/src/main/java/org/tasks/compose/IconPickerActivity.kt @@ -24,7 +24,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import dagger.hilt.android.AndroidEntryPoint import org.tasks.billing.Inventory import org.tasks.billing.PurchaseActivity -import org.tasks.billing.PurchaseActivity.Companion.EXTRA_NAME_YOUR_PRICE +import org.tasks.billing.PurchaseActivityViewModel.Companion.EXTRA_NAME_YOUR_PRICE import org.tasks.compose.pickers.IconPicker import org.tasks.compose.pickers.IconPickerViewModel import org.tasks.themes.TasksTheme diff --git a/app/src/main/java/org/tasks/compose/Subscription.kt b/app/src/main/java/org/tasks/compose/Subscription.kt index 2cfa3d80a..e6313acaf 100644 --- a/app/src/main/java/org/tasks/compose/Subscription.kt +++ b/app/src/main/java/org/tasks/compose/Subscription.kt @@ -39,9 +39,6 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -138,9 +135,11 @@ object PurchaseText { @OptIn(ExperimentalMaterial3Api::class) @Composable fun SubscriptionScreen( - nameYourPrice: MutableState = mutableStateOf(false), - sliderPosition: MutableState = mutableStateOf(0f), + nameYourPrice: Boolean, + sliderPosition: Float, github: Boolean = false, + setPrice: (Float) -> Unit, + setNameYourPrice: (Boolean) -> Unit, subscribe: (Int, Boolean) -> Unit, onBack: () -> Unit, ) { @@ -186,7 +185,7 @@ object PurchaseText { state = pagerState // Optional: to control the pager's state ) { index -> val item = featureList[index] - PagerItem(item, nameYourPrice.value && index == 0) + PagerItem(item, nameYourPrice && index == 0) } Row( Modifier @@ -214,6 +213,8 @@ object PurchaseText { nameYourPrice = nameYourPrice, sliderPosition = sliderPosition, pagerState = pagerState, + setNameYourPrice = setNameYourPrice, + setPrice = setPrice, subscribe = subscribe, ) } @@ -259,9 +260,11 @@ object PurchaseText { @Composable fun GooglePlayButtons( - nameYourPrice: MutableState, - sliderPosition: MutableState, + nameYourPrice: Boolean, + sliderPosition: Float, pagerState: PagerState, + setNameYourPrice: (Boolean) -> Unit, + setPrice: (Float) -> Unit, subscribe: (Int, Boolean) -> Unit, ) { Column( @@ -269,8 +272,12 @@ object PurchaseText { horizontalAlignment = Alignment.CenterHorizontally, ) { HorizontalDivider(modifier = Modifier.padding(vertical = KEYLINE_FIRST)) - if (nameYourPrice.value) { - NameYourPrice(sliderPosition, subscribe) + if (nameYourPrice) { + NameYourPrice( + sliderPosition = sliderPosition, + setPrice = setPrice, + subscribe = subscribe, + ) } else { TasksAccount(subscribe) } @@ -278,7 +285,7 @@ object PurchaseText { val scope = rememberCoroutineScope() OutlinedButton( onClick = { - nameYourPrice.value = !nameYourPrice.value + setNameYourPrice(!nameYourPrice) scope.launch { pagerState.animateScrollToPage(0) } @@ -419,7 +426,11 @@ object PurchaseText { } @Composable - fun NameYourPrice(sliderPosition: MutableState, subscribe: (Int, Boolean) -> Unit) { + fun NameYourPrice( + sliderPosition: Float, + setPrice: (Float) -> Unit, + subscribe: (Int, Boolean) -> Unit, + ) { Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, @@ -427,8 +438,8 @@ object PurchaseText { Row(Modifier.fillMaxWidth()) { Slider( modifier = Modifier.padding(KEYLINE_FIRST, 0.dp, KEYLINE_FIRST, HALF_KEYLINE), - value = sliderPosition.value, - onValueChange = { sliderPosition.value = it }, + value = sliderPosition, + onValueChange = { setPrice(it) }, valueRange = 1f..25f, steps = 25, colors = SliderDefaults.colors( @@ -445,17 +456,17 @@ object PurchaseText { horizontalArrangement = Arrangement.Center ) { PurchaseButton( - price = sliderPosition.value.toInt(), - popperText = if (sliderPosition.value.toInt() >= 7) + price = sliderPosition.toInt(), + popperText = if (sliderPosition.toInt() >= 7) "${stringResource(R.string.above_average, 16)} $POPPER" else "", onClick = subscribe ) - if (sliderPosition.value.toInt() < 3) { + if (sliderPosition.toInt() < 3) { Spacer(Modifier.width(KEYLINE_FIRST)) PurchaseButton( - price = sliderPosition.value.toInt(), + price = sliderPosition.toInt(), monthly = true, popperText = "${stringResource(R.string.above_average)} $POPPER", onClick = subscribe @@ -474,6 +485,10 @@ private fun PurchaseDialogPreview() { SubscriptionScreen( subscribe = { _, _ -> }, onBack = {}, + nameYourPrice = false, + sliderPosition = 1f, + setPrice = {}, + setNameYourPrice = {}, ) } } @@ -484,9 +499,12 @@ private fun PurchaseDialogPreview() { private fun NameYourPricePreview() { TasksTheme { SubscriptionScreen( - nameYourPrice = remember { mutableStateOf(true) }, subscribe = { _, _ -> }, onBack = {}, + nameYourPrice = true, + sliderPosition = 4f, + setPrice = {}, + setNameYourPrice = {}, ) } }