diff --git a/app/build.gradle b/app/build.gradle index 1bb02e347d..3ef4c7da94 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -188,6 +188,7 @@ dependencies { implementation libs.pickT implementation libs.emojiCompat implementation libs.navigationCompose + implementation libs.playBilling implementation libs.lifecycleViewModel implementation libs.lifecycleRuntime diff --git a/app/src/main/java/com/anytypeio/anytype/di/feature/payments/PaymentsDI.kt b/app/src/main/java/com/anytypeio/anytype/di/feature/payments/PaymentsDI.kt index 41f8d0b366..d1d8ba626f 100644 --- a/app/src/main/java/com/anytypeio/anytype/di/feature/payments/PaymentsDI.kt +++ b/app/src/main/java/com/anytypeio/anytype/di/feature/payments/PaymentsDI.kt @@ -1,14 +1,20 @@ package com.anytypeio.anytype.di.feature.payments +import android.content.Context import androidx.lifecycle.ViewModelProvider import com.anytypeio.anytype.analytics.base.Analytics import com.anytypeio.anytype.core_utils.di.scope.PerScreen import com.anytypeio.anytype.di.common.ComponentDependencies +import com.anytypeio.anytype.domain.auth.interactor.GetAccount +import com.anytypeio.anytype.domain.auth.repo.AuthRepository +import com.anytypeio.anytype.domain.base.AppCoroutineDispatchers +import com.anytypeio.anytype.payments.playbilling.BillingClientLifecycle import com.anytypeio.anytype.ui.payments.PaymentsFragment -import com.anytypeio.anytype.viewmodel.PaymentsViewModelFactory +import com.anytypeio.anytype.payments.viewmodel.PaymentsViewModelFactory import dagger.Binds import dagger.Component import dagger.Module +import dagger.Provides @Component( dependencies = [PaymentsComponentDependencies::class], @@ -31,6 +37,14 @@ interface PaymentsComponent { @Module object PaymentsModule { + @JvmStatic + @Provides + @PerScreen + fun provideGetAccountUseCase( + repo: AuthRepository, + dispatchers: AppCoroutineDispatchers + ): GetAccount = GetAccount(repo = repo, dispatcher = dispatchers) + @Module interface Declarations { @@ -45,4 +59,8 @@ object PaymentsModule { interface PaymentsComponentDependencies : ComponentDependencies { fun analytics(): Analytics + fun context(): Context + fun billingListener(): BillingClientLifecycle + fun authRepository(): AuthRepository + fun appCoroutineDispatchers(): AppCoroutineDispatchers } \ No newline at end of file diff --git a/app/src/main/java/com/anytypeio/anytype/di/main/BillingModule.kt b/app/src/main/java/com/anytypeio/anytype/di/main/BillingModule.kt new file mode 100644 index 0000000000..7520cf567b --- /dev/null +++ b/app/src/main/java/com/anytypeio/anytype/di/main/BillingModule.kt @@ -0,0 +1,28 @@ +package com.anytypeio.anytype.di.main + +import android.content.Context +import com.anytypeio.anytype.domain.base.AppCoroutineDispatchers +import com.anytypeio.anytype.payments.playbilling.BillingClientLifecycle +import dagger.Module +import dagger.Provides +import javax.inject.Named +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineScope + +@Module +object BillingModule { + + @Singleton + @Provides + fun provideBillingLifecycle( + context: Context, + dispatchers: AppCoroutineDispatchers, + @Named(ConfigModule.DEFAULT_APP_COROUTINE_SCOPE) scope: CoroutineScope + ): BillingClientLifecycle { + return BillingClientLifecycle( + dispatchers = dispatchers, + applicationContext = context, + scope = scope + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/anytypeio/anytype/di/main/MainComponent.kt b/app/src/main/java/com/anytypeio/anytype/di/main/MainComponent.kt index 0befdb314a..ff59906ecb 100644 --- a/app/src/main/java/com/anytypeio/anytype/di/main/MainComponent.kt +++ b/app/src/main/java/com/anytypeio/anytype/di/main/MainComponent.kt @@ -79,7 +79,8 @@ import javax.inject.Singleton CrashReportingModule::class, TemplatesModule::class, NetworkModeModule::class, - NotificationsModule::class + NotificationsModule::class, + BillingModule::class ] ) interface MainComponent : diff --git a/app/src/main/java/com/anytypeio/anytype/ui/main/MainActivity.kt b/app/src/main/java/com/anytypeio/anytype/ui/main/MainActivity.kt index fe2e9df898..f203c54ba9 100644 --- a/app/src/main/java/com/anytypeio/anytype/ui/main/MainActivity.kt +++ b/app/src/main/java/com/anytypeio/anytype/ui/main/MainActivity.kt @@ -3,11 +3,8 @@ package com.anytypeio.anytype.ui.main import android.content.Context import android.content.Intent import android.graphics.Color -import android.net.Uri import android.os.Build import android.os.Bundle -import android.os.Parcelable -import android.provider.OpenableColumns import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.core.os.bundleOf @@ -37,7 +34,6 @@ import com.anytypeio.anytype.presentation.main.MainViewModel import com.anytypeio.anytype.presentation.main.MainViewModel.Command import com.anytypeio.anytype.presentation.main.MainViewModelFactory import com.anytypeio.anytype.presentation.navigation.AppNavigation -import com.anytypeio.anytype.presentation.util.getExternalFilesDirTemp import com.anytypeio.anytype.presentation.wallpaper.WallpaperColor import com.anytypeio.anytype.ui.editor.CreateObjectFragment import com.anytypeio.anytype.ui.notifications.NotificationsFragment @@ -45,8 +41,6 @@ import com.anytypeio.anytype.ui.sharing.SharingFragment import com.anytypeio.anytype.ui_settings.appearance.ThemeApplicator import com.github.javiersantos.appupdater.AppUpdater import com.github.javiersantos.appupdater.enums.UpdateFrom -import java.io.File -import java.io.FileOutputStream import javax.inject.Inject import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking diff --git a/app/src/main/java/com/anytypeio/anytype/ui/payments/PaymentsFragment.kt b/app/src/main/java/com/anytypeio/anytype/ui/payments/PaymentsFragment.kt index 7c6cef9b29..089d9d6958 100644 --- a/app/src/main/java/com/anytypeio/anytype/ui/payments/PaymentsFragment.kt +++ b/app/src/main/java/com/anytypeio/anytype/ui/payments/PaymentsFragment.kt @@ -17,14 +17,15 @@ import com.anytypeio.anytype.core_ui.common.ComposeDialogView import com.anytypeio.anytype.core_utils.ext.subscribe import com.anytypeio.anytype.core_utils.ui.BaseBottomSheetComposeFragment import com.anytypeio.anytype.di.common.componentManager -import com.anytypeio.anytype.screens.CodeScreen -import com.anytypeio.anytype.screens.MainPaymentsScreen -import com.anytypeio.anytype.screens.PaymentWelcomeScreen -import com.anytypeio.anytype.screens.TierScreen +import com.anytypeio.anytype.payments.playbilling.BillingClientLifecycle +import com.anytypeio.anytype.payments.screens.CodeScreen +import com.anytypeio.anytype.payments.screens.MainPaymentsScreen +import com.anytypeio.anytype.payments.screens.PaymentWelcomeScreen +import com.anytypeio.anytype.payments.screens.TierScreen import com.anytypeio.anytype.ui.settings.typography -import com.anytypeio.anytype.viewmodel.PaymentsNavigation -import com.anytypeio.anytype.viewmodel.PaymentsViewModel -import com.anytypeio.anytype.viewmodel.PaymentsViewModelFactory +import com.anytypeio.anytype.payments.viewmodel.PaymentsNavigation +import com.anytypeio.anytype.payments.viewmodel.PaymentsViewModel +import com.anytypeio.anytype.payments.viewmodel.PaymentsViewModelFactory import com.google.accompanist.navigation.material.BottomSheetNavigator import com.google.accompanist.navigation.material.ExperimentalMaterialNavigationApi import com.google.accompanist.navigation.material.ModalBottomSheetLayout @@ -39,6 +40,14 @@ class PaymentsFragment : BaseBottomSheetComposeFragment() { private val vm by viewModels { factory } private lateinit var navController: NavHostController + @Inject + lateinit var billingClientLifecycle: BillingClientLifecycle + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + lifecycle.addObserver(billingClientLifecycle) + } + @OptIn(ExperimentalMaterialNavigationApi::class) override fun onCreateView( inflater: LayoutInflater, @@ -71,6 +80,12 @@ class PaymentsFragment : BaseBottomSheetComposeFragment() { else -> {} } } + jobs += subscribe(vm.launchBillingCommand) { event -> + billingClientLifecycle.launchBillingFlow( + activity = requireActivity(), + params = event + ) + } } @OptIn(ExperimentalMaterialNavigationApi::class) diff --git a/app/src/main/java/com/anytypeio/anytype/ui/settings/ProfileSettingsFragment.kt b/app/src/main/java/com/anytypeio/anytype/ui/settings/ProfileSettingsFragment.kt index d420262ba6..8755913b93 100644 --- a/app/src/main/java/com/anytypeio/anytype/ui/settings/ProfileSettingsFragment.kt +++ b/app/src/main/java/com/anytypeio/anytype/ui/settings/ProfileSettingsFragment.kt @@ -34,8 +34,8 @@ import com.anytypeio.anytype.ui.auth.account.DeleteAccountWarning import com.anytypeio.anytype.ui.profile.KeychainPhraseDialog import com.anytypeio.anytype.ui_settings.account.ProfileSettingsScreen import com.anytypeio.anytype.ui_settings.account.ProfileSettingsViewModel -import com.anytypeio.anytype.viewmodel.PaymentsViewModel -import com.anytypeio.anytype.viewmodel.PaymentsViewModelFactory +import com.anytypeio.anytype.payments.viewmodel.PaymentsViewModel +import com.anytypeio.anytype.payments.viewmodel.PaymentsViewModelFactory import javax.inject.Inject import timber.log.Timber diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 62956d9af8..c89b7075c1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -168,6 +168,7 @@ sentryTimber = { module = "io.sentry:sentry-android-timber", version.ref = "sent navigationCompose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationComposeVersion" } appUpdater = { module = "com.github.PLPsiSoft:AndroidAppUpdater", version = "9913ce80da7871c84af24b9adc2bf2414ca294f0" } composeQrCode = { module = "com.lightspark:compose-qr-code", version.ref = "composeQrCodeVersion" } +playBilling = { module = "com.android.billingclient:billing", version = "6.2.0" } [bundles] diff --git a/payments/build.gradle b/payments/build.gradle index 1fcfef0e5d..a8fcdfa170 100644 --- a/payments/build.gradle +++ b/payments/build.gradle @@ -4,9 +4,6 @@ plugins { } android { - - def config = rootProject.extensions.getByName("ext") - buildFeatures { compose true } @@ -14,7 +11,7 @@ android { composeOptions { kotlinCompilerExtensionVersion libs.versions.composeKotlinCompilerVersion.get() } - namespace 'com.anytypeio.anytype.peyments' + namespace 'com.anytypeio.anytype.payments' } dependencies { @@ -27,6 +24,8 @@ dependencies { implementation project(':presentation') implementation project(':library-emojifier') + implementation libs.playBilling + compileOnly libs.javaxInject implementation libs.lifecycleViewModel diff --git a/payments/src/main/java/com/anytypeio/anytype/payments/constants/BillingConstants.kt b/payments/src/main/java/com/anytypeio/anytype/payments/constants/BillingConstants.kt new file mode 100644 index 0000000000..32f01977e3 --- /dev/null +++ b/payments/src/main/java/com/anytypeio/anytype/payments/constants/BillingConstants.kt @@ -0,0 +1,9 @@ +package com.anytypeio.anytype.payments.constants + +object BillingConstants { + + //Tiers IDs + const val SUBSCRIPTION_BUILDER = "builder_subscription" + + val suscriptionTiers = listOf(SUBSCRIPTION_BUILDER) +} \ No newline at end of file diff --git a/payments/src/main/java/com/anytypeio/anytype/models/Tier.kt b/payments/src/main/java/com/anytypeio/anytype/payments/models/Tier.kt similarity index 93% rename from payments/src/main/java/com/anytypeio/anytype/models/Tier.kt rename to payments/src/main/java/com/anytypeio/anytype/payments/models/Tier.kt index 763994ad82..fb8489a7e4 100644 --- a/payments/src/main/java/com/anytypeio/anytype/models/Tier.kt +++ b/payments/src/main/java/com/anytypeio/anytype/payments/models/Tier.kt @@ -1,6 +1,6 @@ -package com.anytypeio.anytype.models +package com.anytypeio.anytype.payments.models -import com.anytypeio.anytype.viewmodel.TierId +import com.anytypeio.anytype.payments.viewmodel.TierId sealed class Tier { abstract val id: TierId diff --git a/payments/src/main/java/com/anytypeio/anytype/payments/playbilling/BillingClientLifecycle.kt b/payments/src/main/java/com/anytypeio/anytype/payments/playbilling/BillingClientLifecycle.kt new file mode 100644 index 0000000000..03ebd8986a --- /dev/null +++ b/payments/src/main/java/com/anytypeio/anytype/payments/playbilling/BillingClientLifecycle.kt @@ -0,0 +1,334 @@ +package com.anytypeio.anytype.payments.playbilling + +import android.app.Activity +import android.content.Context +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.MutableLiveData +import com.android.billingclient.api.BillingClient +import com.android.billingclient.api.BillingClientStateListener +import com.android.billingclient.api.BillingFlowParams +import com.android.billingclient.api.BillingResult +import com.android.billingclient.api.ProductDetails +import com.android.billingclient.api.ProductDetailsResponseListener +import com.android.billingclient.api.Purchase +import com.android.billingclient.api.PurchasesResponseListener +import com.android.billingclient.api.PurchasesUpdatedListener +import com.android.billingclient.api.QueryProductDetailsParams +import com.android.billingclient.api.QueryPurchasesParams +import com.anytypeio.anytype.domain.base.AppCoroutineDispatchers +import com.anytypeio.anytype.payments.constants.BillingConstants +import com.anytypeio.anytype.payments.constants.BillingConstants.suscriptionTiers +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import timber.log.Timber + +class BillingClientLifecycle( + private val dispatchers: AppCoroutineDispatchers, + private val applicationContext: Context, + private val scope: CoroutineScope +) : DefaultLifecycleObserver, PurchasesUpdatedListener, BillingClientStateListener, + ProductDetailsResponseListener, PurchasesResponseListener { + + private val _subscriptionPurchases = MutableStateFlow>(emptyList()) + + /** + * Purchases are collectable. This list will be updated when the Billing Library + * detects new or existing purchases. + */ + val subscriptionPurchases = _subscriptionPurchases.asStateFlow() + + /** + * Cached in-app product purchases details. + */ + private var cachedPurchasesList: List? = null + + /** + * ProductDetails for all known products. + */ + val builderSubProductWithProductDetails = MutableLiveData() + + /** + * Instantiate a new BillingClient instance. + */ + private lateinit var billingClient: BillingClient + + override fun onCreate(owner: LifecycleOwner) { + Timber.d("ON_CREATE") + // Create a new BillingClient in onCreate(). + // Since the BillingClient can only be used once, we need to create a new instance + // after ending the previous connection to the Google Play Store in onDestroy(). + billingClient = BillingClient.newBuilder(applicationContext) + .setListener(this) + .enablePendingPurchases() // Not used for subscriptions. + .build() + if (!billingClient.isReady) { + Timber.d("BillingClient: Start connection...") + billingClient.startConnection(this) + } + } + + override fun onDestroy(owner: LifecycleOwner) { + Timber.d("ON_DESTROY") + if (billingClient.isReady) { + Timber.d("BillingClient can only be used once -- closing connection") + // BillingClient can only be used once. + // After calling endConnection(), we must create a new BillingClient. + billingClient.endConnection() + } + } + + override fun onBillingSetupFinished(billingResult: BillingResult) { + val responseCode = billingResult.responseCode + val debugMessage = billingResult.debugMessage + Timber.d("onBillingSetupFinished: $responseCode $debugMessage") + if (responseCode == BillingClient.BillingResponseCode.OK) { + // The billing client is ready. + // You can query product details and purchases here. + querySubscriptionProductDetails() + querySubscriptionPurchases() + } + } + + override fun onBillingServiceDisconnected() { + Timber.d("onBillingServiceDisconnected") + // TODO: Try connecting again with exponential backoff. + // billingClient.startConnection(this) + } + + /** + * In order to make purchases, you need the [ProductDetails] for the item or subscription. + * This is an asynchronous call that will receive a result in [onProductDetailsResponse]. + * + * querySubscriptionProductDetails uses method calls from GPBL 5.0.0. PBL5, released in May 2022, + * is backwards compatible with previous versions. + * To learn more about this you can read: + * https://developer.android.com/google/play/billing/compatibility + */ + private fun querySubscriptionProductDetails() { + Timber.d("querySubscriptionProductDetails") + val params = QueryProductDetailsParams.newBuilder() + + val productList: MutableList = arrayListOf() + for (product in suscriptionTiers) { + productList.add( + QueryProductDetailsParams.Product.newBuilder() + .setProductId(product) + .setProductType(BillingClient.ProductType.SUBS) + .build() + ) + } + + params.setProductList(productList).let { productDetailsParams -> + billingClient.queryProductDetailsAsync(productDetailsParams.build(), this) + } + + } + + /** + * Receives the result from [querySubscriptionProductDetails]. + * + * Store the ProductDetails and post them in the [explorerSubProductWithProductDetails] and + * [builderSubProductWithProductDetails]. This allows other parts of the app to use the + * [ProductDetails] to show product information and make purchases. + * + * onProductDetailsResponse() uses method calls from GPBL 5.0.0. PBL5, released in May 2022, + * is backwards compatible with previous versions. + * To learn more about this you can read: + * https://developer.android.com/google/play/billing/compatibility + */ + override fun onProductDetailsResponse( + billingResult: BillingResult, + productDetailsList: MutableList + ) { + if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { + Timber.d("onProductDetailsResponse: ${productDetailsList.size} product(s)") + processProductDetails(productDetailsList) + } else { + Timber.e("onProductDetailsResponse: ${billingResult.responseCode}") + } + } + + /** + * This method is used to process the product details list returned by the [BillingClient]and + * post the details to the [explorerSubProductWithProductDetails] and + * [builderSubProductWithProductDetails] live data. + * + * @param productDetailsList The list of product details. + * + */ + private fun processProductDetails(productDetailsList: MutableList) { + val expectedProductDetailsCount = suscriptionTiers.size + if (productDetailsList.isEmpty()) { + Timber.e("Expected ${expectedProductDetailsCount}, Found null ProductDetails.") + postProductDetails(emptyList()) + } else { + postProductDetails(productDetailsList) + } + } + + /** + * This method is used to post the product details to the [explorerSubProductWithProductDetails] + * and [builderSubProductWithProductDetails] live data. + * + * @param productDetailsList The list of product details. + * + */ + private fun postProductDetails(productDetailsList: List) { + productDetailsList.forEach { productDetails -> + when (productDetails.productType) { + BillingClient.ProductType.SUBS -> { + when (productDetails.productId) { + BillingConstants.SUBSCRIPTION_BUILDER -> { + Timber.d("Builder Subscription ProductDetails: $productDetails") + builderSubProductWithProductDetails.postValue(productDetails) + } + } + } + } + } + } + + /** + * Query Google Play Billing for existing subscription purchases. + * + * New purchases will be provided to the PurchasesUpdatedListener. + * You still need to check the Google Play Billing API to know when purchase tokens are removed. + */ + fun querySubscriptionPurchases() { + if (!billingClient.isReady) { + Timber.w("querySubscriptionPurchases: BillingClient is not ready") + billingClient.startConnection(this) + } + billingClient.queryPurchasesAsync( + QueryPurchasesParams.newBuilder() + .setProductType(BillingClient.ProductType.SUBS) + .build(), this + ) + } + + /** + * Callback from the billing library when queryPurchasesAsync is called. + */ + override fun onQueryPurchasesResponse( + billingResult: BillingResult, + purchasesList: MutableList + ) { + processPurchases(purchasesList) + } + + /** + * Called by the Billing Library when new purchases are detected. + */ + override fun onPurchasesUpdated( + billingResult: BillingResult, + purchases: MutableList? + ) { + val responseCode = billingResult.responseCode + val debugMessage = billingResult.debugMessage + Timber.d("onPurchasesUpdated: $responseCode $debugMessage") + when (responseCode) { + BillingClient.BillingResponseCode.OK -> { + if (purchases == null) { + Timber.d("onPurchasesUpdated: null purchase list") + processPurchases(null) + } else { + processPurchases(purchases) + } + } + + BillingClient.BillingResponseCode.USER_CANCELED -> { + Timber.i("onPurchasesUpdated: User canceled the purchase") + } + + BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED -> { + Timber.i("onPurchasesUpdated: The user already owns this item") + } + + BillingClient.BillingResponseCode.DEVELOPER_ERROR -> { + Timber.e( + "onPurchasesUpdated: Developer error means that Google Play does " + + "not recognize the configuration." + ) + } + } + } + + /** + * Send purchase to StateFlow, which will trigger network call to verify the subscriptions + * on the sever. + */ + private fun processPurchases(purchasesList: List?) { + Timber.d( "processPurchases: ${purchasesList?.size} purchase(s)") + purchasesList?.let { list -> + if (isUnchangedPurchaseList(list)) { + Timber.d("processPurchases: Purchase list has not changed") + return + } + scope.launch(dispatchers.io) { + val subscriptionPurchaseList = list.filter { purchase -> + purchase.products.any { product -> + product in suscriptionTiers + } + } + _subscriptionPurchases.emit(subscriptionPurchaseList) + } + logAcknowledgementStatus(list) + } + } + + /** + * Check whether the purchases have changed before posting changes. + */ + private fun isUnchangedPurchaseList(purchasesList: List): Boolean { + val isUnchanged = purchasesList == cachedPurchasesList + if (!isUnchanged) { + cachedPurchasesList = purchasesList + } + return isUnchanged + } + + /** + * Log the number of purchases that are acknowledge and not acknowledged. + * + * https://developer.android.com/google/play/billing/billing_library_releases_notes#2_0_acknowledge + * + * When the purchase is first received, it will not be acknowledge. + * This application sends the purchase token to the server for registration. After the + * purchase token is registered to an account, the Android app acknowledges the purchase token. + * The next time the purchase list is updated, it will contain acknowledged purchases. + */ + private fun logAcknowledgementStatus(purchasesList: List) { + var acknowledgedCounter = 0 + var unacknowledgedCounter = 0 + for (purchase in purchasesList) { + if (purchase.isAcknowledged) { + acknowledgedCounter++ + } else { + unacknowledgedCounter++ + } + } + Timber.d( + "logAcknowledgementStatus: acknowledged=$acknowledgedCounter " + + "unacknowledged=$unacknowledgedCounter" + ) + } + + /** + * Launching the billing flow. + * + * Launching the UI to make a purchase requires a reference to the Activity. + */ + fun launchBillingFlow(activity: Activity, params: BillingFlowParams): Int { + if (!billingClient.isReady) { + Timber.e("launchBillingFlow: BillingClient is not ready") + } + val billingResult = billingClient.launchBillingFlow(activity, params) + val responseCode = billingResult.responseCode + val debugMessage = billingResult.debugMessage + Timber.d("launchBillingFlow: BillingResponse $responseCode $debugMessage") + return responseCode + } +} \ No newline at end of file diff --git a/payments/src/main/java/com/anytypeio/anytype/screens/EnterCode.kt b/payments/src/main/java/com/anytypeio/anytype/payments/screens/EnterCode.kt similarity index 97% rename from payments/src/main/java/com/anytypeio/anytype/screens/EnterCode.kt rename to payments/src/main/java/com/anytypeio/anytype/payments/screens/EnterCode.kt index 8b7a6db2ce..34fb5d793a 100644 --- a/payments/src/main/java/com/anytypeio/anytype/screens/EnterCode.kt +++ b/payments/src/main/java/com/anytypeio/anytype/payments/screens/EnterCode.kt @@ -1,4 +1,4 @@ -package com.anytypeio.anytype.screens +package com.anytypeio.anytype.payments.screens import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn @@ -50,9 +50,9 @@ import androidx.compose.ui.unit.dp import com.anytypeio.anytype.core_ui.views.BodyBold import com.anytypeio.anytype.core_ui.views.HeadlineTitle import com.anytypeio.anytype.core_ui.views.PreviewTitle1Regular -import com.anytypeio.anytype.peyments.R -import com.anytypeio.anytype.viewmodel.PaymentsCodeState -import com.anytypeio.anytype.viewmodel.TierId +import com.anytypeio.anytype.payments.R +import com.anytypeio.anytype.payments.viewmodel.PaymentsCodeState +import com.anytypeio.anytype.payments.viewmodel.TierId @OptIn(ExperimentalMaterial3Api::class) @Composable diff --git a/payments/src/main/java/com/anytypeio/anytype/screens/InfoCard.kt b/payments/src/main/java/com/anytypeio/anytype/payments/screens/InfoCard.kt similarity index 98% rename from payments/src/main/java/com/anytypeio/anytype/screens/InfoCard.kt rename to payments/src/main/java/com/anytypeio/anytype/payments/screens/InfoCard.kt index 54e108f826..5802440fbf 100644 --- a/payments/src/main/java/com/anytypeio/anytype/screens/InfoCard.kt +++ b/payments/src/main/java/com/anytypeio/anytype/payments/screens/InfoCard.kt @@ -1,4 +1,4 @@ -package com.anytypeio.anytype.screens +package com.anytypeio.anytype.payments.screens import androidx.compose.foundation.Image import androidx.compose.foundation.background diff --git a/payments/src/main/java/com/anytypeio/anytype/screens/MainPaymensScreen.kt b/payments/src/main/java/com/anytypeio/anytype/payments/screens/MainPaymensScreen.kt similarity index 98% rename from payments/src/main/java/com/anytypeio/anytype/screens/MainPaymensScreen.kt rename to payments/src/main/java/com/anytypeio/anytype/payments/screens/MainPaymensScreen.kt index e42e19b14b..f770c48bde 100644 --- a/payments/src/main/java/com/anytypeio/anytype/screens/MainPaymensScreen.kt +++ b/payments/src/main/java/com/anytypeio/anytype/payments/screens/MainPaymensScreen.kt @@ -1,4 +1,4 @@ -package com.anytypeio.anytype.screens +package com.anytypeio.anytype.payments.screens import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image @@ -53,9 +53,9 @@ import com.anytypeio.anytype.core_ui.views.BodyRegular import com.anytypeio.anytype.core_ui.views.Caption1Regular import com.anytypeio.anytype.core_ui.views.Relations2 import com.anytypeio.anytype.core_ui.views.fontRiccioneRegular -import com.anytypeio.anytype.models.Tier -import com.anytypeio.anytype.viewmodel.PaymentsMainState -import com.anytypeio.anytype.viewmodel.TierId +import com.anytypeio.anytype.payments.models.Tier +import com.anytypeio.anytype.payments.viewmodel.PaymentsMainState +import com.anytypeio.anytype.payments.viewmodel.TierId @Composable fun MainPaymentsScreen(state: PaymentsMainState, tierClicked: (TierId) -> Unit) { diff --git a/payments/src/main/java/com/anytypeio/anytype/screens/MembershipLevel.kt b/payments/src/main/java/com/anytypeio/anytype/payments/screens/MembershipLevel.kt similarity index 98% rename from payments/src/main/java/com/anytypeio/anytype/screens/MembershipLevel.kt rename to payments/src/main/java/com/anytypeio/anytype/payments/screens/MembershipLevel.kt index 902253cc46..ab31fadfa0 100644 --- a/payments/src/main/java/com/anytypeio/anytype/screens/MembershipLevel.kt +++ b/payments/src/main/java/com/anytypeio/anytype/payments/screens/MembershipLevel.kt @@ -1,4 +1,4 @@ -package com.anytypeio.anytype.screens +package com.anytypeio.anytype.payments.screens import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -57,10 +57,10 @@ import com.anytypeio.anytype.core_ui.views.ButtonSize import com.anytypeio.anytype.core_ui.views.HeadlineTitle import com.anytypeio.anytype.core_ui.views.Relations1 import com.anytypeio.anytype.core_ui.views.Relations2 -import com.anytypeio.anytype.models.Tier -import com.anytypeio.anytype.peyments.R -import com.anytypeio.anytype.viewmodel.PaymentsTierState -import com.anytypeio.anytype.viewmodel.TierId +import com.anytypeio.anytype.payments.R +import com.anytypeio.anytype.payments.models.Tier +import com.anytypeio.anytype.payments.viewmodel.PaymentsTierState +import com.anytypeio.anytype.payments.viewmodel.TierId @OptIn(ExperimentalMaterial3Api::class) diff --git a/payments/src/main/java/com/anytypeio/anytype/screens/PaymentWelcomeScreen.kt b/payments/src/main/java/com/anytypeio/anytype/payments/screens/PaymentWelcomeScreen.kt similarity index 94% rename from payments/src/main/java/com/anytypeio/anytype/screens/PaymentWelcomeScreen.kt rename to payments/src/main/java/com/anytypeio/anytype/payments/screens/PaymentWelcomeScreen.kt index 26e0dfd590..54676eb9eb 100644 --- a/payments/src/main/java/com/anytypeio/anytype/screens/PaymentWelcomeScreen.kt +++ b/payments/src/main/java/com/anytypeio/anytype/payments/screens/PaymentWelcomeScreen.kt @@ -1,4 +1,4 @@ -package com.anytypeio.anytype.screens +package com.anytypeio.anytype.payments.screens import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -26,10 +26,10 @@ import com.anytypeio.anytype.core_ui.views.BodyRegular import com.anytypeio.anytype.core_ui.views.ButtonSecondary import com.anytypeio.anytype.core_ui.views.ButtonSize import com.anytypeio.anytype.core_ui.views.HeadlineHeading -import com.anytypeio.anytype.models.Tier -import com.anytypeio.anytype.peyments.R -import com.anytypeio.anytype.viewmodel.PaymentsWelcomeState -import com.anytypeio.anytype.viewmodel.TierId +import com.anytypeio.anytype.payments.R +import com.anytypeio.anytype.payments.models.Tier +import com.anytypeio.anytype.payments.viewmodel.PaymentsWelcomeState +import com.anytypeio.anytype.payments.viewmodel.TierId @OptIn(ExperimentalMaterial3Api::class) @Composable diff --git a/payments/src/main/java/com/anytypeio/anytype/screens/TierView.kt b/payments/src/main/java/com/anytypeio/anytype/payments/screens/TierView.kt similarity index 98% rename from payments/src/main/java/com/anytypeio/anytype/screens/TierView.kt rename to payments/src/main/java/com/anytypeio/anytype/payments/screens/TierView.kt index 9a7403cd1f..55258caf05 100644 --- a/payments/src/main/java/com/anytypeio/anytype/screens/TierView.kt +++ b/payments/src/main/java/com/anytypeio/anytype/payments/screens/TierView.kt @@ -1,4 +1,4 @@ -package com.anytypeio.anytype.screens +package com.anytypeio.anytype.payments.screens import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -37,7 +37,7 @@ import com.anytypeio.anytype.core_ui.views.ButtonSize import com.anytypeio.anytype.core_ui.views.Caption1Regular import com.anytypeio.anytype.core_ui.views.Relations3 import com.anytypeio.anytype.core_ui.views.fontInterSemibold -import com.anytypeio.anytype.models.Tier +import com.anytypeio.anytype.payments.models.Tier @Composable fun TierView( diff --git a/payments/src/main/java/com/anytypeio/anytype/viewmodel/PaymentsMainState.kt b/payments/src/main/java/com/anytypeio/anytype/payments/viewmodel/PaymentsMainState.kt similarity index 93% rename from payments/src/main/java/com/anytypeio/anytype/viewmodel/PaymentsMainState.kt rename to payments/src/main/java/com/anytypeio/anytype/payments/viewmodel/PaymentsMainState.kt index 944c073590..5fe37cd2c3 100644 --- a/payments/src/main/java/com/anytypeio/anytype/viewmodel/PaymentsMainState.kt +++ b/payments/src/main/java/com/anytypeio/anytype/payments/viewmodel/PaymentsMainState.kt @@ -1,6 +1,7 @@ -package com.anytypeio.anytype.viewmodel +package com.anytypeio.anytype.payments.viewmodel + +import com.anytypeio.anytype.payments.models.Tier -import com.anytypeio.anytype.models.Tier sealed class PaymentsMainState { object Loading : PaymentsMainState() diff --git a/payments/src/main/java/com/anytypeio/anytype/payments/viewmodel/PaymentsViewModel.kt b/payments/src/main/java/com/anytypeio/anytype/payments/viewmodel/PaymentsViewModel.kt new file mode 100644 index 0000000000..18e51b5156 --- /dev/null +++ b/payments/src/main/java/com/anytypeio/anytype/payments/viewmodel/PaymentsViewModel.kt @@ -0,0 +1,402 @@ +package com.anytypeio.anytype.payments.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.android.billingclient.api.BillingFlowParams +import com.android.billingclient.api.ProductDetails +import com.android.billingclient.api.Purchase +import com.anytypeio.anytype.analytics.base.Analytics +import com.anytypeio.anytype.domain.auth.interactor.GetAccount +import com.anytypeio.anytype.payments.constants.BillingConstants +import com.anytypeio.anytype.payments.models.Tier +import com.anytypeio.anytype.payments.playbilling.BillingClientLifecycle +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import timber.log.Timber + +class PaymentsViewModel( + private val analytics: Analytics, + private val billingClientLifecycle: BillingClientLifecycle, + private val getAccount: GetAccount +) : ViewModel() { + + val viewState = MutableStateFlow(PaymentsMainState.Loading) + val codeState = MutableStateFlow(PaymentsCodeState.Hidden) + val tierState = MutableStateFlow(PaymentsTierState.Hidden) + val welcomeState = MutableStateFlow(PaymentsWelcomeState.Hidden) + + val command = MutableStateFlow(null) + + private val _tiers = mutableListOf() + + var activeTierName: MutableStateFlow = MutableStateFlow(null) + + /** + * Local billing purchase data. + */ + private val purchases = billingClientLifecycle.subscriptionPurchases + + /** + * ProductDetails for all known Products. + */ + private val builderSubProductWithProductDetails = + billingClientLifecycle.builderSubProductWithProductDetails + + private val _launchBillingCommand = MutableSharedFlow() + val launchBillingCommand = _launchBillingCommand.asSharedFlow() + + + init { + Timber.d("PaymentsViewModel init") + _tiers.addAll(gertTiers()) + setupActiveTierName() + viewState.value = PaymentsMainState.Default(_tiers) + } + + fun onTierClicked(tierId: TierId) { + Timber.d("onTierClicked: tierId:$tierId") + tierState.value = PaymentsTierState.Visible.Initial(tier = _tiers.first { it.id == tierId }) + command.value = PaymentsNavigation.Tier + } + + fun onActionCode(code: String, tierId: TierId) { + Timber.d("onActionCode: tierId:$tierId, code:$code, _tiers:${_tiers}") + viewModelScope.launch { + codeState.value = PaymentsCodeState.Visible.Loading(tierId = tierId) + welcomeState.value = + PaymentsWelcomeState.Initial(tier = _tiers.first { it.id == tierId }) + val updatedTiers = _tiers.map { + val isCurrent = it.id == tierId + when (it) { + is Tier.Builder -> it.copy(isCurrent = isCurrent) + is Tier.CoCreator -> it.copy(isCurrent = isCurrent) + is Tier.Custom -> it.copy(isCurrent = isCurrent) + is Tier.Explorer -> it.copy(isCurrent = isCurrent) + } + } + _tiers.clear() + _tiers.addAll(updatedTiers) + viewState.value = PaymentsMainState.PaymentSuccess(_tiers) + command.value = PaymentsNavigation.Welcome + } + } + + fun onSubmitEmailButtonClicked(tierId: TierId, email: String) { + Timber.d("onSubmitEmailButtonClicked: email:$email") + codeState.value = PaymentsCodeState.Visible.Initial(tierId = tierId) + command.value = PaymentsNavigation.Code + } + + fun onPayButtonClicked(tierId: TierId) { + Timber.d("onPayButtonClicked: tierId:$tierId") + buyBasePlans(product = tierId.value, upDowngrade = false) + } + + fun onDismissTier() { + Timber.d("onDismissTier") + command.value = PaymentsNavigation.Dismiss + } + + fun onDismissCode() { + Timber.d("onDismissCode") + command.value = PaymentsNavigation.Dismiss + } + + fun onDismissWelcome() { + Timber.d("onDismissWelcome") + command.value = PaymentsNavigation.Dismiss + } + + private fun setupActiveTierName() { + activeTierName.value = _tiers.firstOrNull { it.isCurrent }?.prettyName + } + + private fun gertTiers(): List { + return listOf( + Tier.Explorer(id = TierId("explorer_subscription"), isCurrent = false, validUntil = "Forever"), + Tier.Builder(id = TierId("builder_subscription"), isCurrent = false, validUntil = "2022-12-31"), + Tier.CoCreator(id = TierId("cocreator_subscription"), isCurrent = false, validUntil = "2022-12-31"), + Tier.Custom(id = TierId("idCustom"), isCurrent = false, validUntil = "2022-12-31") + ) + } + + //region Google Play Billing + /** + * Use the Google Play Billing Library to make a purchase. + * + * @param tag String representing tags associated with offers and base plans. + * @param product Product being purchased. + * @param upDowngrade Boolean indicating if the purchase is an upgrade or downgrade and + * when converting from one base plan to another. + * + */ + private fun buyBasePlans(tag: String = "", product: String, upDowngrade: Boolean) { + //todo check if the user has already purchased the product + val isProductOnServer = false//serverHasSubscription(subscriptions.value, product) + val isProductOnDevice = deviceHasGooglePlaySubscription(purchases.value, product) + Timber.d( + "Billing", "$product - isProductOnServer: $isProductOnServer," + + " isProductOnDevice: $isProductOnDevice" + ) + + when { + isProductOnDevice && isProductOnServer -> { + Timber.d("User is trying to top up prepaid subscription: $product. ") + } + + isProductOnDevice && !isProductOnServer -> { + Timber.d( + "The Google Play Billing Library APIs indicate that " + + "this Product is already owned, but the purchase token is not " + + "registered with the server." + ) + } + + !isProductOnDevice && isProductOnServer -> { + Timber.w( + "WHOA! The server says that the user already owns " + + "this item: $product. This could be from another Google account. " + + "You should warn the user that they are trying to buy something " + + "from Google Play that they might already have access to from " + + "another purchase, possibly from a different Google account " + + "on another device.\n" + + "You can choose to block this purchase.\n" + + "If you are able to cancel the existing subscription on the server, " + + "you should allow the user to subscribe with Google Play, and then " + + "cancel the subscription after this new subscription is complete. " + + "This will allow the user to seamlessly transition their payment " + + "method from an existing payment method to this Google Play account." + ) + return + } + } + + val builderSubProductDetails = builderSubProductWithProductDetails.value ?: run { + Timber.e( "Could not find Basic product details.") + return + } + + val builderOffers = + builderSubProductDetails.subscriptionOfferDetails?.let { offerDetailsList -> + retrieveEligibleOffers( + offerDetails = offerDetailsList, + tag = tag + ) + } + + val offerToken: String + + when (product) { + BillingConstants.SUBSCRIPTION_BUILDER -> { + offerToken = builderOffers?.let { leastPricedOfferToken(it) }.toString() + launchFlow(upDowngrade, offerToken, builderSubProductDetails) + } + } + } + + /** + * Calculates the lowest priced offer amongst all eligible offers. + * In this implementation the lowest price of all offers' pricing phases is returned. + * It's possible the logic can be implemented differently. + * For example, the lowest average price in terms of month could be returned instead. + * + * @param offerDetails List of of eligible offers and base plans. + * + * @return the offer id token of the lowest priced offer. + * + */ + private fun leastPricedOfferToken( + offerDetails: List + ): String { + var offerToken = String() + var leastPricedOffer: ProductDetails.SubscriptionOfferDetails + var lowestPrice = Int.MAX_VALUE + + if (offerDetails.isNotEmpty()) { + for (offer in offerDetails) { + for (price in offer.pricingPhases.pricingPhaseList) { + if (price.priceAmountMicros < lowestPrice) { + lowestPrice = price.priceAmountMicros.toInt() + leastPricedOffer = offer + offerToken = leastPricedOffer.offerToken + } + } + } + } + return offerToken + + TODO("Replace this with least average priced offer implementation") + } + + /** + * Retrieves all eligible base plans and offers using tags from ProductDetails. + * + * @param offerDetails offerDetails from a ProductDetails returned by the library. + * @param tag string representing tags associated with offers and base plans. + * + * @return the eligible offers and base plans in a list. + * + */ + private fun retrieveEligibleOffers( + offerDetails: MutableList, tag: String + ): + List { + val eligibleOffers = emptyList().toMutableList() + offerDetails.forEach { offerDetail -> + if (offerDetail.offerTags.contains(tag)) { + eligibleOffers.add(offerDetail) + } + } + return eligibleOffers + } + + /** + * BillingFlowParams Builder for normal purchases. + * + * @param productDetails ProductDetails object returned by the library. + * @param offerToken the least priced offer's offer id token returned by + * [leastPricedOfferToken]. + * + * @return [BillingFlowParams] builder. + */ + private suspend fun billingFlowParamsBuilder( + productDetails: ProductDetails, + offerToken: String + ): + BillingFlowParams { + val anyId = getAccount.async(Unit).getOrNull() + return BillingFlowParams.newBuilder() + .setObfuscatedAccountId(anyId?.id.orEmpty()) + .setObfuscatedProfileId("testobfuscatedProfileId") + .setProductDetailsParamsList( + listOf( + BillingFlowParams.ProductDetailsParams.newBuilder() + .setProductDetails(productDetails) + .setOfferToken(offerToken) + .build() + ) + ).build() + } + + /** + * BillingFlowParams Builder for upgrades and downgrades. + * + * @param productDetails ProductDetails object returned by the library. + * @param offerToken the least priced offer's offer id token returned by + * [leastPricedOfferToken]. + * @param oldToken the purchase token of the subscription purchase being upgraded or downgraded. + * + * @return [BillingFlowParams] builder. + */ + private fun upDowngradeBillingFlowParamsBuilder( + productDetails: ProductDetails, offerToken: String, oldToken: String + ): BillingFlowParams { + return BillingFlowParams.newBuilder() + .setProductDetailsParamsList( + listOf( + BillingFlowParams.ProductDetailsParams.newBuilder() + .setProductDetails(productDetails) + .setOfferToken(offerToken) + .build() + ) + ).setSubscriptionUpdateParams( + BillingFlowParams.SubscriptionUpdateParams.newBuilder() + .setOldPurchaseToken(oldToken) + .setSubscriptionReplacementMode( + BillingFlowParams.SubscriptionUpdateParams.ReplacementMode.CHARGE_FULL_PRICE + ).build() + ).build() + } + + /** + * Launches the billing flow for a subscription product purchase. + * A user can only have one subscription purchase on the device at a time. If the user + * has more than one subscription purchase on the device, the app should not allow the + * user to purchase another subscription. + * + * @param upDowngrade Boolean indicating if the purchase is an upgrade or downgrade and + * when converting from one base plan to another. + * @param offerToken String representing the offer token of the lowest priced offer. + * @param productDetails ProductDetails of the product being purchased. + * + */ + private fun launchFlow( + upDowngrade: Boolean, + offerToken: String, + productDetails: ProductDetails + ) { + + val currentSubscriptionPurchaseCount = purchases.value.count { + it.products.contains(BillingConstants.SUBSCRIPTION_BUILDER) + } + + if (currentSubscriptionPurchaseCount > EXPECTED_SUBSCRIPTION_PURCHASE_LIST_SIZE) { + Timber.e("There are more than one subscription purchases on the device.") + return + + TODO( + "Handle this case better, such as by showing a dialog to the user or by " + + "programmatically getting the correct purchase token." + ) + } + + val oldToken = purchases.value.filter { + it.products.contains(BillingConstants.SUBSCRIPTION_BUILDER) + }.firstOrNull { it.purchaseToken.isNotEmpty() }?.purchaseToken ?: "" + + + viewModelScope.launch { + val billingParams: BillingFlowParams = if (upDowngrade) { + upDowngradeBillingFlowParamsBuilder( + productDetails = productDetails, + offerToken = offerToken, + oldToken = oldToken + ) + } else { + billingFlowParamsBuilder( + productDetails = productDetails, + offerToken = offerToken + ) + } + _launchBillingCommand.emit(billingParams) + } + } + + /** + * This will return true if the Google Play Billing APIs have a record for the subscription. + * This will not always match the server's record of the subscription for this app user. + * + * Example: App user buys the subscription on a different device with a different Google + * account. The server will show that this app user has the subscription, even if the + * Google account on this device has not purchased the subscription. + * In this example, the method will return false. + * + * Example: The app user changes by signing out and signing into the app with a different + * email address. The server will show that this app user does not have the subscription, + * even if the Google account on this device has purchased the subscription. + * In this example, the method will return true. + */ + private fun deviceHasGooglePlaySubscription(purchases: List?, product: String) = + purchaseForProduct(purchases, product) != null + + /** + * Return purchase for the provided Product, if it exists. + */ + private fun purchaseForProduct(purchases: List?, product: String): Purchase? { + purchases?.let { + for (purchase in it) { + if (purchase.products[0] == product) { + return purchase + } + } + } + return null + } + + + companion object { + const val EXPECTED_SUBSCRIPTION_PURCHASE_LIST_SIZE = 1 + } +} \ No newline at end of file diff --git a/payments/src/main/java/com/anytypeio/anytype/viewmodel/PaymentsViewModelFactory.kt b/payments/src/main/java/com/anytypeio/anytype/payments/viewmodel/PaymentsViewModelFactory.kt similarity index 55% rename from payments/src/main/java/com/anytypeio/anytype/viewmodel/PaymentsViewModelFactory.kt rename to payments/src/main/java/com/anytypeio/anytype/payments/viewmodel/PaymentsViewModelFactory.kt index 12a36be613..6e09b2c6e0 100644 --- a/payments/src/main/java/com/anytypeio/anytype/viewmodel/PaymentsViewModelFactory.kt +++ b/payments/src/main/java/com/anytypeio/anytype/payments/viewmodel/PaymentsViewModelFactory.kt @@ -1,17 +1,23 @@ -package com.anytypeio.anytype.viewmodel +package com.anytypeio.anytype.payments.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import com.anytypeio.anytype.analytics.base.Analytics +import com.anytypeio.anytype.domain.auth.interactor.GetAccount +import com.anytypeio.anytype.payments.playbilling.BillingClientLifecycle import javax.inject.Inject class PaymentsViewModelFactory @Inject constructor( private val analytics: Analytics, + private val billingClientLifecycle: BillingClientLifecycle, + private val getAccount: GetAccount, ) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T { return PaymentsViewModel( analytics = analytics, + billingClientLifecycle = billingClientLifecycle, + getAccount = getAccount ) as T } } \ No newline at end of file diff --git a/payments/src/main/java/com/anytypeio/anytype/viewmodel/PaymentsViewModel.kt b/payments/src/main/java/com/anytypeio/anytype/viewmodel/PaymentsViewModel.kt deleted file mode 100644 index b0a9eaf324..0000000000 --- a/payments/src/main/java/com/anytypeio/anytype/viewmodel/PaymentsViewModel.kt +++ /dev/null @@ -1,103 +0,0 @@ -package com.anytypeio.anytype.viewmodel - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.anytypeio.anytype.analytics.base.Analytics -import com.anytypeio.anytype.models.Tier -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.launch -import timber.log.Timber - -class PaymentsViewModel( - private val analytics: Analytics, -) : ViewModel() { - - val viewState = MutableStateFlow(PaymentsMainState.Loading) - val codeState = MutableStateFlow(PaymentsCodeState.Hidden) - val tierState = MutableStateFlow(PaymentsTierState.Hidden) - val welcomeState = MutableStateFlow(PaymentsWelcomeState.Hidden) - - val command = MutableStateFlow(null) - - private val _tiers = mutableListOf() - - var activeTierName: MutableStateFlow = MutableStateFlow(null) - - init { - Timber.d("PaymentsViewModel init") - - _tiers.addAll(gertTiers()) - setupActiveTierName() - viewState.value = PaymentsMainState.Default(_tiers) - } - - fun onTierClicked(tierId: TierId) { - Timber.d("onTierClicked: tierId:$tierId") - tierState.value = PaymentsTierState.Visible.Initial(tier = _tiers.first { it.id == tierId }) - command.value = PaymentsNavigation.Tier - } - - fun onActionCode(code: String, tierId: TierId) { - Timber.d("onActionCode: tierId:$tierId, code:$code, _tiers:${_tiers}") - viewModelScope.launch { - codeState.value = PaymentsCodeState.Visible.Loading(tierId = tierId) - delay(2000) - welcomeState.value = - PaymentsWelcomeState.Initial(tier = _tiers.first { it.id == tierId }) - val updatedTiers = _tiers.map { - val isCurrent = it.id == tierId - when (it) { - is Tier.Builder -> it.copy(isCurrent = isCurrent) - is Tier.CoCreator -> it.copy(isCurrent = isCurrent) - is Tier.Custom -> it.copy(isCurrent = isCurrent) - is Tier.Explorer -> it.copy(isCurrent = isCurrent) - } - } - _tiers.clear() - _tiers.addAll(updatedTiers) - viewState.value = PaymentsMainState.PaymentSuccess(_tiers) - command.value = PaymentsNavigation.Welcome - } - } - - fun onSubmitEmailButtonClicked(tierId: TierId, email: String) { - Timber.d("onSubmitEmailButtonClicked: email:$email") - codeState.value = PaymentsCodeState.Visible.Initial(tierId = tierId) - command.value = PaymentsNavigation.Code - } - - fun onPayButtonClicked(tierId: TierId) { - Timber.d("onPayButtonClicked: tierId:$tierId") - codeState.value = PaymentsCodeState.Visible.Initial(tierId = tierId) - command.value = PaymentsNavigation.Code - } - - fun onDismissTier() { - Timber.d("onDismissTier") - command.value = PaymentsNavigation.Dismiss - } - - fun onDismissCode() { - Timber.d("onDismissCode") - command.value = PaymentsNavigation.Dismiss - } - - fun onDismissWelcome() { - Timber.d("onDismissWelcome") - command.value = PaymentsNavigation.Dismiss - } - - private fun setupActiveTierName() { - activeTierName.value = _tiers.firstOrNull { it.isCurrent }?.prettyName - } - - private fun gertTiers(): List { - return listOf( - Tier.Explorer(id = TierId("idExplorer"), isCurrent = false, validUntil = "Forever"), - Tier.Builder(id = TierId("idBuilder"), isCurrent = false, validUntil = "2022-12-31"), - Tier.CoCreator(id = TierId("idCoCreator"), isCurrent = false, validUntil = "2022-12-31"), - Tier.Custom(id = TierId("idCustom"), isCurrent = false, validUntil = "2022-12-31") - ) - } -} \ No newline at end of file