1
0
Fork 0
mirror of https://github.com/anyproto/anytype-kotlin.git synced 2025-06-08 05:47:05 +09:00

DROID-2239 Membership | Feature | Introduce membership and payments (#1189)

This commit is contained in:
Konstantin Ivanov 2024-05-30 15:05:07 +02:00 committed by GitHub
parent a15a3f22f1
commit 6493728687
Signed by: github
GPG key ID: B5690EEEBB952194
72 changed files with 7540 additions and 2118 deletions

View file

@ -12,6 +12,10 @@ android {
kotlinCompilerExtensionVersion libs.versions.composeKotlinCompilerVersion.get()
}
namespace 'com.anytypeio.anytype.payments'
testOptions {
unitTests.returnDefaultValues = true
}
}
dependencies {
@ -45,4 +49,15 @@ dependencies {
testImplementation libs.junit
testImplementation libs.kotlinTest
testImplementation libs.mockitoKotlin
testImplementation libs.coroutineTesting
testImplementation libs.liveDataTesting
testImplementation libs.archCoreTesting
testImplementation libs.androidXTestCore
testImplementation libs.robolectric
testImplementation libs.timberJUnit
testImplementation libs.turbine
testImplementation project(":test:utils")
testImplementation project(":test:core-models-stub")
}

View file

@ -1,9 +0,0 @@
package com.anytypeio.anytype.payments.constants
object BillingConstants {
//Tiers IDs
const val SUBSCRIPTION_BUILDER = "builder_subscription"
val suscriptionTiers = listOf(SUBSCRIPTION_BUILDER)
}

View file

@ -0,0 +1,21 @@
package com.anytypeio.anytype.payments.constants
object MembershipConstants {
const val NONE_ID = 0
const val EXPLORER_ID = 1
const val BUILDER_ID = 4
const val CO_CREATOR_ID = 5
const val MEMBERSHIP_LEVEL_DETAILS = "https://anytype.io/pricing"
const val PRIVACY_POLICY = "https://anytype.io/app_privacy"
const val TERMS_OF_SERVICE = "https://anytype.io/terms_of_use"
const val MEMBERSHIP_CONTACT_EMAIL = "membership-upgrade@anytype.io"
val ACTIVE_TIERS_WITH_BANNERS = listOf(NONE_ID, EXPLORER_ID)
const val ERROR_PRODUCT_NOT_FOUND = "Product not found"
const val ERROR_PRODUCT_PRICE = "Price of the product is not available"
const val MEMBERSHIP_NAME_MIN_LENGTH = 7
}

View file

@ -0,0 +1,409 @@
package com.anytypeio.anytype.payments.mapping
import com.android.billingclient.api.ProductDetails
import com.anytypeio.anytype.core_models.membership.Membership
import com.anytypeio.anytype.core_models.membership.MembershipPaymentMethod
import com.anytypeio.anytype.core_models.membership.MembershipPeriodType
import com.anytypeio.anytype.core_models.membership.MembershipTierData
import com.anytypeio.anytype.core_ui.R
import com.anytypeio.anytype.payments.constants.MembershipConstants
import com.anytypeio.anytype.payments.constants.MembershipConstants.ACTIVE_TIERS_WITH_BANNERS
import com.anytypeio.anytype.payments.constants.MembershipConstants.MEMBERSHIP_CONTACT_EMAIL
import com.anytypeio.anytype.payments.constants.MembershipConstants.MEMBERSHIP_LEVEL_DETAILS
import com.anytypeio.anytype.payments.constants.MembershipConstants.PRIVACY_POLICY
import com.anytypeio.anytype.payments.constants.MembershipConstants.TERMS_OF_SERVICE
import com.anytypeio.anytype.payments.models.BillingPriceInfo
import com.anytypeio.anytype.payments.models.Tier
import com.anytypeio.anytype.payments.models.TierAnyName
import com.anytypeio.anytype.payments.models.TierButton
import com.anytypeio.anytype.payments.models.TierConditionInfo
import com.anytypeio.anytype.payments.models.TierEmail
import com.anytypeio.anytype.payments.models.TierPeriod
import com.anytypeio.anytype.payments.models.TierPreview
import com.anytypeio.anytype.payments.playbilling.BillingClientState
import com.anytypeio.anytype.payments.playbilling.BillingPurchaseState
import com.anytypeio.anytype.payments.viewmodel.MembershipMainState
import com.anytypeio.anytype.presentation.membership.models.MembershipStatus
import com.anytypeio.anytype.presentation.membership.models.TierId
fun MembershipStatus.toMainView(
billingClientState: BillingClientState,
billingPurchaseState: BillingPurchaseState
): MembershipMainState {
val (showBanner, subtitle) = if (activeTier.value in ACTIVE_TIERS_WITH_BANNERS) {
true to R.string.payments_subheader
} else {
false to null
}
return MembershipMainState.Default(
title = R.string.payments_header,
subtitle = subtitle,
tiersPreview = tiers.map {
it.toPreviewView(
membershipStatus = this,
billingClientState = billingClientState,
billingPurchaseState = billingPurchaseState
)
},
membershipLevelDetails = MEMBERSHIP_LEVEL_DETAILS,
privacyPolicy = PRIVACY_POLICY,
termsOfService = TERMS_OF_SERVICE,
contactEmail = MEMBERSHIP_CONTACT_EMAIL,
showBanner = showBanner,
tiers = tiers.map {
it.toView(
membershipStatus = this,
billingClientState = billingClientState,
billingPurchaseState = billingPurchaseState
)
}
)
}
private fun MembershipStatus.isTierActive(tierId: Int): Boolean {
return when (this.status) {
Membership.Status.STATUS_ACTIVE -> activeTier.value == tierId
else -> false
}
}
private fun MembershipTierData.isActiveTierPurchasedOnAndroid(activePaymentMethod: MembershipPaymentMethod): Boolean {
val androidProductId = this.androidProductId
return when (activePaymentMethod) {
MembershipPaymentMethod.METHOD_INAPP_GOOGLE -> return !androidProductId.isNullOrBlank()
else -> false
}
}
fun MembershipTierData.toView(
membershipStatus: MembershipStatus,
billingClientState: BillingClientState,
billingPurchaseState: BillingPurchaseState
): Tier {
val tierId = TierId(id)
val isActive = membershipStatus.isTierActive(id)
val emailState = getTierEmail(isActive, membershipStatus.userEmail)
val tierName = name
val tierDescription = description
val result = Tier(
id = tierId,
title = tierName,
subtitle = tierDescription,
conditionInfo = getConditionInfo(
isActive = isActive,
billingClientState = billingClientState,
membershipStatus = membershipStatus,
billingPurchaseState = billingPurchaseState
),
isActive = isActive,
features = features,
membershipAnyName = getAnyName(
isActive = isActive,
billingClientState = billingClientState,
membershipStatus = membershipStatus,
billingPurchaseState = billingPurchaseState
),
buttonState = toButtonView(
isActive = isActive,
billingPurchaseState = billingPurchaseState,
membershipStatus = membershipStatus
),
email = emailState,
color = colorStr,
urlInfo = androidManageUrl,
stripeManageUrl = stripeManageUrl,
iosManageUrl = iosManageUrl,
androidManageUrl = androidManageUrl,
androidProductId = androidProductId,
paymentMethod = membershipStatus.paymentMethod
)
return result
}
fun MembershipTierData.toPreviewView(
membershipStatus: MembershipStatus,
billingClientState: BillingClientState,
billingPurchaseState: BillingPurchaseState
): TierPreview {
val tierId = TierId(id)
val isActive = membershipStatus.isTierActive(id)
val tierName = name
val tierDescription = description
return TierPreview(
id = tierId,
title = tierName,
subtitle = tierDescription,
conditionInfo = getConditionInfo(
isActive = isActive,
billingClientState = billingClientState,
membershipStatus = membershipStatus,
billingPurchaseState = billingPurchaseState
),
isActive = isActive,
color = colorStr
)
}
private fun MembershipTierData.toButtonView(
isActive: Boolean,
billingPurchaseState: BillingPurchaseState,
membershipStatus: MembershipStatus
): TierButton {
val androidProductId = this.androidProductId
val androidInfoUrl = this.androidManageUrl
return if (isActive) {
val wasPurchasedOnAndroid = isActiveTierPurchasedOnAndroid(membershipStatus.paymentMethod)
if (!wasPurchasedOnAndroid) {
if (id == MembershipConstants.EXPLORER_ID) {
if (membershipStatus.userEmail.isBlank()) {
TierButton.Submit.Enabled
} else {
TierButton.ChangeEmail
}
} else {
when (membershipStatus.paymentMethod) {
MembershipPaymentMethod.METHOD_NONE,
MembershipPaymentMethod.METHOD_CRYPTO -> {
TierButton.Hidden
}
MembershipPaymentMethod.METHOD_STRIPE -> {
if (stripeManageUrl.isNullOrBlank()) {
TierButton.Hidden
} else {
TierButton.Manage.External.Enabled(stripeManageUrl)
}
}
MembershipPaymentMethod.METHOD_INAPP_APPLE -> {
if (iosManageUrl.isNullOrBlank()) {
TierButton.Hidden
} else {
TierButton.Manage.External.Enabled(iosManageUrl)
}
}
MembershipPaymentMethod.METHOD_INAPP_GOOGLE -> TierButton.Manage.External.Enabled(
androidInfoUrl
)
}
}
} else {
if (billingPurchaseState is BillingPurchaseState.HasPurchases) {
TierButton.Manage.Android.Enabled(androidProductId)
} else {
TierButton.Manage.Android.Disabled
}
}
} else {
if (androidProductId == null) {
if (androidInfoUrl == null) {
TierButton.Info.Disabled
} else {
TierButton.Info.Enabled(androidInfoUrl)
}
} else {
when (billingPurchaseState) {
is BillingPurchaseState.HasPurchases -> {
//Tier has purchase, but it's not active yet, still waiting for a event from the mw
TierButton.Hidden
}
BillingPurchaseState.Loading -> {
TierButton.Hidden
}
BillingPurchaseState.NoPurchases -> {
if (membershipStatus.anyName.isBlank()) {
TierButton.Pay.Disabled
} else {
TierButton.Pay.Enabled
}
}
}
}
}
}
private fun MembershipTierData.getAnyName(
isActive: Boolean,
billingClientState: BillingClientState,
membershipStatus: MembershipStatus,
billingPurchaseState: BillingPurchaseState
): TierAnyName {
if (isActive) {
return TierAnyName.Hidden
} else {
if (androidProductId == null) {
return TierAnyName.Hidden
} else {
if (membershipStatus.status == Membership.Status.STATUS_PENDING ||
membershipStatus.status == Membership.Status.STATUS_PENDING_FINALIZATION
) {
return TierAnyName.Hidden
}
if (billingPurchaseState is BillingPurchaseState.Loading
|| billingPurchaseState is BillingPurchaseState.HasPurchases
) {
return TierAnyName.Hidden
}
if (billingClientState is BillingClientState.Connected) {
val product =
billingClientState.productDetails.find { it.productId == androidProductId }
if (product == null) {
return TierAnyName.Visible.Disabled
} else {
if (product.billingPriceInfo() == null) {
return TierAnyName.Visible.Disabled
} else {
if (membershipStatus.anyName.isBlank()) {
return TierAnyName.Visible.Enter
} else {
return TierAnyName.Visible.Purchased(membershipStatus.anyName)
}
}
}
} else {
return TierAnyName.Visible.Disabled
}
}
}
}
private fun MembershipTierData.getConditionInfo(
isActive: Boolean,
billingClientState: BillingClientState,
membershipStatus: MembershipStatus,
billingPurchaseState: BillingPurchaseState
): TierConditionInfo {
return if (isActive) {
createConditionInfoForCurrentTier(
membershipValidUntil = membershipStatus.dateEnds,
paymentMethod = membershipStatus.paymentMethod
)
} else {
if (androidProductId == null) {
createConditionInfoForNonBillingTier()
} else {
createConditionInfoForBillingTier(
billingClientState,
membershipStatus,
billingPurchaseState
)
}
}
}
private fun MembershipTierData.createConditionInfoForCurrentTier(
membershipValidUntil: Long,
paymentMethod: MembershipPaymentMethod
): TierConditionInfo {
return TierConditionInfo.Visible.Valid(
dateEnds = membershipValidUntil,
payedBy = paymentMethod,
period = convertToTierViewPeriod(this)
)
}
private fun MembershipTierData.createConditionInfoForNonBillingTier(): TierConditionInfo {
return if (priceStripeUsdCents == 0) {
TierConditionInfo.Visible.Free(
period = convertToTierViewPeriod(this)
)
} else {
TierConditionInfo.Visible.Price(
price = formatPriceInCents(priceStripeUsdCents),
period = convertToTierViewPeriod(this)
)
}
}
private fun formatPriceInCents(priceInCents: Int): String {
val dollars = priceInCents / 100
return if (priceInCents % 100 == 0) {
"$$dollars"
} else {
"$%.2f".format(dollars + (priceInCents % 100) / 100.0)
}
}
private fun MembershipTierData.createConditionInfoForBillingTier(
billingClientState: BillingClientState,
membershipStatus: MembershipStatus,
billingPurchaseState: BillingPurchaseState
): TierConditionInfo {
if (
membershipStatus.status == Membership.Status.STATUS_PENDING ||
membershipStatus.status == Membership.Status.STATUS_PENDING_FINALIZATION
) {
return TierConditionInfo.Visible.Pending
}
if (
billingPurchaseState is BillingPurchaseState.Loading
|| billingPurchaseState is BillingPurchaseState.HasPurchases
) {
return TierConditionInfo.Visible.Pending
}
return when (billingClientState) {
BillingClientState.Loading -> {
TierConditionInfo.Visible.LoadingBillingClient
}
is BillingClientState.Error -> {
TierConditionInfo.Visible.Error(billingClientState.message)
}
is BillingClientState.Connected -> {
val product =
billingClientState.productDetails.find { it.productId == androidProductId }
if (product == null) {
TierConditionInfo.Visible.Error(MembershipConstants.ERROR_PRODUCT_NOT_FOUND)
} else {
val billingPriceInfo = product.billingPriceInfo()
if (billingPriceInfo == null) {
TierConditionInfo.Visible.Error(MembershipConstants.ERROR_PRODUCT_PRICE)
} else {
TierConditionInfo.Visible.PriceBilling(
price = billingPriceInfo
)
}
}
}
}
}
private fun ProductDetails.billingPriceInfo(): BillingPriceInfo? {
val pricingPhase = subscriptionOfferDetails?.get(0)?.pricingPhases?.pricingPhaseList?.get(0)
val formattedPrice = pricingPhase?.formattedPrice
val periodType = pricingPhase?.billingPeriod?.parsePeriod()
if (formattedPrice == null || periodType == null || formattedPrice.isBlank()) {
return null
}
return BillingPriceInfo(
formattedPrice = formattedPrice,
period = periodType
)
}
private fun convertToTierViewPeriod(tier: MembershipTierData): TierPeriod {
return when (tier.periodType) {
MembershipPeriodType.PERIOD_TYPE_UNKNOWN -> TierPeriod.Unknown
MembershipPeriodType.PERIOD_TYPE_UNLIMITED -> TierPeriod.Unlimited
MembershipPeriodType.PERIOD_TYPE_DAYS -> TierPeriod.Day(tier.periodValue)
MembershipPeriodType.PERIOD_TYPE_WEEKS -> TierPeriod.Week(tier.periodValue)
MembershipPeriodType.PERIOD_TYPE_MONTHS -> TierPeriod.Month(tier.periodValue)
MembershipPeriodType.PERIOD_TYPE_YEARS -> TierPeriod.Year(tier.periodValue)
}
}
private fun MembershipTierData.getTierEmail(isActive: Boolean, membershipEmail: String): TierEmail {
if (isActive) {
if (id == MembershipConstants.EXPLORER_ID && membershipEmail.isBlank()) {
return TierEmail.Visible.Enter
}
}
return TierEmail.Hidden
}

View file

@ -0,0 +1,50 @@
package com.anytypeio.anytype.payments.mapping
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.pluralStringResource
import com.anytypeio.anytype.core_ui.R
import com.anytypeio.anytype.payments.models.PeriodDescription
import com.anytypeio.anytype.payments.models.PeriodUnit
import com.anytypeio.anytype.payments.models.TierPeriod
import java.time.Period
fun String.parsePeriod(): PeriodDescription? {
return try {
val period = Period.parse(this)
when {
period.years > 0 -> PeriodDescription(period.years, PeriodUnit.YEARS)
period.months > 0 -> PeriodDescription(period.months, PeriodUnit.MONTHS)
period.days > 0 -> PeriodDescription(period.days, PeriodUnit.DAYS)
else -> null
}
} catch (e: Exception) {
null
}
}
@Composable
fun LocalizedPeriodString(desc: PeriodDescription?): String {
desc ?: return ""
val quantityStringId = when (desc.unit) {
PeriodUnit.YEARS -> R.plurals.period_years
PeriodUnit.MONTHS -> R.plurals.period_months
PeriodUnit.DAYS -> R.plurals.period_days
PeriodUnit.WEEKS -> R.plurals.period_weeks
}
return pluralStringResource(
id = quantityStringId,
count = desc.amount,
formatArgs = arrayOf(desc.amount)
)
}
fun TierPeriod.toPeriodDescription(): PeriodDescription {
return when (this) {
is TierPeriod.Unknown -> PeriodDescription(0, PeriodUnit.DAYS)
is TierPeriod.Unlimited -> PeriodDescription(Int.MAX_VALUE, PeriodUnit.DAYS)
is TierPeriod.Year -> PeriodDescription(count, PeriodUnit.YEARS)
is TierPeriod.Month -> PeriodDescription(count, PeriodUnit.MONTHS)
is TierPeriod.Week -> PeriodDescription(count, PeriodUnit.WEEKS)
is TierPeriod.Day -> PeriodDescription(count, PeriodUnit.DAYS)
}
}

View file

@ -0,0 +1,116 @@
package com.anytypeio.anytype.payments.models
import com.anytypeio.anytype.core_models.membership.MembershipErrors
import com.anytypeio.anytype.core_models.membership.MembershipPaymentMethod
import com.anytypeio.anytype.presentation.membership.models.TierId
//This is a data class that represents a tier preview view in the main Membership screen
data class TierPreview(
val id: TierId,
val isActive: Boolean,
val title: String,
val subtitle: String,
val conditionInfo: TierConditionInfo,
val color: String = "red"
)
//This is a data class that represents a tier view when tier is opened
data class Tier(
val id: TierId,
val isActive: Boolean,
val title: String,
val subtitle: String,
val conditionInfo: TierConditionInfo,
val features: List<String>,
val membershipAnyName: TierAnyName,
val buttonState: TierButton,
val email: TierEmail,
val color: String = "red",
val urlInfo: String? = null,
val stripeManageUrl: String?,
val iosManageUrl: String?,
val androidManageUrl: String?,
val androidProductId: String?,
val paymentMethod: MembershipPaymentMethod
)
sealed class TierConditionInfo {
data object Hidden : TierConditionInfo()
sealed class Visible : TierConditionInfo() {
data object LoadingBillingClient : Visible()
data class Valid(val period: TierPeriod, val dateEnds: Long, val payedBy : MembershipPaymentMethod) : Visible()
data class Price(val price: String, val period: TierPeriod) : Visible()
data class PriceBilling(val price: BillingPriceInfo) : Visible()
data class Free(val period: TierPeriod) : Visible()
data class Error(val message: String) : Visible()
data object Pending : Visible()
}
}
sealed class TierPeriod {
data object Unknown : TierPeriod()
data object Unlimited : TierPeriod()
data class Year(val count : Int) : TierPeriod()
data class Month(val count : Int) : TierPeriod()
data class Week(val count : Int) : TierPeriod()
data class Day(val count : Int) : TierPeriod()
}
sealed class TierButton {
data object Hidden : TierButton()
sealed class Submit : TierButton() {
data object Enabled : Submit()
data object Disabled : Submit()
}
data object ChangeEmail : TierButton()
sealed class Info : TierButton() {
data class Enabled(val url: String) : Info()
data object Disabled : Info()
}
sealed class Pay : TierButton() {
data object Enabled : Submit()
data object Disabled : Submit()
}
sealed class Manage : TierButton() {
sealed class Android : Manage() {
data class Enabled(val productId: String?) : Android()
data object Disabled : Android()
}
sealed class External : Manage() {
data class Enabled(val manageUrl: String?) : External()
data object Disabled : External()
}
}
}
sealed class TierAnyName {
data object Hidden : TierAnyName()
sealed class Visible : TierAnyName() {
data object Disabled : Visible()
data object Enter : Visible()
data object Validating : Visible()
data class Validated(val validatedName: String) : Visible()
data class Error(val membershipErrors: MembershipErrors) : Visible()
data class ErrorOther(val message: String?) : Visible()
data class Purchased(val name: String) : Visible()
}
}
sealed class TierEmail {
data object Hidden : TierEmail()
sealed class Visible : TierEmail() {
data object Enter : Visible()
data object Validating : Visible()
data object Validated : Visible()
data class Error(val membershipErrors: MembershipErrors) : Visible()
data class ErrorOther(val message: String?) : Visible()
}
}
data class BillingPriceInfo(val formattedPrice: String, val period: PeriodDescription)
data class PeriodDescription(val amount: Int, val unit: PeriodUnit)
enum class PeriodUnit { YEARS, MONTHS, WEEKS, DAYS }

View file

@ -2,9 +2,10 @@ package com.anytypeio.anytype.payments.playbilling
import android.app.Activity
import android.content.Context
import android.os.Handler
import android.os.Looper
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
@ -17,10 +18,10 @@ 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 kotlin.math.min
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import timber.log.Timber
@ -32,7 +33,8 @@ class BillingClientLifecycle(
) : DefaultLifecycleObserver, PurchasesUpdatedListener, BillingClientStateListener,
ProductDetailsResponseListener, PurchasesResponseListener {
private val _subscriptionPurchases = MutableStateFlow<List<Purchase>>(emptyList())
private val _subscriptionPurchases =
MutableStateFlow<BillingPurchaseState>(BillingPurchaseState.Loading)
/**
* Purchases are collectable. This list will be updated when the Billing Library
@ -48,13 +50,26 @@ class BillingClientLifecycle(
/**
* ProductDetails for all known products.
*/
val builderSubProductWithProductDetails = MutableLiveData<ProductDetails?>()
private val _builderSubProductWithProductDetails =
MutableStateFlow<BillingClientState>(BillingClientState.Loading)
val builderSubProductWithProductDetails: StateFlow<BillingClientState> =
_builderSubProductWithProductDetails
/**
* Instantiate a new BillingClient instance.
*/
private lateinit var billingClient: BillingClient
private val subscriptionIds = mutableListOf<String>()
// how long before the data source tries to reconnect to Google play
private var reconnectMilliseconds = RECONNECT_TIMER_START_MILLISECONDS
fun setupSubIds(ids: List<String>) {
subscriptionIds.clear()
subscriptionIds.addAll(ids)
}
override fun onCreate(owner: LifecycleOwner) {
Timber.d("ON_CREATE")
// Create a new BillingClient in onCreate().
@ -89,13 +104,31 @@ class BillingClientLifecycle(
// You can query product details and purchases here.
querySubscriptionProductDetails()
querySubscriptionPurchases()
} else {
Timber.e("onBillingSetupFinished: BillingResponse $responseCode")
_builderSubProductWithProductDetails.value =
BillingClientState.Error("BillingResponse $responseCode")
}
}
override fun onBillingServiceDisconnected() {
Timber.d("onBillingServiceDisconnected")
// TODO: Try connecting again with exponential backoff.
// billingClient.startConnection(this)
retryBillingServiceConnectionWithExponentialBackoff()
}
/**
* From the official example:
* https://github.com/android/play-billing-samples/blob/main/TrivialDriveKotlin/app/src/main/java/com/sample/android/trivialdrivesample/billing/BillingDataSource.kt
*/
private fun retryBillingServiceConnectionWithExponentialBackoff() {
handler.postDelayed(
{ billingClient.startConnection(this) },
reconnectMilliseconds
)
reconnectMilliseconds = min(
reconnectMilliseconds * 2,
RECONNECT_TIMER_MAX_TIME_MILLISECONDS
)
}
/**
@ -112,7 +145,7 @@ class BillingClientLifecycle(
val params = QueryProductDetailsParams.newBuilder()
val productList: MutableList<QueryProductDetailsParams.Product> = arrayListOf()
for (product in suscriptionTiers) {
for (product in subscriptionIds) {
productList.add(
QueryProductDetailsParams.Product.newBuilder()
.setProductId(product)
@ -148,6 +181,8 @@ class BillingClientLifecycle(
processProductDetails(productDetailsList)
} else {
Timber.e("onProductDetailsResponse: ${billingResult.responseCode}")
_builderSubProductWithProductDetails.value =
BillingClientState.Error("onProductDetailsResponse: ${billingResult.responseCode}")
}
}
@ -160,7 +195,7 @@ class BillingClientLifecycle(
*
*/
private fun processProductDetails(productDetailsList: MutableList<ProductDetails>) {
val expectedProductDetailsCount = suscriptionTiers.size
val expectedProductDetailsCount = subscriptionIds.size
if (productDetailsList.isEmpty()) {
Timber.e("Expected ${expectedProductDetailsCount}, Found null ProductDetails.")
postProductDetails(emptyList())
@ -177,18 +212,24 @@ class BillingClientLifecycle(
*
*/
private fun postProductDetails(productDetailsList: List<ProductDetails>) {
val result = mutableListOf<ProductDetails>()
productDetailsList.forEach { productDetails ->
when (productDetails.productType) {
BillingClient.ProductType.SUBS -> {
when (productDetails.productId) {
BillingConstants.SUBSCRIPTION_BUILDER -> {
Timber.d("Builder Subscription ProductDetails: $productDetails")
builderSubProductWithProductDetails.postValue(productDetails)
}
if (subscriptionIds.contains(productDetails.productId)) {
Timber.d("Subscription ProductDetails: $productDetails")
result.add(productDetails)
}
}
}
}
if (result.isNotEmpty()) {
_builderSubProductWithProductDetails.value = BillingClientState.Connected(result)
} else {
Timber.e("No product details found for subscriptionIds: $subscriptionIds")
_builderSubProductWithProductDetails.value =
BillingClientState.Error("No product details found for subscriptionIds: $subscriptionIds")
}
}
/**
@ -197,7 +238,7 @@ class BillingClientLifecycle(
* 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() {
private fun querySubscriptionPurchases() {
if (!billingClient.isReady) {
Timber.w("querySubscriptionPurchases: BillingClient is not ready")
billingClient.startConnection(this)
@ -216,7 +257,10 @@ class BillingClientLifecycle(
billingResult: BillingResult,
purchasesList: MutableList<Purchase>
) {
processPurchases(purchasesList)
processPurchases(
purchasesList = purchasesList,
isNewPurchase = false
)
}
/**
@ -233,9 +277,15 @@ class BillingClientLifecycle(
BillingClient.BillingResponseCode.OK -> {
if (purchases == null) {
Timber.d("onPurchasesUpdated: null purchase list")
processPurchases(null)
processPurchases(
purchasesList = null,
isNewPurchase = true
)
} else {
processPurchases(purchases)
processPurchases(
purchasesList = purchases,
isNewPurchase = true
)
}
}
@ -253,15 +303,17 @@ class BillingClientLifecycle(
"not recognize the configuration."
)
}
else -> {
Timber.e("onPurchasesUpdated: BillingResponseCode $responseCode")
}
}
}
/**
* Send purchase to StateFlow, which will trigger network call to verify the subscriptions
* on the sever.
* Send purchase to StateFlow
*/
private fun processPurchases(purchasesList: List<Purchase>?) {
Timber.d( "processPurchases: ${purchasesList?.size} purchase(s)")
private fun processPurchases(purchasesList: List<Purchase>?, isNewPurchase: Boolean) {
Timber.d("processPurchases: ${purchasesList?.size} purchase(s)")
purchasesList?.let { list ->
if (isUnchangedPurchaseList(list)) {
Timber.d("processPurchases: Purchase list has not changed")
@ -270,12 +322,22 @@ class BillingClientLifecycle(
scope.launch(dispatchers.io) {
val subscriptionPurchaseList = list.filter { purchase ->
purchase.products.any { product ->
product in suscriptionTiers
product in subscriptionIds
}
}
_subscriptionPurchases.emit(subscriptionPurchaseList)
if (subscriptionPurchaseList.isEmpty()) {
Timber.d("processPurchases: No subscription purchases found")
_subscriptionPurchases.emit(BillingPurchaseState.NoPurchases)
} else {
Timber.d("processPurchases: Subscription purchases found ${subscriptionPurchaseList[0]}")
_subscriptionPurchases.emit(
BillingPurchaseState.HasPurchases(
purchases = subscriptionPurchaseList,
isNewPurchase = isNewPurchase
)
)
}
}
logAcknowledgementStatus(list)
}
}
@ -290,32 +352,6 @@ class BillingClientLifecycle(
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<Purchase>) {
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.
*
@ -331,4 +367,25 @@ class BillingClientLifecycle(
Timber.d("launchBillingFlow: BillingResponse $responseCode $debugMessage")
return responseCode
}
companion object {
private val handler = Handler(Looper.getMainLooper())
private const val RECONNECT_TIMER_MAX_TIME_MILLISECONDS = 1000L * 60L * 15L // 15 minutes
private const val RECONNECT_TIMER_START_MILLISECONDS = 1L * 1000L // 1 second
}
}
sealed class BillingClientState {
data object Loading : BillingClientState()
data class Error(val message: String) : BillingClientState()
//Connected state is suppose that we have non empty list of product details
data class Connected(val productDetails: List<ProductDetails>) : BillingClientState()
}
sealed class BillingPurchaseState {
data object Loading : BillingPurchaseState()
data class HasPurchases(val purchases: List<Purchase>, val isNewPurchase: Boolean) : BillingPurchaseState()
data object NoPurchases : BillingPurchaseState()
}

View file

@ -0,0 +1,218 @@
package com.anytypeio.anytype.payments.screens
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.wrapContentHeight
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text2.BasicTextField2
import androidx.compose.foundation.text2.input.TextFieldLineLimits
import androidx.compose.foundation.text2.input.TextFieldState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.anytypeio.anytype.core_models.membership.MembershipErrors
import com.anytypeio.anytype.core_ui.foundation.Divider
import com.anytypeio.anytype.core_ui.views.BodyBold
import com.anytypeio.anytype.core_ui.views.BodyCallout
import com.anytypeio.anytype.core_ui.views.BodyRegular
import com.anytypeio.anytype.core_ui.views.Relations2
import com.anytypeio.anytype.payments.R
import com.anytypeio.anytype.payments.models.TierAnyName
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun AnyNameView(
anyNameState: TierAnyName,
anyNameTextField: TextFieldState
) {
if (anyNameState != TierAnyName.Hidden) {
val focusRequester = remember { FocusRequester() }
val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current
val anyNameEnabled = remember { mutableStateOf(false) }
val showHint = remember { mutableStateOf(false) }
anyNameEnabled.value = when (anyNameState) {
TierAnyName.Hidden -> false
TierAnyName.Visible.Disabled -> false
TierAnyName.Visible.Enter -> true
is TierAnyName.Visible.Error -> true
is TierAnyName.Visible.Validated -> true
TierAnyName.Visible.Validating -> true
is TierAnyName.Visible.ErrorOther -> true
is TierAnyName.Visible.Purchased -> false
}
Column(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.background(
shape = RoundedCornerShape(16.dp),
color = colorResource(id = R.color.background_primary)
)
.padding(horizontal = 20.dp)
) {
Text(
modifier = Modifier.fillMaxWidth(),
text = stringResource(id = R.string.payments_tier_details_name_title),
color = colorResource(id = R.color.text_primary),
style = BodyBold,
textAlign = TextAlign.Start
)
Spacer(modifier = Modifier.height(6.dp))
Text(
modifier = Modifier.fillMaxWidth(),
text = stringResource(id = R.string.payments_tier_details_name_subtitle),
color = colorResource(id = R.color.text_primary),
style = BodyCallout,
textAlign = TextAlign.Start
)
Spacer(modifier = Modifier.height(10.dp))
Box {
Row(modifier = Modifier.fillMaxWidth()) {
if (anyNameState is TierAnyName.Visible.Purchased) {
Text(
modifier = Modifier
.weight(1f)
.wrapContentHeight(),
text = anyNameState.name,
style = BodyRegular,
color = colorResource(id = R.color.text_tertiary)
)
Text(
text = stringResource(id = R.string.payments_tier_details_name_domain),
style = BodyRegular,
color = colorResource(id = R.color.text_tertiary)
)
} else {
BasicTextField2(
modifier = Modifier
.weight(1f)
.wrapContentHeight()
.focusRequester(focusRequester)
.onFocusChanged {
showHint.value = !it.isFocused && anyNameTextField.text.isEmpty()
},
state = anyNameTextField,
textStyle = BodyRegular.copy(color = colorResource(id = R.color.text_primary)),
enabled = anyNameEnabled.value,
cursorBrush = SolidColor(colorResource(id = R.color.text_primary)),
keyboardOptions = KeyboardOptions.Default.copy(
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions {
keyboardController?.hide()
focusManager.clearFocus()
},
lineLimits = TextFieldLineLimits.SingleLine,
interactionSource = remember { MutableInteractionSource() }
)
Text(
text = stringResource(id = R.string.payments_tier_details_name_domain),
style = BodyRegular,
color = colorResource(id = R.color.text_primary)
)
}
}
if (showHint.value) {
Text(
text = stringResource(id = R.string.payments_tier_details_name_hint),
style = BodyRegular,
color = colorResource(id = R.color.text_tertiary)
)
}
}
Spacer(modifier = Modifier.height(12.dp))
Divider(paddingStart = 0.dp, paddingEnd = 0.dp)
val (messageTextColor, messageText) = when (anyNameState) {
TierAnyName.Hidden -> Color.Transparent to ""
TierAnyName.Visible.Disabled -> colorResource(id = R.color.text_secondary) to stringResource(
id = R.string.payments_tier_details_name_min
)
TierAnyName.Visible.Enter -> colorResource(id = R.color.text_secondary) to stringResource(
id = R.string.payments_tier_details_name_min
)
is TierAnyName.Visible.Error -> ErrorMessage(anyNameState)
is TierAnyName.Visible.Validated -> colorResource(id = R.color.palette_dark_lime) to stringResource(
id = R.string.payments_tier_details_name_validated,
anyNameState.validatedName
)
TierAnyName.Visible.Validating -> colorResource(id = R.color.palette_dark_orange) to stringResource(
id = R.string.payments_tier_details_name_validating
)
is TierAnyName.Visible.ErrorOther -> colorResource(id = R.color.palette_system_red) to (anyNameState.message
?: stringResource(id = R.string.membership_any_name_unknown))
is TierAnyName.Visible.Purchased -> Color.Transparent to ""
}
Spacer(modifier = Modifier.height(10.dp))
Text(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp),
text = messageText,
color = messageTextColor,
style = Relations2,
textAlign = TextAlign.Center
)
}
}
}
@Composable
private fun ErrorMessage(state: TierAnyName.Visible.Error): Pair<Color, String> {
val color = colorResource(id = R.color.palette_system_red)
val res = when (state.membershipErrors) {
is MembershipErrors.IsNameValid.BadInput -> R.string.membership_name_bad_input
is MembershipErrors.IsNameValid.CacheError -> R.string.membership_name_cache_error
is MembershipErrors.IsNameValid.CanNotConnect -> R.string.membership_name_cant_connect
is MembershipErrors.IsNameValid.CanNotReserve -> R.string.membership_any_name_not_reserved
is MembershipErrors.IsNameValid.HasInvalidChars -> R.string.membership_name_invalid_chars
is MembershipErrors.IsNameValid.NotLoggedIn -> R.string.membership_name_not_logged
is MembershipErrors.IsNameValid.Null -> R.string.membership_any_name_null_error
is MembershipErrors.IsNameValid.PaymentNodeError -> R.string.membership_name_payment_node_error
is MembershipErrors.IsNameValid.TierFeaturesNoName -> R.string.membership_name_tier_features_no_name
is MembershipErrors.IsNameValid.TierNotFound -> R.string.membership_name_tier_not_found
is MembershipErrors.IsNameValid.TooLong -> R.string.membership_name_too_long
is MembershipErrors.IsNameValid.TooShort -> R.string.membership_name_too_short
is MembershipErrors.IsNameValid.UnknownError -> R.string.membership_any_name_unknown
is MembershipErrors.IsNameValid.NameIsReserved -> R.string.membership_any_name_not_reserved
is MembershipErrors.ResolveName.BadInput -> R.string.membership_name_bad_input
is MembershipErrors.ResolveName.CanNotConnect -> R.string.membership_name_cant_connect
is MembershipErrors.ResolveName.Null -> R.string.membership_any_name_null_error
is MembershipErrors.ResolveName.UnknownError -> R.string.membership_any_name_unknown
is MembershipErrors.ResolveName.NotAvailable -> R.string.membership_any_name_not_reserved
else -> R.string.membership_any_name_unknown
}
return color to stringResource(id = res)
}

View file

@ -0,0 +1,277 @@
package com.anytypeio.anytype.payments.screens
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.anytypeio.anytype.core_models.membership.MembershipPaymentMethod
import com.anytypeio.anytype.core_ui.views.BodyBold
import com.anytypeio.anytype.core_ui.views.Caption1Regular
import com.anytypeio.anytype.payments.R
import com.anytypeio.anytype.payments.mapping.LocalizedPeriodString
import com.anytypeio.anytype.payments.mapping.toPeriodDescription
import com.anytypeio.anytype.payments.models.BillingPriceInfo
import com.anytypeio.anytype.payments.models.PeriodDescription
import com.anytypeio.anytype.payments.models.PeriodUnit
import com.anytypeio.anytype.payments.models.TierConditionInfo
import com.anytypeio.anytype.payments.models.TierPeriod
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@Composable
fun ConditionInfoPreview(
state: TierConditionInfo
) {
when (state) {
is TierConditionInfo.Hidden -> {
// Do nothing
}
is TierConditionInfo.Visible.LoadingBillingClient -> {
ConditionInfoPreviewText(text = stringResource(id = R.string.membership_price_pending))
}
is TierConditionInfo.Visible.Valid -> {
val validUntilDate = formatTimestamp(state.dateEnds)
val result = when (state.period) {
TierPeriod.Unknown -> stringResource(id = R.string.membership_valid_for_unknown)
TierPeriod.Unlimited -> stringResource(id = R.string.payments_tier_details_free_forever)
else -> stringResource(id = R.string.payments_tier_details_valid_until, validUntilDate)
}
ConditionInfoPreviewText(text = result)
}
is TierConditionInfo.Visible.Price -> {
val periodString = stringResource(id = R.string.per_period, getDate(state.period))
ConditionInfoPreviewPriceAndText(state.price, periodString)
}
is TierConditionInfo.Visible.PriceBilling -> {
val periodString = stringResource(id = R.string.per_period, LocalizedPeriodString(desc = state.price.period))
ConditionInfoPreviewPriceAndText(
price = state.price.formattedPrice,
period = periodString
)
}
is TierConditionInfo.Visible.Free -> {
//todo: add some refactoring here
val text = when (state.period) {
is TierPeriod.Day -> stringResource(id = R.string.free_for, getDate(state.period))
is TierPeriod.Month -> stringResource(id = R.string.free_for, getDate(state.period))
TierPeriod.Unknown -> stringResource(id = R.string.free_for_unknown)
TierPeriod.Unlimited -> stringResource(id = R.string.payments_tier_details_free_forever)
is TierPeriod.Week -> stringResource(id = R.string.free_for, getDate(state.period))
is TierPeriod.Year -> stringResource(id = R.string.free_for, getDate(state.period))
}
ConditionInfoPreviewText(text = text)
}
is TierConditionInfo.Visible.Error -> {
ConditionInfoPreviewText(text = state.message, textColor = R.color.palette_dark_red)
}
TierConditionInfo.Visible.Pending -> ConditionInfoPreviewText(text = stringResource(id = R.string.membership_price_pending))
}
}
@Composable
fun getDate(tierPeriod: TierPeriod): String {
tierPeriod.toPeriodDescription().let { desc ->
return LocalizedPeriodString(desc)
}
}
@Composable
private fun ConditionInfoPreviewText(text: String, textColor: Int = R.color.text_primary) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(24.dp)
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
modifier = Modifier
.fillMaxWidth(),
text = text,
color = colorResource(id = textColor),
style = Caption1Regular,
textAlign = TextAlign.Start,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@Composable
private fun ConditionInfoPreviewPriceAndText(price: String, period: String) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(24.dp)
.padding(horizontal = 16.dp)
) {
Text(
modifier = Modifier
.wrapContentWidth()
.align(Alignment.CenterVertically),
text = price,
color = colorResource(id = R.color.text_primary),
style = BodyBold,
textAlign = TextAlign.Start
)
Text(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.Bottom)
.padding(start = 4.dp, bottom = 2.dp),
text = period,
color = colorResource(id = R.color.text_primary),
style = Caption1Regular,
textAlign = TextAlign.Start,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
fun formatTimestamp(timestamp: Long, locale: java.util.Locale = java.util.Locale.getDefault()): String {
val dateTime = Instant.ofEpochSecond(timestamp).atZone(ZoneId.systemDefault()).toLocalDate()
val formatter = DateTimeFormatter.ofPattern("d MMM uuuu", locale)
return dateTime.format(formatter)
}
@Preview
@Composable
fun MyConditionInfoPreview1() {
ConditionInfoPreview(TierConditionInfo.Visible.Price("$99", TierPeriod.Year(1)))
}
@Preview
@Composable
fun MyConditionInfoPreview2() {
ConditionInfoPreview(TierConditionInfo.Visible.Price("$99", TierPeriod.Year(2)))
}
@Preview
@Composable
fun MyConditionInfoPreview3() {
ConditionInfoPreview(TierConditionInfo.Visible.LoadingBillingClient)
}
@Preview
@Composable
fun MyConditionInfoPreview4() {
ConditionInfoPreview(TierConditionInfo.Visible.Error("Error message"))
}
@Preview
@Composable
fun MyConditionInfoPreview5() {
ConditionInfoPreview(TierConditionInfo.Visible.Free(TierPeriod.Unlimited))
}
@Preview
@Composable
fun MyConditionInfoPreview6() {
ConditionInfoPreview(TierConditionInfo.Visible.Free(TierPeriod.Year(1)))
}
@Preview
@Composable
fun MyConditionInfoPreview7() {
ConditionInfoPreview(TierConditionInfo.Visible.Free(TierPeriod.Year(2)))
}
@Preview
@Composable
fun MyConditionInfoPreview8() {
ConditionInfoPreview(TierConditionInfo.Visible.Free(TierPeriod.Unknown))
}
@Preview
@Composable
fun MyConditionInfoValidValidUntilDate() {
ConditionInfoPreview(
TierConditionInfo.Visible.Valid(
dateEnds = 1714199910L,
payedBy = MembershipPaymentMethod.METHOD_CRYPTO,
period = TierPeriod.Year(1)
)
)
}
@Preview
@Composable
fun MyConditionInfoValidForever() {
ConditionInfoPreview(
TierConditionInfo.Visible.Valid(
dateEnds = 1714199910L,
payedBy = MembershipPaymentMethod.METHOD_CRYPTO,
period = TierPeriod.Unlimited
)
)
}
@Preview
@Composable
fun MyConditionInfoValidUnknown() {
ConditionInfoPreview(
TierConditionInfo.Visible.Valid(
dateEnds = 1714199910L,
payedBy = MembershipPaymentMethod.METHOD_CRYPTO,
period = TierPeriod.Unknown
)
)
}
@Preview
@Composable
fun MyConditionInfoValidFree() {
ConditionInfoPreview(
TierConditionInfo.Visible.Valid(
dateEnds = 0,
payedBy = MembershipPaymentMethod.METHOD_CRYPTO,
period = TierPeriod.Unlimited
)
)
}
@Preview
@Composable
fun MyConditionInfoPriceBilling1() {
ConditionInfoPreview(
TierConditionInfo.Visible.PriceBilling(
price = BillingPriceInfo(
formattedPrice = "$99",
period = PeriodDescription(1, PeriodUnit.YEARS)
),
)
)
}
@Preview
@Composable
fun MyConditionInfoPriceBilling2() {
ConditionInfoPreview(
TierConditionInfo.Visible.PriceBilling(
price = BillingPriceInfo(
formattedPrice = "$300",
period = PeriodDescription(3, PeriodUnit.YEARS)
),
)
)
}

View file

@ -0,0 +1,304 @@
package com.anytypeio.anytype.payments.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
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.wrapContentWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.anytypeio.anytype.core_models.membership.MembershipPaymentMethod
import com.anytypeio.anytype.core_ui.views.BodyBold
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.payments.R
import com.anytypeio.anytype.payments.mapping.LocalizedPeriodString
import com.anytypeio.anytype.payments.models.TierConditionInfo
import com.anytypeio.anytype.payments.models.TierPeriod
@Composable
fun ConditionInfoView(
state: TierConditionInfo
) {
when (state) {
is TierConditionInfo.Hidden -> {
// Do nothing
}
is TierConditionInfo.Visible.LoadingBillingClient -> {
ConditionInfoViewPriceAndText(
period = stringResource(id = R.string.membership_price_pending),
price = ""
)
}
is TierConditionInfo.Visible.Valid -> {
val validUntilDate = formatTimestamp(state.dateEnds)
val result = when (state.period) {
TierPeriod.Unknown -> stringResource(id = R.string.three_dots_text_placeholder)
TierPeriod.Unlimited -> stringResource(id = R.string.payments_tier_details_free_forever)
else -> validUntilDate
}
val showPayedBy = state.payedBy != MembershipPaymentMethod.METHOD_NONE
ConditionInfoViewValid(
textValidUntil = result,
showPayedBy = showPayedBy,
paymentMethod = state.payedBy
)
}
is TierConditionInfo.Visible.Price -> {
val periodString = stringResource(id = R.string.per_period, getDate(state.period))
ConditionInfoViewPriceAndText(state.price, periodString)
}
is TierConditionInfo.Visible.PriceBilling -> {
val periodString = stringResource(
id = R.string.per_period,
LocalizedPeriodString(desc = state.price.period)
)
ConditionInfoViewPriceAndText(
price = state.price.formattedPrice,
period = periodString
)
}
is TierConditionInfo.Visible.Free -> {
ConditionInfoViewFree(text = stringResource(id = R.string.three_dots_text_placeholder))
}
is TierConditionInfo.Visible.Error -> {
ConditionInfoViewPriceAndText(price = "", period = state.message)
}
TierConditionInfo.Visible.Pending -> ConditionInfoViewPriceAndText(
period = stringResource(id = R.string.membership_price_pending),
price = ""
)
}
}
@Composable
private fun ConditionInfoViewPriceAndText(price: String, period: String) {
Row(
modifier = Modifier
.padding(horizontal = 20.dp)
.fillMaxWidth()
.height(32.dp)
) {
Text(
modifier = Modifier
.wrapContentWidth()
.align(Alignment.CenterVertically),
text = price,
color = colorResource(id = R.color.text_primary),
style = HeadlineTitle,
textAlign = TextAlign.Start
)
Text(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.Bottom)
.padding(start = 4.dp, bottom = 1.dp),
text = period,
color = colorResource(id = R.color.text_primary),
style = Relations1,
textAlign = TextAlign.Start,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@Composable
fun ConditionInfoViewValid(
textValidUntil: String,
showPayedBy: Boolean = true,
paymentMethod: MembershipPaymentMethod
) {
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp)) {
Text(
modifier = Modifier
.fillMaxWidth(),
text = stringResource(id = R.string.payments_tier_current_title),
color = colorResource(id = R.color.text_primary),
style = BodyBold,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(14.dp))
Column(
modifier = Modifier
.fillMaxWidth()
.height(144.dp)
.background(
shape = RoundedCornerShape(12.dp),
color = colorResource(id = R.color.payments_tier_current_background)
),
) {
Text(
modifier = Modifier
.fillMaxWidth()
.padding(top = 34.dp),
text = stringResource(id = R.string.payments_tier_current_valid),
color = colorResource(id = R.color.text_primary),
style = Relations2,
textAlign = TextAlign.Center
)
Text(
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp),
text = textValidUntil,
color = colorResource(id = R.color.text_primary),
style = HeadlineTitle,
textAlign = TextAlign.Center
)
if (showPayedBy) {
val payedBy = stringResource(id = R.string.payments_tier_current_paid_by)
val paymentText = when (paymentMethod) {
MembershipPaymentMethod.METHOD_NONE -> ""
MembershipPaymentMethod.METHOD_STRIPE -> "$payedBy ${stringResource(id = R.string.payments_tier_current_paid_by_card)}"
MembershipPaymentMethod.METHOD_CRYPTO -> "$payedBy ${stringResource(id = R.string.payments_tier_current_paid_by_crypto)}"
MembershipPaymentMethod.METHOD_INAPP_APPLE -> stringResource(id = R.string.payments_tier_current_paid_by_apple)
MembershipPaymentMethod.METHOD_INAPP_GOOGLE -> stringResource(id = R.string.payments_tier_current_paid_by_google)
}
Text(
modifier = Modifier
.fillMaxWidth()
.padding(top = 23.dp),
text = paymentText,
color = colorResource(id = R.color.text_secondary),
style = Relations2,
textAlign = TextAlign.Center
)
}
}
}
}
@Composable
fun ConditionInfoViewFree(text: String) {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
modifier = Modifier
.fillMaxWidth().padding(horizontal = 20.dp),
text = stringResource(id = R.string.payments_tier_current_title),
color = colorResource(id = R.color.text_primary),
style = BodyBold,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(14.dp))
Column(
modifier = Modifier
.fillMaxWidth()
.height(144.dp)
.background(
shape = RoundedCornerShape(12.dp),
color = colorResource(id = R.color.payments_tier_current_background)
),
) {
Text(
modifier = Modifier
.fillMaxWidth()
.padding(top = 34.dp),
text = stringResource(id = R.string.payments_tier_current_valid),
color = colorResource(id = R.color.text_primary),
style = Relations2,
textAlign = TextAlign.Center
)
Text(
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp),
text = text,
color = colorResource(id = R.color.text_primary),
style = HeadlineTitle,
textAlign = TextAlign.Center
)
}
}
}
@Preview
@Composable
fun MyConditionInfoViewForeverFree() {
ConditionInfoView(
state = TierConditionInfo.Visible.Valid(
period = TierPeriod.Unlimited,
dateEnds = 0,
payedBy = MembershipPaymentMethod.METHOD_CRYPTO
)
)
}
@Preview
@Composable
fun MyConditionInfoViewUntilDate() {
ConditionInfoView(
state = TierConditionInfo.Visible.Valid(
period = TierPeriod.Year(1),
dateEnds = 1714199910L,
payedBy = MembershipPaymentMethod.METHOD_INAPP_GOOGLE
)
)
}
@Preview
@Composable
fun MyConditionInfoViewInknown() {
ConditionInfoView(
state = TierConditionInfo.Visible.Valid(
period = TierPeriod.Unknown,
dateEnds = 1714199910L,
payedBy = MembershipPaymentMethod.METHOD_INAPP_GOOGLE
)
)
}
@Preview
@Composable
fun MyConditionInfoViewFree() {
ConditionInfoView(
state = TierConditionInfo.Visible.Free(
period = TierPeriod.Unknown
)
)
}
@Preview
@Composable
fun MyConditionInfoViewPrice1() {
ConditionInfoView(TierConditionInfo.Visible.Price("$99", TierPeriod.Year(1)))
}
@Preview
@Composable
fun MyConditionInfoViewPrice2() {
ConditionInfoView(TierConditionInfo.Visible.Price("$99", TierPeriod.Year(2)))
}
@Preview
@Composable
fun MyConditionInfoViewLoading() {
ConditionInfoView(TierConditionInfo.Visible.LoadingBillingClient)
}
@Preview
@Composable
fun MyConditionInfoViewPending() {
ConditionInfoView(TierConditionInfo.Visible.Pending)
}

View file

@ -1,237 +0,0 @@
package com.anytypeio.anytype.payments.screens
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
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.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
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.payments.R
import com.anytypeio.anytype.payments.viewmodel.PaymentsCodeState
import com.anytypeio.anytype.presentation.membership.models.TierId
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CodeScreen(
state: PaymentsCodeState,
actionResend: () -> Unit,
actionCode: (String, TierId) -> Unit,
onDismiss: () -> Unit
) {
if (state is PaymentsCodeState.Visible) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
sheetState = sheetState,
onDismissRequest = onDismiss,
containerColor = colorResource(id = R.color.background_primary),
content = { ModalCodeContent(state = state, actionCode = { code -> actionCode(code, state.tierId)}) }
)
}
}
@Composable
private fun ModalCodeContent(state: PaymentsCodeState.Visible, actionCode: (String) -> Unit) {
val focusRequesters = remember { List(4) { FocusRequester() } }
val enteredDigits = remember { mutableStateListOf<Char>() }
val focusManager = LocalFocusManager.current
LaunchedEffect(key1 = enteredDigits.size) {
if (enteredDigits.size == 4) {
val code = enteredDigits.joinToString("")
actionCode(code)
}
}
LaunchedEffect(key1 = state) {
if (state is PaymentsCodeState.Visible.Loading) {
focusManager.clearFocus(true)
}
}
Box(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize()) {
Spacer(modifier = Modifier.padding(118.dp))
Text(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp),
text = stringResource(id = com.anytypeio.anytype.localization.R.string.payments_code_title),
style = BodyBold,
color = colorResource(
id = R.color.text_primary
),
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(44.dp))
val modifier = Modifier
.width(48.dp)
.height(64.dp)
Row(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.CenterHorizontally),
horizontalArrangement = Arrangement.Center
) {
focusRequesters.forEachIndexed { index, focusRequester ->
CodeNumber(
isEnabled = state !is PaymentsCodeState.Visible.Loading,
modifier = modifier,
focusRequester = focusRequester,
onDigitEntered = { digit ->
if (enteredDigits.size < 4) {
enteredDigits.add(digit)
}
if (index < 3) focusRequesters[index + 1].requestFocus()
},
onBackspace = {
if (enteredDigits.isNotEmpty()) enteredDigits.removeLast()
if (index > 0) focusRequesters[index - 1].requestFocus()
}
)
if (index < 3) Spacer(modifier = Modifier.width(8.dp))
}
}
if (state is PaymentsCodeState.Visible.Error) {
Text(
text = state.message,
color = colorResource(id = R.color.palette_system_red),
modifier = Modifier
.fillMaxWidth()
.padding(start = 20.dp, end = 20.dp, top = 7.dp),
textAlign = TextAlign.Center
)
}
Spacer(modifier = Modifier.height(149.dp))
Text(
modifier = Modifier.fillMaxWidth(),
text = stringResource(id = com.anytypeio.anytype.localization.R.string.payments_code_resend),
style = PreviewTitle1Regular,
color = colorResource(id = R.color.text_tertiary),
textAlign = TextAlign.Center
)
}
AnimatedVisibility(
modifier = Modifier.align(Alignment.Center),
visible = state is PaymentsCodeState.Visible.Loading,
enter = fadeIn(),
exit = fadeOut()
) {
CircularProgressIndicator(
modifier = Modifier
.size(24.dp),
color = colorResource(R.color.shape_secondary),
trackColor = colorResource(R.color.shape_primary)
)
}
}
}
@Composable
private fun CodeNumber(
isEnabled: Boolean,
focusRequester: FocusRequester,
onDigitEntered: (Char) -> Unit,
onBackspace: () -> Unit,
modifier: Modifier
) {
val (text, setText) = remember { mutableStateOf("") }
val borderColor = colorResource(id = R.color.shape_primary)
BasicTextField(
value = text,
onValueChange = { newValue ->
when {
newValue.length == 1 && newValue[0].isDigit() && text.isEmpty() -> {
setText(newValue)
onDigitEntered(newValue[0])
}
newValue.isEmpty() -> {
if (text.isNotEmpty()) {
setText("")
onBackspace()
}
}
}
},
modifier = modifier
.focusRequester(focusRequester)
.onKeyEvent { event ->
if (event.type == KeyEventType.KeyUp && event.key == Key.Backspace && text.isEmpty()) {
onBackspace()
true
} else false
},
singleLine = true,
enabled = isEnabled,
cursorBrush = SolidColor(colorResource(id = R.color.text_primary)),
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Next,
keyboardType = KeyboardType.Number
),
decorationBox = { innerTextField ->
Box(
modifier = Modifier
.border(BorderStroke(1.dp, borderColor), RoundedCornerShape(8.dp))
.padding(horizontal = 15.dp),
contentAlignment = Alignment.Center
) {
innerTextField()
}
},
textStyle = HeadlineTitle.copy(color = colorResource(id = R.color.text_primary))
)
}
@Preview
@Composable
fun EnterCodeModalPreview() {
ModalCodeContent(
state = PaymentsCodeState.Visible.Loading(TierId("123")),
actionCode = { _ -> }
)
}

View file

@ -0,0 +1,247 @@
package com.anytypeio.anytype.payments.screens
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
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.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.anytypeio.anytype.core_models.membership.MembershipErrors
import com.anytypeio.anytype.core_ui.foundation.noRippleThrottledClickable
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.payments.R
import com.anytypeio.anytype.payments.viewmodel.MembershipEmailCodeState
import com.anytypeio.anytype.payments.viewmodel.TierAction
import kotlinx.coroutines.delay
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CodeScreen(
state: MembershipEmailCodeState,
action: (TierAction) -> Unit,
onDismiss: () -> Unit
) {
if (state is MembershipEmailCodeState.Visible) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
sheetState = sheetState,
onDismissRequest = onDismiss,
containerColor = colorResource(id = R.color.background_primary),
content = {
ModalCodeContent(
state = state,
action = action
)
}
)
}
}
@Composable
private fun ModalCodeContent(
state: MembershipEmailCodeState.Visible,
action: (TierAction) -> Unit
) {
var enteredDigits by remember { mutableStateOf("") }
val focusManager = LocalFocusManager.current
val borderColor = colorResource(id = R.color.shape_primary)
val borderSelectedColor = colorResource(id = R.color.shape_secondary)
var timeLeft by remember { mutableStateOf(RESEND_DELAY) }
LaunchedEffect(key1 = timeLeft) {
if (timeLeft > 0) {
delay(1000)
timeLeft--
}
}
LaunchedEffect(key1 = enteredDigits.length) {
if (enteredDigits.length == 4) {
action(TierAction.OnVerifyCodeClicked(enteredDigits))
}
}
LaunchedEffect(key1 = state) {
if (state is MembershipEmailCodeState.Visible.Loading) {
focusManager.clearFocus(true)
}
}
Box(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize()) {
Spacer(modifier = Modifier.padding(118.dp))
Text(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp),
text = stringResource(id = com.anytypeio.anytype.localization.R.string.payments_code_title),
style = BodyBold,
color = colorResource(
id = R.color.text_primary
),
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(44.dp))
BasicTextField(
modifier = Modifier.fillMaxWidth(),
value = enteredDigits,
onValueChange = {
if (it.length <= 4) {
enteredDigits = it
}
},
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Next,
keyboardType = KeyboardType.Number
),
decorationBox = {
Row(horizontalArrangement = Arrangement.Center) {
repeat(4) { index ->
val char = when {
index >= enteredDigits.length -> ""
else -> enteredDigits[index].toString()
}
val isFocused = index == enteredDigits.length
Text(modifier = Modifier
.width(50.dp)
.border(BorderStroke(
if (isFocused) 2.dp else 1.dp,
if (isFocused) borderSelectedColor else borderColor),
RoundedCornerShape(8.dp)
)
.padding(vertical = 16.dp),
text = char,
style = HeadlineTitle,
color = colorResource(id = R.color.text_primary),
textAlign = TextAlign.Center
)
if (index < 3) Spacer(modifier = Modifier.width(15.dp))
}
}
}
)
val (messageTextColor, messageText) = when (state) {
is MembershipEmailCodeState.Visible.Error -> ErrorMessage(state)
is MembershipEmailCodeState.Visible.ErrorOther -> colorResource(id = R.color.palette_system_red) to state.message
MembershipEmailCodeState.Visible.Success -> colorResource(id = R.color.palette_dark_lime) to stringResource(
id = R.string.membership_email_code_success
)
else -> Color.Transparent to ""
}
Text(
text = messageText.orEmpty(),
color = messageTextColor,
modifier = Modifier
.fillMaxWidth()
.padding(start = 20.dp, end = 20.dp, top = 7.dp),
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(149.dp))
val (resendEnabled, resendText) = if (timeLeft == 0) {
true to stringResource(id = R.string.payments_code_resend)
} else {
false to stringResource(id = R.string.payments_code_resend_in, timeLeft)
}
Text(
modifier = Modifier
.fillMaxWidth()
.noRippleThrottledClickable {
if (resendEnabled) {
timeLeft = RESEND_DELAY
action(TierAction.OnResendCodeClicked)
}
}
.alpha(if (resendEnabled) 1f else 0.5f),
text = resendText,
style = PreviewTitle1Regular,
color = colorResource(id = R.color.text_tertiary),
textAlign = TextAlign.Center
)
}
AnimatedVisibility(
modifier = Modifier.align(Alignment.Center),
visible = state is MembershipEmailCodeState.Visible.Loading,
enter = fadeIn(),
exit = fadeOut()
) {
CircularProgressIndicator(
modifier = Modifier
.size(24.dp),
color = colorResource(R.color.shape_secondary),
trackColor = colorResource(R.color.shape_primary)
)
}
}
}
@Composable
private fun ErrorMessage(state: MembershipEmailCodeState.Visible.Error): Pair<Color, String> {
val color = colorResource(id = R.color.palette_system_red)
val message = when (state.error) {
is MembershipErrors.VerifyEmailCode.BadInput -> R.string.membership_name_bad_input
is MembershipErrors.VerifyEmailCode.CacheError -> R.string.membership_name_cache_error
is MembershipErrors.VerifyEmailCode.CanNotConnect -> R.string.membership_name_cant_connect
is MembershipErrors.VerifyEmailCode.CodeExpired -> R.string.membership_email_code_expired
is MembershipErrors.VerifyEmailCode.CodeWrong -> R.string.membership_email_code_wrong
is MembershipErrors.VerifyEmailCode.EmailAlreadyVerified -> R.string.membership_email_already_verified
is MembershipErrors.VerifyEmailCode.MembershipAlreadyActive -> R.string.membership_email_membership_already_active
is MembershipErrors.VerifyEmailCode.MembershipNotFound -> R.string.membership_email_membership_not_found
is MembershipErrors.VerifyEmailCode.NotLoggedIn -> R.string.membership_name_not_logged
is MembershipErrors.VerifyEmailCode.Null -> R.string.membership_any_name_null_error
is MembershipErrors.VerifyEmailCode.PaymentNodeError -> R.string.membership_name_payment_node_error
is MembershipErrors.VerifyEmailCode.UnknownError -> R.string.membership_any_name_unknown
else -> R.string.membership_any_name_unknown
}
return color to stringResource(id = message)
}
const val RESEND_DELAY = 60
@Preview
@Composable
fun EnterCodeModalPreview() {
ModalCodeContent(
state = MembershipEmailCodeState.Visible.Initial,
action = {}
)
}

View file

@ -0,0 +1,173 @@
package com.anytypeio.anytype.payments.screens
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.wrapContentHeight
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text2.BasicTextField2
import androidx.compose.foundation.text2.input.TextFieldLineLimits
import androidx.compose.foundation.text2.input.TextFieldState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.anytypeio.anytype.core_models.membership.MembershipErrors
import com.anytypeio.anytype.core_ui.foundation.Divider
import com.anytypeio.anytype.core_ui.views.BodyCallout
import com.anytypeio.anytype.core_ui.views.BodyRegular
import com.anytypeio.anytype.core_ui.views.Relations2
import com.anytypeio.anytype.payments.R
import com.anytypeio.anytype.payments.models.TierEmail
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun MembershipEmailScreen(
state: TierEmail,
anyEmailTextField: TextFieldState
) {
if (state != TierEmail.Hidden) {
val focusRequester = remember { FocusRequester() }
val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current
val anyEmailEnabled = remember { mutableStateOf(false) }
val showHint = remember { mutableStateOf(false) }
anyEmailEnabled.value = when (state) {
TierEmail.Hidden -> false
TierEmail.Visible.Enter -> true
TierEmail.Visible.Validated -> true
TierEmail.Visible.Validating -> false
is TierEmail.Visible.Error -> true
is TierEmail.Visible.ErrorOther -> true
}
Column(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.background(
shape = RoundedCornerShape(16.dp),
color = colorResource(id = R.color.background_primary)
)
.padding(horizontal = 20.dp)
) {
Spacer(modifier = Modifier.height(40.dp))
Text(
modifier = Modifier.fillMaxWidth(),
text = stringResource(id = R.string.payments_email_title),
color = colorResource(id = R.color.text_primary),
style = BodyRegular,
textAlign = TextAlign.Start
)
Spacer(modifier = Modifier.height(6.dp))
Text(
modifier = Modifier.fillMaxWidth(),
text = stringResource(id = R.string.payments_email_subtitle),
color = colorResource(id = R.color.text_secondary),
style = BodyCallout,
textAlign = TextAlign.Start
)
Spacer(modifier = Modifier.height(10.dp))
Box(modifier = Modifier) {
BasicTextField2(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.focusRequester(focusRequester)
.onFocusChanged {
showHint.value = !it.isFocused && anyEmailTextField.text.isEmpty()
},
state = anyEmailTextField,
textStyle = BodyRegular.copy(color = colorResource(id = R.color.text_primary)),
enabled = anyEmailEnabled.value,
cursorBrush = SolidColor(colorResource(id = R.color.text_primary)),
keyboardOptions = KeyboardOptions.Default.copy(
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions {
keyboardController?.hide()
focusManager.clearFocus()
},
lineLimits = TextFieldLineLimits.SingleLine,
interactionSource = remember { MutableInteractionSource() }
)
if (showHint.value) {
Text(
text = stringResource(id = R.string.payments_email_hint),
style = BodyRegular,
color = colorResource(id = R.color.text_tertiary)
)
}
}
Spacer(modifier = Modifier.height(12.dp))
Divider(paddingStart = 0.dp, paddingEnd = 0.dp)
val (messageTextColor, messageText) = when (state) {
is TierEmail.Visible.Error -> ErrorMessage(state)
is TierEmail.Visible.ErrorOther -> colorResource(id = R.color.palette_system_red) to (state.message ?: stringResource(id = R.string.membership_any_name_unknown))
else -> Color.Transparent to ""
}
Spacer(modifier = Modifier.height(10.dp))
Text(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp),
text = messageText,
color = messageTextColor,
style = Relations2,
textAlign = TextAlign.Center
)
}
}
}
@Composable
fun ErrorMessage(state: TierEmail.Visible.Error): Pair<Color, String> {
val color = colorResource(id = R.color.palette_system_red)
val res = when (state.membershipErrors) {
is MembershipErrors.GetVerificationEmail.EmailAlreadySent -> R.string.membership_email_already_sent
is MembershipErrors.GetVerificationEmail.EmailAlreadyVerified -> R.string.membership_email_already_verified
is MembershipErrors.GetVerificationEmail.EmailFailedToSend -> R.string.membership_email_failed_to_send
is MembershipErrors.GetVerificationEmail.EmailWrongFormat -> R.string.membership_email_wrong_format
is MembershipErrors.GetVerificationEmail.MembershipAlreadyExists -> R.string.membership_email_membership_already_exists
is MembershipErrors.GetVerificationEmail.BadInput -> R.string.membership_name_bad_input
is MembershipErrors.GetVerificationEmail.CacheError -> R.string.membership_name_cache_error
is MembershipErrors.GetVerificationEmail.CanNotConnect -> R.string.membership_name_cant_connect
is MembershipErrors.GetVerificationEmail.NotLoggedIn -> R.string.membership_name_not_logged
is MembershipErrors.GetVerificationEmail.PaymentNodeError -> R.string.membership_name_payment_node_error
else -> R.string.membership_any_name_unknown
}
return color to stringResource(id = res)
}
@OptIn(ExperimentalFoundationApi::class)
@Preview(showBackground = true)
@Composable
fun MembershipEmailScreenPreview() {
MembershipEmailScreen(
state = TierEmail.Visible.Error(MembershipErrors.GetVerificationEmail.EmailWrongFormat("error")),
anyEmailTextField = TextFieldState(initialText = "")
)
}

View file

@ -83,7 +83,7 @@ fun infoCardsState() = listOf(
subtitle = stringResource(id = R.string.payments_card_description_1),
gradient = Brush.verticalGradient(
colors = listOf(
Color(0xFFCFF6CF),
colorResource(id = R.color.membership_info_gradient_green),
Color.Transparent
)
)
@ -94,7 +94,7 @@ fun infoCardsState() = listOf(
subtitle = stringResource(id = R.string.payments_card_description_2),
gradient = Brush.verticalGradient(
colors = listOf(
Color(0xFFFEF2C6),
colorResource(id = R.color.membership_info_gradient_yellow),
Color.Transparent
)
)
@ -105,7 +105,7 @@ fun infoCardsState() = listOf(
subtitle = stringResource(id = R.string.payments_card_description_3),
gradient = Brush.verticalGradient(
colors = listOf(
Color(0xFFFFEBEB),
colorResource(id = R.color.membership_info_gradient_pink),
Color.Transparent
)
)
@ -116,7 +116,7 @@ fun infoCardsState() = listOf(
subtitle = stringResource(id = R.string.payments_card_description_4),
gradient = Brush.verticalGradient(
colors = listOf(
Color(0xFFEBEDFE),
colorResource(id = R.color.membership_info_gradient_purple),
Color.Transparent
)
)

View file

@ -1,655 +0,0 @@
package com.anytypeio.anytype.payments.screens
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
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.wrapContentHeight
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.anytypeio.anytype.core_ui.foundation.Divider
import com.anytypeio.anytype.core_ui.foundation.noRippleClickable
import com.anytypeio.anytype.core_ui.views.BodyBold
import com.anytypeio.anytype.core_ui.views.BodyCallout
import com.anytypeio.anytype.core_ui.views.BodyRegular
import com.anytypeio.anytype.core_ui.views.ButtonPrimary
import com.anytypeio.anytype.core_ui.views.ButtonSecondary
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.payments.R
import com.anytypeio.anytype.presentation.membership.models.Tier
import com.anytypeio.anytype.payments.viewmodel.PaymentsTierState
import com.anytypeio.anytype.presentation.membership.models.TierId
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TierScreen(
state: PaymentsTierState,
onDismiss: () -> Unit,
actionPay: (TierId) -> Unit,
actionSubmitEmail: (TierId, String) -> Unit
) {
if (state is PaymentsTierState.Visible) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
modifier = Modifier.padding(top = 30.dp),
sheetState = sheetState,
containerColor = Color.Transparent,
dragHandle = null,
onDismissRequest = { onDismiss() },
content = {
MembershipLevels(
tier = state.tier,
actionPay = { actionPay(state.tier.id) },
actionSubmitEmail = { email -> actionSubmitEmail(state.tier.id, email) }
)
}
)
}
}
@Composable
fun MembershipLevels(tier: Tier, actionPay: () -> Unit, actionSubmitEmail: (String) -> Unit) {
Box(
modifier = Modifier
.fillMaxSize()
.background(
color = colorResource(id = R.color.shape_tertiary),
shape = RoundedCornerShape(16.dp)
),
) {
val tierResources = mapTierToResources(tier)
if (tierResources != null) {
val brush = Brush.verticalGradient(
listOf(
tierResources.colorGradient,
Color.Transparent
)
)
Column {
Box(
modifier = Modifier
.fillMaxWidth()
.height(132.dp)
.background(brush = brush, shape = RoundedCornerShape(16.dp)),
contentAlignment = androidx.compose.ui.Alignment.BottomStart
) {
Icon(
modifier = Modifier
.padding(start = 16.dp),
painter = painterResource(id = tierResources.mediumIcon!!),
contentDescription = "logo",
tint = tierResources.radialGradient
)
}
Spacer(modifier = Modifier.height(14.dp))
Text(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp),
text = tierResources.title,
color = colorResource(id = R.color.text_primary),
style = HeadlineTitle,
textAlign = TextAlign.Start
)
Text(
modifier = Modifier
.fillMaxWidth()
.padding(start = 20.dp, end = 20.dp, top = 6.dp),
text = tierResources.subtitle,
color = colorResource(id = R.color.text_primary),
style = BodyCallout,
textAlign = TextAlign.Start
)
Text(
modifier = Modifier
.fillMaxWidth()
.padding(start = 20.dp, end = 20.dp, top = 22.dp),
text = stringResource(id = R.string.payments_details_whats_included),
color = colorResource(id = R.color.text_secondary),
style = BodyCallout,
textAlign = TextAlign.Start
)
Spacer(modifier = Modifier.height(6.dp))
tierResources.benefits.forEach { benefit ->
Benefit(benefit = benefit)
Spacer(modifier = Modifier.height(6.dp))
}
Spacer(modifier = Modifier.height(30.dp))
if (tier is Tier.Explorer) {
if (tier.isCurrent) {
StatusSubscribedExplorer(tier)
} else {
SubmitEmail(tier = tier, updateEmail = { email ->
actionSubmitEmail(email)
})
}
}
if (tier is Tier.Builder) {
if (tier.isCurrent) {
StatusSubscribed(tier, {})
} else {
NamePickerAndButton(
name = tier.name,
nameIsTaken = tier.nameIsTaken,
nameIsFree = tier.nameIsFree,
price = tier.price,
interval = tier.interval,
actionPay = actionPay
)
}
}
if (tier is Tier.CoCreator) {
if (tier.isCurrent) {
StatusSubscribed(tier, {})
} else {
NamePickerAndButton(
name = tier.name,
nameIsTaken = tier.nameIsTaken,
nameIsFree = tier.nameIsFree,
price = tier.price,
interval = tier.interval,
actionPay = actionPay
)
}
}
}
}
}
}
@Composable
fun NamePickerAndButton(
name: String,
nameIsTaken: Boolean,
nameIsFree: Boolean,
price: String,
interval: String,
actionPay: () -> Unit
) {
var innerValue by remember(name) { mutableStateOf(name) }
val focusRequester = remember { FocusRequester() }
val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current
Column(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
.background(
shape = RoundedCornerShape(16.dp),
color = colorResource(id = R.color.background_primary)
)
.padding(start = 20.dp, end = 20.dp)
) {
Text(
modifier = Modifier
.fillMaxWidth()
.padding(top = 26.dp),
text = stringResource(id = R.string.payments_tier_details_name_title),
color = colorResource(id = R.color.text_primary),
style = BodyBold,
textAlign = TextAlign.Start
)
Text(
modifier = Modifier
.fillMaxWidth()
.padding(top = 6.dp),
text = stringResource(id = R.string.payments_tier_details_name_subtitle),
color = colorResource(id = R.color.text_primary),
style = BodyCallout,
textAlign = TextAlign.Start
)
Spacer(modifier = Modifier.height(10.dp))
Row(modifier = Modifier.fillMaxWidth()) {
BasicTextField(
value = innerValue,
onValueChange = { innerValue = it },
textStyle = BodyRegular.copy(color = colorResource(id = com.anytypeio.anytype.core_ui.R.color.text_primary)),
singleLine = true,
enabled = true,
cursorBrush = SolidColor(colorResource(id = com.anytypeio.anytype.core_ui.R.color.text_primary)),
modifier = Modifier
.weight(1f)
.wrapContentHeight()
.padding(start = 0.dp, top = 2.dp)
.focusRequester(focusRequester)
.onFocusChanged { focusState ->
if (focusState.isFocused) {
} else {
}
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text
),
keyboardActions = KeyboardActions {
keyboardController?.hide()
focusManager.clearFocus()
},
decorationBox = { innerTextField ->
if (innerValue.isEmpty()) {
Text(
text = stringResource(id = com.anytypeio.anytype.localization.R.string.payments_tier_details_name_hint),
style = BodyRegular,
color = colorResource(id = com.anytypeio.anytype.core_ui.R.color.text_tertiary),
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
)
}
innerTextField()
}
)
Text(
text = stringResource(id = R.string.payments_tier_details_name_domain),
style = BodyRegular,
color = colorResource(id = R.color.text_primary)
)
}
Spacer(modifier = Modifier.height(12.dp))
Divider(paddingStart = 0.dp, paddingEnd = 0.dp)
val (messageTextColor, messageText) = when {
nameIsTaken ->
colorResource(id = R.color.palette_system_red) to stringResource(id = R.string.payments_tier_details_name_error)
nameIsFree ->
colorResource(id = R.color.palette_dark_lime) to stringResource(id = R.string.payments_tier_details_name_success)
else ->
colorResource(id = R.color.text_secondary) to stringResource(id = R.string.payments_tier_details_name_min)
}
Spacer(modifier = Modifier.height(10.dp))
Text(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp),
text = messageText,
color = messageTextColor,
style = Relations2,
textAlign = TextAlign.Center
)
Price(price = price, interval = interval)
Spacer(modifier = Modifier.height(14.dp))
ButtonPay(enabled = true, actionPay = {
actionPay()
})
}
}
@Composable
private fun StatusSubscribed(tier: Tier, actionManage: () -> Unit) {
Column(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
.background(
shape = RoundedCornerShape(16.dp),
color = colorResource(id = R.color.background_primary)
)
.padding(start = 20.dp, end = 20.dp)
) {
Text(
modifier = Modifier
.fillMaxWidth()
.padding(top = 26.dp),
text = stringResource(id = R.string.payments_tier_current_title),
color = colorResource(id = R.color.text_primary),
style = BodyBold,
textAlign = TextAlign.Start
)
Spacer(modifier = Modifier.height(14.dp))
Column(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.background(
shape = RoundedCornerShape(12.dp),
color = colorResource(id = R.color.payments_tier_current_background)
)
) {
Text(
modifier = Modifier
.fillMaxWidth()
.padding(top = 34.dp),
text = stringResource(id = R.string.payments_tier_current_valid),
color = colorResource(id = R.color.text_primary),
style = Relations2,
textAlign = TextAlign.Center
)
Text(
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp),
text = tier.validUntil,
color = colorResource(id = R.color.text_primary),
style = HeadlineTitle,
textAlign = TextAlign.Center
)
Text(
modifier = Modifier
.fillMaxWidth()
.padding(top = 23.dp, bottom = 15.dp),
text = stringResource(id = R.string.payments_tier_current_paid_by),
color = colorResource(id = R.color.text_secondary),
style = Relations2,
textAlign = TextAlign.Center
)
}
Spacer(modifier = Modifier.height(20.dp))
ButtonSecondary(
enabled = true,
text = stringResource(id = R.string.payments_tier_current_button),
onClick = { actionManage() },
size = ButtonSize.LargeSecondary,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp)
)
}
}
@Composable
private fun StatusSubscribedExplorer(tier: Tier) {
Column(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
.background(
shape = RoundedCornerShape(16.dp),
color = colorResource(id = R.color.background_primary)
)
.padding(start = 20.dp, end = 20.dp)
) {
Text(
modifier = Modifier
.fillMaxWidth()
.padding(top = 26.dp),
text = stringResource(id = R.string.payments_tier_current_title),
color = colorResource(id = R.color.text_primary),
style = BodyBold,
textAlign = TextAlign.Start
)
Spacer(modifier = Modifier.height(14.dp))
Column(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.background(
shape = RoundedCornerShape(12.dp),
color = colorResource(id = R.color.payments_tier_current_background)
)
) {
Text(
modifier = Modifier
.fillMaxWidth()
.padding(top = 34.dp),
text = stringResource(id = R.string.payments_tier_current_valid),
color = colorResource(id = R.color.text_primary),
style = Relations2,
textAlign = TextAlign.Center
)
Text(
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp, bottom = 56.dp),
text = tier.validUntil,
color = colorResource(id = R.color.text_primary),
style = HeadlineTitle,
textAlign = TextAlign.Center
)
}
Spacer(modifier = Modifier.height(20.dp))
ButtonSecondary(
enabled = true,
text = stringResource(id = R.string.payments_tier_current_change_email_button),
onClick = { },
size = ButtonSize.LargeSecondary,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp)
)
}
}
@Composable
private fun Price(price: String, interval: String) {
Row() {
Text(
modifier = Modifier
.wrapContentWidth()
.padding(start = 20.dp),
text = price,
color = colorResource(id = R.color.text_primary),
style = HeadlineTitle,
textAlign = TextAlign.Start
)
Text(
modifier = Modifier
.wrapContentWidth()
.align(Alignment.Bottom)
.padding(bottom = 4.dp, start = 6.dp),
text = interval,
color = colorResource(id = R.color.text_primary),
style = Relations1,
textAlign = TextAlign.Start
)
}
}
@Composable
private fun Benefit(benefit: String) {
Box(
modifier = Modifier
.wrapContentHeight()
.fillMaxWidth()
.padding(horizontal = 20.dp)
) {
Image(
modifier = Modifier
.wrapContentSize()
.align(Alignment.CenterStart),
painter = painterResource(id = R.drawable.ic_check_16),
contentDescription = "text check icon"
)
Text(
modifier = Modifier
.fillMaxWidth()
.padding(start = 22.dp)
.align(Alignment.CenterStart),
text = benefit,
style = BodyCallout,
color = colorResource(id = R.color.text_primary)
)
}
}
@Composable
private fun SubmitEmail(tier: Tier.Explorer, updateEmail: (String) -> Unit) {
var innerValue by remember(tier.email) { mutableStateOf(tier.email) }
val focusRequester = remember { FocusRequester() }
val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current
var isChecked by remember(tier.isChecked) { mutableStateOf(tier.isChecked) }
Column(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
.background(
shape = RoundedCornerShape(16.dp),
color = colorResource(id = R.color.background_primary)
)
.padding(start = 20.dp, end = 20.dp)
) {
Spacer(modifier = Modifier.height(26.dp))
Text(
text = stringResource(id = R.string.payments_email_title),
style = BodyBold,
color = colorResource(id = R.color.text_primary)
)
Spacer(modifier = Modifier.height(6.dp))
Text(
text = stringResource(id = R.string.payments_email_subtitle),
style = BodyCallout,
color = colorResource(id = R.color.text_primary)
)
Spacer(modifier = Modifier.height(10.dp))
BasicTextField(
value = innerValue,
onValueChange = { innerValue = it },
textStyle = BodyRegular.copy(color = colorResource(id = com.anytypeio.anytype.core_ui.R.color.text_primary)),
singleLine = true,
enabled = true,
cursorBrush = SolidColor(colorResource(id = com.anytypeio.anytype.core_ui.R.color.text_primary)),
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(start = 0.dp, top = 2.dp)
.focusRequester(focusRequester)
.onFocusChanged { focusState ->
if (focusState.isFocused) {
} else {
}
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email
),
keyboardActions = KeyboardActions {
keyboardController?.hide()
focusManager.clearFocus()
},
decorationBox = { innerTextField ->
if (innerValue.isEmpty()) {
Text(
text = stringResource(id = com.anytypeio.anytype.localization.R.string.payments_email_hint),
style = BodyRegular,
color = colorResource(id = com.anytypeio.anytype.core_ui.R.color.text_tertiary),
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
)
}
innerTextField()
}
)
Spacer(modifier = Modifier.height(12.dp))
Divider(paddingStart = 0.dp, paddingEnd = 0.dp)
val icon = if (isChecked) {
R.drawable.ic_system_checkbox
} else {
R.drawable.ic_system_checkbox_empty
}
Spacer(modifier = Modifier.height(15.dp))
Row {
Image(
modifier = Modifier
.padding(top = 3.dp)
.size(16.dp)
.noRippleClickable { isChecked = !isChecked },
painter = painterResource(id = icon),
contentDescription = "checkbox"
)
Text(
modifier = Modifier
.fillMaxWidth()
.padding(start = 12.dp),
text = stringResource(id = R.string.payments_email_checkbox_text),
style = BodyRegular,
color = colorResource(id = R.color.text_primary)
)
}
Spacer(modifier = Modifier.height(31.dp))
val enabled = innerValue.isNotEmpty()
ButtonPrimary(
enabled = enabled,
text = stringResource(id = R.string.payments_detials_button_submit),
onClick = { updateEmail.invoke(innerValue) },
size = ButtonSize.Large,
modifier = Modifier.fillMaxWidth()
)
}
}
@Composable
private fun ButtonPay(enabled: Boolean, actionPay: () -> Unit) {
ButtonPrimary(
enabled = enabled,
text = stringResource(id = R.string.payments_detials_button_pay),
onClick = { actionPay() },
size = ButtonSize.Large,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp)
)
}
@Preview()
@Composable
fun MyLevel() {
MembershipLevels(
tier = Tier.Explorer(
id = TierId("121"),
isCurrent = true,
price = "$99",
validUntil = "12/12/2025",
),
actionPay = {},
actionSubmitEmail = {}
)
}

View file

@ -1,8 +1,13 @@
package com.anytypeio.anytype.payments.screens
import androidx.annotation.StringRes
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@ -26,6 +31,7 @@ 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.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@ -41,7 +47,6 @@ 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.style.TextDecoration
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp
@ -50,61 +55,109 @@ import com.anytypeio.anytype.core_ui.foundation.Divider
import com.anytypeio.anytype.core_ui.foundation.Dragger
import com.anytypeio.anytype.core_ui.foundation.noRippleThrottledClickable
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.Caption1Regular
import com.anytypeio.anytype.core_ui.views.Relations2
import com.anytypeio.anytype.core_ui.views.fontRiccioneRegular
import com.anytypeio.anytype.presentation.membership.models.Tier
import com.anytypeio.anytype.payments.viewmodel.PaymentsMainState
import com.anytypeio.anytype.payments.models.TierPreview
import com.anytypeio.anytype.payments.viewmodel.MembershipMainState
import com.anytypeio.anytype.payments.viewmodel.TierAction
import com.anytypeio.anytype.presentation.membership.models.TierId
@Composable
fun MainPaymentsScreen(state: PaymentsMainState, tierClicked: (TierId) -> Unit) {
fun MainMembershipScreen(
state: MembershipMainState,
tierClicked: (TierId) -> Unit,
tierAction: (TierAction) -> Unit
) {
Box(
modifier = Modifier
.nestedScroll(rememberNestedScrollInteropConnection())
.fillMaxWidth()
.wrapContentHeight()
.background(
color = colorResource(id = R.color.background_secondary),
color = colorResource(id = R.color.background_primary),
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)
),
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(bottom = 20.dp)
.verticalScroll(rememberScrollState())
MainContent(state, tierClicked, tierAction)
AnimatedVisibility(
modifier = Modifier.align(Alignment.Center),
visible = state is MembershipMainState.Loading,
enter = fadeIn(),
exit = fadeOut()
) {
if (state is PaymentsMainState.Default) {
Title()
Spacer(modifier = Modifier.height(7.dp))
Subtitle()
Spacer(modifier = Modifier.height(32.dp))
CircularProgressIndicator(
modifier = Modifier
.size(24.dp),
color = colorResource(R.color.shape_secondary),
trackColor = colorResource(R.color.shape_primary)
)
}
}
}
@Composable
private fun MainContent(
state: MembershipMainState,
tierClicked: (TierId) -> Unit,
tierAction: (TierAction) -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(bottom = 20.dp)
.verticalScroll(rememberScrollState())
) {
if (state is MembershipMainState.Default) {
Title()
Spacer(modifier = Modifier.height(7.dp))
if (state.subtitle != null) {
Subtitle(state.subtitle)
}
Spacer(modifier = Modifier.height(32.dp))
if (state.showBanner) {
InfoCards()
Spacer(modifier = Modifier.height(32.dp))
TiersList(tiers = state.tiers, tierClicked = tierClicked)
Spacer(modifier = Modifier.height(32.dp))
LinkButton(text = stringResource(id = R.string.payments_member_link), action = {})
Divider()
LinkButton(text = stringResource(id = R.string.payments_privacy_link), action = {})
Divider()
LinkButton(text = stringResource(id = R.string.payments_terms_link), action = {})
Spacer(modifier = Modifier.height(32.dp))
BottomText()
}
if (state is PaymentsMainState.PaymentSuccess) {
Title()
Spacer(modifier = Modifier.height(39.dp))
TiersList(tiers = state.tiers, tierClicked = tierClicked)
Spacer(modifier = Modifier.height(32.dp))
LinkButton(text = stringResource(id = R.string.payments_member_link), action = {})
Divider()
LinkButton(text = stringResource(id = R.string.payments_privacy_link), action = {})
Divider()
LinkButton(text = stringResource(id = R.string.payments_terms_link), action = {})
Spacer(modifier = Modifier.height(32.dp))
BottomText()
}
TiersList(tiers = state.tiersPreview, onClick = tierClicked)
Spacer(modifier = Modifier.height(32.dp))
LinkButton(text = stringResource(id = R.string.payments_member_link), action = {
tierAction(TierAction.OpenUrl(state.membershipLevelDetails))
})
Divider()
LinkButton(text = stringResource(id = R.string.payments_privacy_link), action = {
tierAction(TierAction.OpenUrl(state.privacyPolicy))
})
Divider()
LinkButton(text = stringResource(id = R.string.payments_terms_link), action = {
tierAction(TierAction.OpenUrl(state.termsOfService))
})
Spacer(modifier = Modifier.height(32.dp))
BottomText(tierAction = tierAction)
}
if (state is MembershipMainState.ErrorState) {
Title()
Spacer(modifier = Modifier.height(39.dp))
Text(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 40.dp),
text = state.message,
color = colorResource(id = R.color.palette_system_red),
style = BodyRegular,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(32.dp))
ButtonSecondary(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp),
onClick = { tierAction(TierAction.ContactUsError(state.message)) },
size = ButtonSize.LargeSecondary,
text = stringResource(id = R.string.contact_us)
)
}
}
}
@ -140,7 +193,7 @@ private fun Title() {
}
@Composable
private fun Subtitle() {
private fun Subtitle(@StringRes subtitle: Int) {
Box(
modifier = Modifier
.fillMaxWidth()
@ -150,7 +203,7 @@ private fun Subtitle() {
modifier = Modifier
.fillMaxWidth()
.padding(start = 60.dp, end = 60.dp),
text = stringResource(id = R.string.payments_subheader),
text = stringResource(id = subtitle),
color = colorResource(id = R.color.text_primary),
style = Relations2,
textAlign = TextAlign.Center
@ -160,8 +213,8 @@ private fun Subtitle() {
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun TiersList(tiers: List<Tier>, tierClicked: (TierId) -> Unit) {
val itemsScroll = rememberLazyListState(initialFirstVisibleItemIndex = 1)
fun TiersList(tiers: List<TierPreview>, onClick: (TierId) -> Unit) {
val itemsScroll = rememberLazyListState()
LazyRow(
state = itemsScroll,
modifier = Modifier
@ -171,19 +224,10 @@ fun TiersList(tiers: List<Tier>, tierClicked: (TierId) -> Unit) {
flingBehavior = rememberSnapFlingBehavior(lazyListState = itemsScroll)
) {
itemsIndexed(tiers) { _, tier ->
val resources = mapTierToResources(tier)
if (resources != null) {
TierView(
title = resources.title,
subTitle = resources.subtitle,
colorGradient = resources.colorGradient,
radialGradient = resources.radialGradient,
icon = resources.smallIcon,
buttonText = stringResource(id = R.string.payments_button_learn),
onClick = { tierClicked.invoke(tier.id) },
isCurrent = tier.isCurrent
)
}
TierPreviewView(
onClick = onClick,
tier = tier
)
}
}
}
@ -256,7 +300,9 @@ fun LinkButton(text: String, action: () -> Unit) {
}
@Composable
fun BottomText() {
fun BottomText(
tierAction: (TierAction) -> Unit
) {
val start = stringResource(id = R.string.payments_let_us_link_start)
val end = stringResource(id = R.string.payments_let_us_link_end)
val buildString = buildAnnotatedString {
@ -277,25 +323,14 @@ fun BottomText() {
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp)
.wrapContentHeight(),
.wrapContentHeight()
.clickable { tierAction(TierAction.OpenEmail) },
text = buildString,
style = Caption1Regular,
color = colorResource(id = R.color.text_primary)
)
}
@Preview
@Composable
fun MainPaymentsScreenPreview() {
val tiers = listOf(
Tier.Explorer(TierId("999"), isCurrent = true, validUntil = "2022-12-31"),
Tier.Builder(TierId("999"), isCurrent = true, validUntil = "2022-12-31"),
Tier.CoCreator(TierId("999"), isCurrent = false, validUntil = "2022-12-31"),
Tier.Custom(TierId("999"), isCurrent = false, validUntil = "2022-12-31")
)
MainPaymentsScreen(PaymentsMainState.PaymentSuccess(tiers), {})
}
val headerTextStyle = TextStyle(
fontFamily = fontRiccioneRegular,
fontWeight = FontWeight.W400,

View file

@ -21,12 +21,10 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp
@ -37,22 +35,24 @@ 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.presentation.membership.models.Tier
import com.anytypeio.anytype.payments.constants.MembershipConstants.BUILDER_ID
import com.anytypeio.anytype.payments.constants.MembershipConstants.CO_CREATOR_ID
import com.anytypeio.anytype.payments.constants.MembershipConstants.EXPLORER_ID
import com.anytypeio.anytype.payments.models.TierPreview
import com.anytypeio.anytype.payments.models.Tier
import com.anytypeio.anytype.presentation.editor.cover.CoverColor
import com.anytypeio.anytype.presentation.membership.models.TierId
@Composable
fun TierView(
title: String,
subTitle: String,
colorGradient: Color,
radialGradient: Color,
icon: Int,
buttonText: String,
onClick: () -> Unit,
isCurrent: Boolean
fun TierPreviewView(
tier: TierPreview,
onClick: (TierId) -> Unit
) {
val resources = mapTierPreviewToResources(tier)
val brush = Brush.verticalGradient(
listOf(
colorGradient,
resources.colors.gradientStart,
Color.Transparent
)
)
@ -63,10 +63,10 @@ fun TierView(
.width(192.dp)
.wrapContentHeight()
.background(
color = colorResource(id = R.color.shape_tertiary),
color = colorResource(id = R.color.shape_secondary),
shape = RoundedCornerShape(16.dp)
)
.noRippleThrottledClickable { onClick() }
.noRippleThrottledClickable { onClick(tier.id) }
) {
Box(
modifier = Modifier
@ -78,16 +78,16 @@ fun TierView(
Icon(
modifier = Modifier
.padding(start = 16.dp),
painter = painterResource(id = icon),
painter = painterResource(id = resources.smallIcon),
contentDescription = "logo",
tint = radialGradient
tint = resources.colors.gradientEnd
)
}
Text(
modifier = Modifier
.fillMaxWidth()
.padding(start = 17.dp, top = 10.dp),
text = title,
text = tier.title,
color = colorResource(id = R.color.text_primary),
style = titleTextStyle,
textAlign = TextAlign.Start
@ -97,24 +97,24 @@ fun TierView(
.fillMaxWidth()
.height(96.dp)
.padding(start = 16.dp, end = 16.dp, top = 5.dp),
text = subTitle,
text = tier.subtitle,
color = colorResource(id = R.color.text_primary),
style = Caption1Regular,
textAlign = TextAlign.Start
)
PriceOrOption()
ConditionInfoPreview(state = tier.conditionInfo)
ButtonPrimary(
text = buttonText,
text = stringResource(id = R.string.payments_button_learn),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
onClick = { onClick() },
onClick = { onClick(tier.id) },
size = ButtonSize.Small
)
Spacer(modifier = Modifier.height(10.dp))
}
if (isCurrent) {
if (tier.isActive) {
Text(
modifier = Modifier
.wrapContentSize()
@ -136,76 +136,89 @@ fun TierView(
}
@Composable
fun PriceOrOption() {
Text(
modifier = Modifier.padding(start = 16.dp),
text = "9.99",
style = titleTextStyle,
color = colorResource(id = R.color.text_primary)
)
}
@Composable
fun mapTierToResources(tier: Tier?): TierResources? {
return when (tier) {
is Tier.Builder -> TierResources(
title = stringResource(id = R.string.payments_tier_builder),
subtitle = stringResource(id = R.string.payments_tier_builder_description),
fun mapTierToResources(tier: Tier): TierResources {
return when (tier.id.value) {
BUILDER_ID -> TierResources(
mediumIcon = R.drawable.logo_builder_96,
smallIcon = R.drawable.logo_builder_64,
colorGradient = Color(0xFFE4E7FF),
radialGradient = Color(0xFFA5AEFF),
benefits = stringArrayResource(id = R.array.payments_benefits_builder).toList()
colors = toValue(tier.color),
features = tier.features,
)
is Tier.CoCreator -> TierResources(
title = stringResource(id = R.string.payments_tier_cocreator),
subtitle = stringResource(id = R.string.payments_tier_cocreator_description),
CO_CREATOR_ID -> TierResources(
mediumIcon = R.drawable.logo_co_creator_96,
smallIcon = R.drawable.logo_co_creator_64,
colorGradient = Color(0xFFFBEAEA),
radialGradient = Color(0xFFF05F5F),
benefits = stringArrayResource(id = R.array.payments_benefits_cocreator).toList()
colors = toValue(tier.color),
features = tier.features,
)
is Tier.Custom -> TierResources(
title = stringResource(id = R.string.payments_tier_custom),
subtitle = stringResource(id = R.string.payments_tier_custom_description),
smallIcon = R.drawable.logo_custom_64,
colorGradient = Color(0xFFFBEAFF),
radialGradient = Color(0xFFFE86DE3),
benefits = emptyList()
)
is Tier.Explorer -> TierResources(
title = stringResource(id = R.string.payments_tier_explorer),
subtitle = stringResource(id = R.string.payments_tier_explorer_description),
EXPLORER_ID -> TierResources(
mediumIcon = R.drawable.logo_explorer_96,
smallIcon = R.drawable.logo_explorer_64,
colorGradient = Color(0xFFCFFAFF),
radialGradient = Color(0xFF24BFD4),
benefits = stringArrayResource(id = R.array.payments_benefits_explorer).toList()
colors = toValue(tier.color),
features = tier.features,
)
else -> TierResources(
smallIcon = R.drawable.logo_custom_64,
mediumIcon = R.drawable.logo_custom_64,
colors = toValue(tier.color),
features = tier.features,
)
else -> null
}
}
@Preview
@Composable
fun TierPreview() {
TierView(
title = "Explorer",
subTitle = "Dive into the network and enjoy the thrill of one-on-one collaboration",
buttonText = "Subscribe",
onClick = {},
icon = R.drawable.logo_co_creator_64,
colorGradient = Color(0xFFCFF6CF),
radialGradient = Color(0xFF24BFD4),
isCurrent = true
fun mapTierPreviewToResources(tier: TierPreview): TierResources {
return when (tier.id.value) {
BUILDER_ID -> TierResources(
mediumIcon = R.drawable.logo_builder_96,
smallIcon = R.drawable.logo_builder_64,
colors = toValue(tier.color)
)
CO_CREATOR_ID -> TierResources(
mediumIcon = R.drawable.logo_co_creator_96,
smallIcon = R.drawable.logo_co_creator_64,
colors = toValue(tier.color)
)
EXPLORER_ID -> TierResources(
mediumIcon = R.drawable.logo_explorer_96,
smallIcon = R.drawable.logo_explorer_64,
colors = toValue(tier.color)
)
else -> TierResources(
smallIcon = R.drawable.logo_custom_64,
mediumIcon = R.drawable.logo_custom_64,
colors = toValue(tier.color)
)
}
}
@Composable
fun toValue(colorCode: String): TierColors {
return TierColors(
gradientStart = colorCode.gradientStart(),
gradientEnd = colorCode.gradientEnd()
)
}
@Composable
fun String.gradientStart(): Color = when (this) {
CoverColor.RED.code -> colorResource(id = R.color.tier_gradient_red_start)
CoverColor.BLUE.code -> colorResource(id = R.color.tier_gradient_blue_start)
CoverColor.GREEN.code -> colorResource(id = R.color.tier_gradient_teal_start)
CoverColor.PURPLE.code -> colorResource(id = R.color.tier_gradient_purple_start)
else -> colorResource(id = R.color.tier_gradient_blue_start)
}
@Composable
private fun String.gradientEnd(): Color = when (this) {
CoverColor.RED.code -> colorResource(id = R.color.tier_gradient_red_end)
CoverColor.BLUE.code -> colorResource(id = R.color.tier_gradient_blue_end)
CoverColor.GREEN.code -> colorResource(id = R.color.tier_gradient_teal_end)
CoverColor.PURPLE.code -> colorResource(id = R.color.tier_gradient_purple_end)
else -> colorResource(id = R.color.tier_gradient_blue_end)
}
val titleTextStyle = TextStyle(
fontFamily = fontInterSemibold,
fontWeight = FontWeight.W600,
@ -215,11 +228,13 @@ val titleTextStyle = TextStyle(
)
data class TierResources(
val title: String,
val subtitle: String,
val mediumIcon: Int? = null,
val mediumIcon: Int,
val smallIcon: Int,
val colorGradient: Color,
val radialGradient: Color,
val benefits: List<String>
val colors: TierColors,
val features: List<String> = emptyList()
)
data class TierColors(
val gradientStart: Color,
val gradientEnd: Color
)

View file

@ -0,0 +1,413 @@
package com.anytypeio.anytype.payments.screens
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.wrapContentHeight
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.text2.input.TextFieldState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
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.style.TextAlign
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.anytypeio.anytype.core_models.membership.MembershipPaymentMethod
import com.anytypeio.anytype.core_ui.views.BodyCallout
import com.anytypeio.anytype.core_ui.views.ButtonPrimary
import com.anytypeio.anytype.core_ui.views.ButtonSecondary
import com.anytypeio.anytype.core_ui.views.ButtonSize
import com.anytypeio.anytype.core_ui.views.HeadlineTitle
import com.anytypeio.anytype.payments.R
import com.anytypeio.anytype.payments.constants.MembershipConstants.EXPLORER_ID
import com.anytypeio.anytype.payments.constants.MembershipConstants.PRIVACY_POLICY
import com.anytypeio.anytype.payments.constants.MembershipConstants.TERMS_OF_SERVICE
import com.anytypeio.anytype.payments.models.TierAnyName
import com.anytypeio.anytype.payments.models.TierButton
import com.anytypeio.anytype.payments.models.TierConditionInfo
import com.anytypeio.anytype.payments.models.TierEmail
import com.anytypeio.anytype.payments.models.TierPeriod
import com.anytypeio.anytype.payments.models.Tier
import com.anytypeio.anytype.payments.viewmodel.MembershipTierState
import com.anytypeio.anytype.payments.viewmodel.TierAction
import com.anytypeio.anytype.presentation.membership.models.TierId
import timber.log.Timber
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
fun TierViewScreen(
state: MembershipTierState,
onDismiss: () -> Unit,
actionTier: (TierAction) -> Unit,
anyNameTextField: TextFieldState,
anyEmailTextField: TextFieldState
) {
if (state is MembershipTierState.Visible) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
modifier = Modifier
.padding(top = 30.dp)
.fillMaxWidth()
.wrapContentHeight(),
sheetState = sheetState,
containerColor = Color.Transparent,
dragHandle = null,
onDismissRequest = { onDismiss() },
content = {
TierViewVisible(
state = state,
actionTier = actionTier,
anyNameTextField = anyNameTextField,
anyEmailTextField = anyEmailTextField
)
},
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun TierViewVisible(
state: MembershipTierState.Visible,
actionTier: (TierAction) -> Unit,
anyNameTextField: TextFieldState,
anyEmailTextField: TextFieldState
) {
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight(align = Alignment.Top)
.background(
color = colorResource(id = R.color.shape_tertiary),
shape = RoundedCornerShape(16.dp)
)
.verticalScroll(scrollState),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
) {
val tierResources: TierResources = mapTierToResources(state.tier)
val brush = Brush.verticalGradient(
listOf(
tierResources.colors.gradientStart,
Color.Transparent
)
)
Box(
modifier = Modifier
.fillMaxWidth()
.height(132.dp)
.background(brush = brush, shape = RoundedCornerShape(16.dp)),
contentAlignment = Alignment.BottomStart
) {
Icon(
modifier = Modifier
.padding(start = 16.dp),
painter = painterResource(id = tierResources.mediumIcon),
contentDescription = "logo",
tint = tierResources.colors.gradientEnd
)
}
Spacer(modifier = Modifier.height(14.dp))
Text(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp),
text = state.tier.title,
color = colorResource(id = R.color.text_primary),
style = HeadlineTitle,
textAlign = TextAlign.Start
)
Text(
modifier = Modifier
.fillMaxWidth()
.padding(start = 20.dp, end = 20.dp, top = 6.dp),
text = state.tier.subtitle,
color = colorResource(id = R.color.text_primary),
style = BodyCallout,
textAlign = TextAlign.Start
)
Text(
modifier = Modifier
.fillMaxWidth()
.padding(start = 20.dp, end = 20.dp, top = 22.dp),
text = stringResource(id = R.string.payments_details_whats_included),
color = colorResource(id = R.color.text_secondary),
style = BodyCallout,
textAlign = TextAlign.Start
)
Spacer(modifier = Modifier.height(6.dp))
state.tier.features.forEach { benefit ->
Benefit(benefit = benefit)
Spacer(modifier = Modifier.height(6.dp))
}
Spacer(modifier = Modifier.height(30.dp))
}
Column(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.background(
shape = RoundedCornerShape(16.dp),
color = colorResource(id = R.color.background_primary)
)
) {
Spacer(modifier = Modifier.height(26.dp))
if (state.tier.isActive) {
ConditionInfoView(state = state.tier.conditionInfo)
MembershipEmailScreen(
state = state.tier.email,
anyEmailTextField = anyEmailTextField
)
Spacer(modifier = Modifier.height(20.dp))
SecondaryButton(
buttonState = state.tier.buttonState,
tierId = state.tier.id,
actionTier = actionTier
)
} else {
AnyNameView(
anyNameState = state.tier.membershipAnyName,
anyNameTextField = anyNameTextField
)
ConditionInfoView(state = state.tier.conditionInfo)
Spacer(modifier = Modifier.height(14.dp))
MainButton(
buttonState = state.tier.buttonState,
tier = state.tier,
actionTier = actionTier
)
when (state.tier.buttonState) {
TierButton.Pay.Enabled -> TermsAndPrivacyText(actionTier)
else -> {}
}
}
Spacer(modifier = Modifier.height(300.dp))
}
}
}
@Composable
fun Benefit(benefit: String) {
Box(
modifier = Modifier
.wrapContentHeight()
.fillMaxWidth()
.padding(horizontal = 20.dp)
) {
Image(
modifier = Modifier
.wrapContentSize()
.align(Alignment.CenterStart),
painter = painterResource(id = R.drawable.ic_check_16),
contentDescription = "text check icon"
)
Text(
modifier = Modifier
.fillMaxWidth()
.padding(start = 22.dp)
.align(Alignment.CenterStart),
text = benefit,
style = BodyCallout,
color = colorResource(id = R.color.text_primary)
)
}
}
@Composable
private fun MainButton(
tier: Tier,
buttonState: TierButton,
actionTier: (TierAction) -> Unit
) {
if (buttonState !is TierButton.Hidden) {
val (stringRes, enabled) = getButtonText(buttonState)
ButtonPrimary(
enabled = enabled,
text = stringResource(id = stringRes),
onClick = {
when (buttonState) {
is TierButton.Pay.Enabled -> actionTier(TierAction.PayClicked(tier.id))
is TierButton.Info.Enabled -> actionTier(TierAction.OpenUrl(tier.urlInfo))
TierButton.Submit.Enabled -> actionTier(TierAction.SubmitClicked)
else -> {
Timber.d("MainButton: skipped action: $buttonState")
}
}
},
size = ButtonSize.Large,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp)
)
}
}
@Composable
private fun TermsAndPrivacyText(
actionTier: (TierAction) -> Unit
) {
val start = stringResource(id = R.string.membership_agree_start)
val middle = stringResource(id = R.string.membership_agree_middle)
val terms = stringResource(id = R.string.membership_agree_terms)
val privacy = stringResource(id = R.string.membership_agree_privacy)
val annotatedString = buildAnnotatedString {
append(start)
append(" ")
pushStringAnnotation(tag = TAG_TERMS, annotation = TERMS_OF_SERVICE)
withStyle(
style = SpanStyle(
color = colorResource(id = R.color.text_secondary)
)
) { append(terms) }
pop()
append(" ")
append(middle)
append(" ")
pushStringAnnotation(tag = TAG_PRIVACY, annotation = PRIVACY_POLICY)
withStyle(
style = SpanStyle(
color = colorResource(id = R.color.text_secondary)
)
) { append(privacy) }
pop()
}
Spacer(modifier = Modifier.height(16.dp))
ClickableText(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp),
text = annotatedString,
style = BodyCallout.copy(
textAlign = TextAlign.Center,
color = colorResource(id = R.color.text_tertiary)
),
onClick = { offset ->
annotatedString.getStringAnnotations(tag = TAG_TERMS, start = offset, end = offset)
.firstOrNull()?.let { annotation ->
actionTier(TierAction.OpenUrl(annotation.item))
}
annotatedString.getStringAnnotations(tag = TAG_PRIVACY, start = offset, end = offset)
.firstOrNull()?.let { annotation ->
actionTier(TierAction.OpenUrl(annotation.item))
}
})
}
@Composable
private fun SecondaryButton(
tierId: TierId,
buttonState: TierButton,
actionTier: (TierAction) -> Unit
) {
if (buttonState !is TierButton.Hidden) {
val (stringRes, enabled) = getButtonText(buttonState)
ButtonSecondary(
enabled = enabled,
text = stringResource(id = stringRes),
onClick = {
when (buttonState) {
is TierButton.Pay.Enabled -> actionTier(TierAction.PayClicked(tierId))
is TierButton.Manage.Android.Enabled -> actionTier(TierAction.ManagePayment(tierId))
TierButton.Submit.Enabled -> actionTier(TierAction.SubmitClicked)
TierButton.ChangeEmail -> actionTier(TierAction.ChangeEmail)
else -> {}
}
},
size = ButtonSize.Large,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp)
)
}
}
@Composable
private fun getButtonText(buttonState: TierButton): Pair<Int, Boolean> {
return when (buttonState) {
TierButton.Hidden -> Pair(0, false)
TierButton.Info.Disabled -> Pair(R.string.payments_button_info, false)
is TierButton.Info.Enabled -> Pair(R.string.payments_button_info, true)
TierButton.Manage.Android.Disabled -> Pair(R.string.payments_button_manage, false)
is TierButton.Manage.Android.Enabled -> Pair(R.string.payments_button_manage, true)
TierButton.Manage.External.Disabled -> Pair(R.string.payments_button_manage, false)
is TierButton.Manage.External.Enabled -> Pair(R.string.payments_button_manage, true)
TierButton.Submit.Disabled -> Pair(R.string.payments_button_submit, false)
TierButton.Submit.Enabled -> Pair(R.string.payments_button_submit, true)
TierButton.Pay.Disabled -> Pair(R.string.payments_button_pay, false)
TierButton.Pay.Enabled -> Pair(R.string.payments_button_pay, true)
TierButton.ChangeEmail -> Pair(R.string.payments_button_change_email, true)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Preview
@Composable
fun TierViewScreenPreview() {
TierViewScreen(
state = MembershipTierState.Visible(
tier = Tier(
title = "Builder",
subtitle = "Subtitle",
features = listOf(
"Feature 1",
"Feature 2",
"Feature 3",
"Feature 1"
),
isActive = false,
conditionInfo = TierConditionInfo.Visible.Valid(
dateEnds = 1714199910,
period = TierPeriod.Year(1),
payedBy = MembershipPaymentMethod.METHOD_INAPP_GOOGLE
),
buttonState = TierButton.Pay.Enabled,
id = TierId(value = EXPLORER_ID),
membershipAnyName = TierAnyName.Visible.Purchased("someanyname111"),
email = TierEmail.Visible.Enter,
color = "teal",
stripeManageUrl = "",
iosManageUrl = "",
androidManageUrl = "",
androidProductId = "",
paymentMethod = MembershipPaymentMethod.METHOD_INAPP_GOOGLE
)
),
actionTier = {},
onDismiss = {},
anyNameTextField = TextFieldState(),
anyEmailTextField = TextFieldState()
)
}
const val TAG_TERMS = "terms"
const val TAG_PRIVACY = "privacy"

View file

@ -1,9 +1,15 @@
package com.anytypeio.anytype.payments.screens
import android.content.res.Configuration.UI_MODE_NIGHT_NO
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.layout.wrapContentSize
@ -16,26 +22,33 @@ import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.anytypeio.anytype.core_models.membership.MembershipPaymentMethod
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.payments.R
import com.anytypeio.anytype.presentation.membership.models.Tier
import com.anytypeio.anytype.payments.viewmodel.PaymentsWelcomeState
import com.anytypeio.anytype.payments.models.TierAnyName
import com.anytypeio.anytype.payments.models.TierButton
import com.anytypeio.anytype.payments.models.TierConditionInfo
import com.anytypeio.anytype.payments.models.TierEmail
import com.anytypeio.anytype.payments.models.TierPeriod
import com.anytypeio.anytype.payments.models.Tier
import com.anytypeio.anytype.payments.viewmodel.WelcomeState
import com.anytypeio.anytype.presentation.membership.models.TierId
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PaymentWelcomeScreen(state: PaymentsWelcomeState, onDismiss: () -> Unit) {
fun WelcomeScreen(state: WelcomeState, onDismiss: () -> Unit) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
if (state is PaymentsWelcomeState.Initial) {
if (state is WelcomeState.Initial) {
ModalBottomSheet(
modifier = Modifier
.padding(start = 16.dp, end = 16.dp)
@ -43,36 +56,49 @@ fun PaymentWelcomeScreen(state: PaymentsWelcomeState, onDismiss: () -> Unit) {
.wrapContentHeight(),
sheetState = sheetState,
onDismissRequest = onDismiss,
containerColor = colorResource(id = R.color.background_secondary),
containerColor = Color.Transparent,
content = {
val tierResources = mapTierToResources(state.tier)
if (tierResources != null) WelcomeContent(tierResources, onDismiss)
BoxWithConstraints(
Modifier.navigationBarsPadding()
) {
val boxWithConstraintsScope = this
val tierResources = mapTierToResources(state.tier)
WelcomeContent(state.tier, tierResources, onDismiss)
}
},
shape = RoundedCornerShape(16.dp),
dragHandle = null
dragHandle = null,
windowInsets = WindowInsets(0, 0, 0, 0)
)
}
}
@Composable
private fun WelcomeContent(tierResources: TierResources, onDismiss: () -> Unit) {
private fun WelcomeContent(tier: Tier, tierResources: TierResources, onDismiss: () -> Unit) {
Column(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 20.dp)
.background(
shape = RoundedCornerShape(16.dp),
color = colorResource(id = R.color.background_primary)
),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(36.dp))
Icon(
modifier = Modifier.wrapContentSize(),
painter = painterResource(id = tierResources.mediumIcon!!),
painter = painterResource(id = tierResources.mediumIcon),
contentDescription = "logo",
tint = tierResources.radialGradient
tint = tierResources.colors.gradientEnd
)
Spacer(modifier = Modifier.height(14.dp))
Text(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp),
text = stringResource(id = R.string.payments_welcome_title, tierResources.title),
text = stringResource(id = R.string.payments_welcome_title, tier.title),
color = colorResource(id = R.color.text_primary),
style = HeadlineHeading,
textAlign = TextAlign.Center
@ -101,9 +127,40 @@ private fun WelcomeContent(tierResources: TierResources, onDismiss: () -> Unit)
}
@Preview
@Preview(
name = "Dark Mode",
showBackground = true,
uiMode = UI_MODE_NIGHT_YES
)
@Preview(
name = "Light Mode",
showBackground = true,
uiMode = UI_MODE_NIGHT_NO
)
@Composable
fun PaymentWelcomeScreenPreview() {
PaymentWelcomeScreen(
PaymentsWelcomeState.Initial(Tier.Explorer(TierId("Free"), true, "01-01-2025")), {})
WelcomeScreen(
WelcomeState.Initial(
tier = Tier(
id = TierId(value = 3506),
isActive = false,
title = "Tier Title",
subtitle = "Tier Subtitle",
conditionInfo = TierConditionInfo.Visible.Price(
price = "$99.9", period = TierPeriod.Year(1)
),
features = listOf(),
membershipAnyName = TierAnyName.Visible.Enter,
buttonState = TierButton.Manage.Android.Enabled(""),
email = TierEmail.Visible.Enter,
color = "red",
stripeManageUrl = "",
iosManageUrl = "",
androidManageUrl = "",
androidProductId = "",
paymentMethod = MembershipPaymentMethod.METHOD_INAPP_GOOGLE
)
)
) { }
}

View file

@ -0,0 +1,84 @@
package com.anytypeio.anytype.payments.viewmodel
import androidx.annotation.StringRes
import com.anytypeio.anytype.core_models.membership.MembershipErrors
import com.anytypeio.anytype.payments.models.TierPreview
import com.anytypeio.anytype.payments.models.Tier
import com.anytypeio.anytype.presentation.membership.models.TierId
sealed class MembershipMainState {
data object Loading : MembershipMainState()
data class Default(
@StringRes val title: Int,
@StringRes val subtitle: Int?,
val showBanner: Boolean,
val tiersPreview: List<TierPreview>,
val tiers: List<Tier>,
val membershipLevelDetails: String,
val privacyPolicy: String,
val termsOfService: String,
val contactEmail: String
) : MembershipMainState()
data class ErrorState(val message: String) : MembershipMainState()
}
sealed class MembershipTierState {
data object Hidden : MembershipTierState()
data class Visible(val tier: Tier) : MembershipTierState() {
}
}
sealed class MembershipNameState {
data class Default(val name: String) : MembershipNameState()
data class Validating(val name: String) : MembershipNameState()
data class Validated(val name: String) : MembershipNameState()
data class Error(val name: String, val message: String) : MembershipNameState()
}
sealed class MembershipErrorState {
data object Hidden : MembershipErrorState()
data class Show(val message: String) : MembershipErrorState()
}
sealed class TierAction {
data class PayClicked(val tierId: TierId) : TierAction()
data object SubmitClicked : TierAction()
data class ManagePayment(val tierId: TierId) : TierAction()
data class OpenUrl(val url: String?) : TierAction()
data object OpenEmail : TierAction()
data object OnResendCodeClicked : TierAction()
data class OnVerifyCodeClicked(val code: String) : TierAction()
data object ChangeEmail : TierAction()
data class ContactUsError(val error: String): TierAction()
}
sealed class MembershipEmailCodeState {
data object Hidden : MembershipEmailCodeState()
sealed class Visible : MembershipEmailCodeState() {
data object Initial : Visible()
data object Loading : Visible()
data object Success : Visible()
data class Error(val error: MembershipErrors.VerifyEmailCode) : Visible()
data class ErrorOther(val message: String?) : Visible()
}
}
sealed class WelcomeState {
data object Hidden : WelcomeState()
data class Initial(val tier: Tier) : WelcomeState()
}
sealed class MembershipNavigation(val route: String) {
data object Main : MembershipNavigation("main")
data object Tier : MembershipNavigation("tier")
data object Code : MembershipNavigation("code")
data object Welcome : MembershipNavigation("welcome")
data object Dismiss : MembershipNavigation("")
data class OpenUrl(val url: String?) : MembershipNavigation("")
data class OpenEmail(val accountId: String?) : MembershipNavigation("")
data class OpenErrorEmail(val error: String, val accountId: String?) : MembershipNavigation("")
}

View file

@ -0,0 +1,732 @@
package com.anytypeio.anytype.payments.viewmodel
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.text2.input.TextFieldState
import androidx.compose.foundation.text2.input.clearText
import androidx.compose.foundation.text2.input.textAsFlow
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.core_models.membership.EmailVerificationStatus
import com.anytypeio.anytype.core_models.membership.MembershipErrors
import com.anytypeio.anytype.core_models.membership.MembershipPaymentMethod
import com.anytypeio.anytype.domain.auth.interactor.GetAccount
import com.anytypeio.anytype.domain.base.fold
import com.anytypeio.anytype.domain.payments.GetMembershipEmailStatus
import com.anytypeio.anytype.domain.payments.GetMembershipPaymentUrl
import com.anytypeio.anytype.domain.payments.IsMembershipNameValid
import com.anytypeio.anytype.domain.payments.SetMembershipEmail
import com.anytypeio.anytype.domain.payments.VerifyMembershipEmailCode
import com.anytypeio.anytype.payments.constants.MembershipConstants.EXPLORER_ID
import com.anytypeio.anytype.payments.constants.MembershipConstants.MEMBERSHIP_NAME_MIN_LENGTH
import com.anytypeio.anytype.payments.mapping.toMainView
import com.anytypeio.anytype.payments.models.TierAnyName
import com.anytypeio.anytype.payments.models.TierButton
import com.anytypeio.anytype.payments.models.TierEmail
import com.anytypeio.anytype.payments.models.Tier
import com.anytypeio.anytype.payments.playbilling.BillingClientLifecycle
import com.anytypeio.anytype.payments.playbilling.BillingClientState
import com.anytypeio.anytype.payments.playbilling.BillingPurchaseState
import com.anytypeio.anytype.presentation.membership.models.MembershipStatus
import com.anytypeio.anytype.presentation.membership.models.TierId
import com.anytypeio.anytype.presentation.membership.provider.MembershipProvider
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import timber.log.Timber
@OptIn(ExperimentalFoundationApi::class, FlowPreview::class)
class MembershipViewModel(
private val analytics: Analytics,
private val billingClientLifecycle: BillingClientLifecycle,
private val getAccount: GetAccount,
private val membershipProvider: MembershipProvider,
private val getMembershipPaymentUrl: GetMembershipPaymentUrl,
private val isMembershipNameValid: IsMembershipNameValid,
private val setMembershipEmail: SetMembershipEmail,
private val verifyMembershipEmailCode: VerifyMembershipEmailCode,
private val getMembershipEmailStatus: GetMembershipEmailStatus
) : ViewModel() {
val viewState = MutableStateFlow<MembershipMainState>(MembershipMainState.Loading)
val codeState = MutableStateFlow<MembershipEmailCodeState>(MembershipEmailCodeState.Hidden)
val tierState = MutableStateFlow<MembershipTierState>(MembershipTierState.Hidden)
val welcomeState = MutableStateFlow<WelcomeState>(WelcomeState.Hidden)
val errorState = MutableStateFlow<MembershipErrorState>(MembershipErrorState.Hidden)
val navigation = MutableSharedFlow<MembershipNavigation>(0)
/**
* Local billing purchase data.
*/
private val billingPurchases = billingClientLifecycle.subscriptionPurchases
/**
* ProductDetails for all known Products.
*/
private val billingProducts = billingClientLifecycle.builderSubProductWithProductDetails
private val _launchBillingCommand = MutableSharedFlow<BillingFlowParams>()
val launchBillingCommand = _launchBillingCommand.asSharedFlow()
val initBillingClient = MutableStateFlow(false)
@OptIn(ExperimentalFoundationApi::class)
val anyNameState = TextFieldState(initialText = "")
@OptIn(ExperimentalFoundationApi::class)
val anyEmailState = TextFieldState(initialText = "")
init {
viewModelScope.launch {
combine(
membershipProvider.status()
.onEach { setupBillingClient(it) },
billingProducts,
billingPurchases
) { membershipStatus, billingProducts, billingPurchases ->
Timber.d("TierResult: " +
"\n----------------------------\nmembershipStatus:[$membershipStatus]," +
"\n----------------------------\nbillingProducts:[$billingProducts]," +
"\n----------------------------\nbillingPurchases:[$billingPurchases]")
MainResult(membershipStatus, billingProducts, billingPurchases)
}.collect { (membershipStatus, billingClientState, purchases) ->
val newState = membershipStatus.toMainView(
billingClientState = billingClientState,
billingPurchaseState = purchases
)
proceedWithUpdatingVisibleTier(newState)
viewState.value = newState
}
}
viewModelScope.launch {
anyNameState.textAsFlow()
.debounce(NAME_VALIDATION_DELAY)
.collectLatest {
proceedWithValidatingName(it.toString())
}
}
viewModelScope.launch {
billingPurchases.collectLatest { billingPurchaseState ->
checkPurchaseStatus(billingPurchaseState)
}
}
}
private fun proceedWithUpdatingVisibleTier(mainState: MembershipMainState) {
val actualTier = tierState.value
if (actualTier is MembershipTierState.Visible && mainState is MembershipMainState.Default) {
val tierView = mainState.tiers.find { it.id == actualTier.tier.id } ?: return
tierState.value = MembershipTierState.Visible(tierView)
}
}
private fun checkPurchaseStatus(billingPurchaseState: BillingPurchaseState) {
if (billingPurchaseState is BillingPurchaseState.HasPurchases && billingPurchaseState.isNewPurchase) {
Timber.d("Billing purchase state: $billingPurchaseState")
//Got new purchase, show success screen
val tierView = (tierState.value as? MembershipTierState.Visible)?.tier ?: return
proceedWithHideTier()
welcomeState.value = WelcomeState.Initial(tierView)
proceedWithNavigation(MembershipNavigation.Welcome)
}
}
private fun setupBillingClient(membershipStatus: MembershipStatus) {
if (initBillingClient.value) {
Timber.d("Billing client already initialized")
return
}
val androidProductIds = membershipStatus.tiers.mapNotNull { it.androidProductId }
if (androidProductIds.isNotEmpty()) {
billingClientLifecycle.setupSubIds(androidProductIds)
initBillingClient.value = true
}
}
fun onTierClicked(tierId: TierId) {
Timber.d("onTierClicked: tierId:${tierId.value}")
proceedWithShowingTier(tierId)
}
private fun proceedWithShowingTier(tierId: TierId) {
val visibleTier = getTierById(tierId) ?: return
tierState.value = MembershipTierState.Visible(visibleTier)
proceedWithNavigation(MembershipNavigation.Tier)
}
private fun proceedWithHideTier() {
tierState.value = MembershipTierState.Hidden
anyEmailState.clearText()
anyNameState.clearText()
}
fun onTierAction(action: TierAction) {
Timber.d("onTierAction: action:$action")
when (action) {
is TierAction.PayClicked -> onPayButtonClicked(action.tierId)
is TierAction.ManagePayment -> onManageTierClicked(action.tierId)
is TierAction.OpenUrl -> {
proceedWithNavigation(MembershipNavigation.OpenUrl(action.url))
}
TierAction.OpenEmail -> proceedWithSupportEmail()
is TierAction.SubmitClicked -> {
proceedWithSettingEmail(
email = anyEmailState.text.toString(),
)
}
TierAction.OnResendCodeClicked -> {
proceedWithSettingEmail(
email = anyEmailState.text.toString(),
)
}
is TierAction.OnVerifyCodeClicked -> {
proceedWithValidatingEmailCode(action.code)
}
TierAction.ChangeEmail -> {
val tierView = (tierState.value as? MembershipTierState.Visible)?.tier ?: return
val updatedTierState = tierView.copy(
email = TierEmail.Visible.Enter,
buttonState = TierButton.Submit.Enabled
)
tierState.value = MembershipTierState.Visible(updatedTierState)
}
is TierAction.ContactUsError -> {
proceedWithSupportErrorEmail(action.error)
}
}
}
private fun proceedWithSupportEmail() {
viewModelScope.launch {
val anyId = getAccount.async(Unit).getOrNull()
proceedWithNavigation(MembershipNavigation.OpenEmail(anyId?.id))
}
}
private fun proceedWithSupportErrorEmail(error: String) {
viewModelScope.launch {
val anyId = getAccount.async(Unit).getOrNull()
proceedWithNavigation(
MembershipNavigation.OpenErrorEmail(
accountId = anyId?.id,
error = error
)
)
}
}
private var validateNameJob: Job? = null
private fun proceedWithValidatingName(name: String) {
val tierView = (tierState.value as? MembershipTierState.Visible)?.tier ?: return
if (name.length < MEMBERSHIP_NAME_MIN_LENGTH) {
when (tierView.membershipAnyName) {
is TierAnyName.Visible.Error -> setAnyNameStateToEnter(tierView)
is TierAnyName.Visible.Validated -> setAnyNameStateToEnter(tierView)
else -> {}
}
return
}
if (validateNameJob?.isActive == true) {
validateNameJob?.cancel()
}
setValidatingAnyNameState(tierView)
validateNameJob = viewModelScope.launch {
val params = IsMembershipNameValid.Params(
tier = tierView.id.value,
name = name
)
isMembershipNameValid.async(params).fold(
onSuccess = {
Timber.d("Name is valid")
setValidatedAnyNameState(tierView, name)
},
onFailure = { error ->
Timber.w("Error validating name: $error")
setErrorAnyNameState(tierView, error)
}
)
}
}
private fun setAnyNameStateToEnter(tier: Tier) {
val updatedTierState = tier.copy(
membershipAnyName = TierAnyName.Visible.Enter,
buttonState = TierButton.Pay.Disabled
)
tierState.value = MembershipTierState.Visible(updatedTierState)
}
private fun setValidatingAnyNameState(tier: Tier) {
val updatedTierState = tier.copy(
membershipAnyName = TierAnyName.Visible.Validating,
buttonState = TierButton.Pay.Disabled
)
tierState.value = MembershipTierState.Visible(updatedTierState)
}
private fun setErrorAnyNameState(tier: Tier, error: Throwable) {
val state = if (error is MembershipErrors) {
TierAnyName.Visible.Error(error)
} else {
TierAnyName.Visible.ErrorOther(error.message)
}
val updatedTierState = tier.copy(
membershipAnyName = state,
buttonState = TierButton.Pay.Disabled
)
tierState.value = MembershipTierState.Visible(updatedTierState)
}
private fun setValidatedAnyNameState(tier: Tier, validatedName: String) {
val updatedTierState = tier.copy(
membershipAnyName = TierAnyName.Visible.Validated(validatedName),
buttonState = TierButton.Pay.Enabled
)
tierState.value = MembershipTierState.Visible(updatedTierState)
}
private fun setErrorEmailCodeState(error: Throwable) {
val state = if (error is MembershipErrors.VerifyEmailCode) {
MembershipEmailCodeState.Visible.Error(error)
} else {
MembershipEmailCodeState.Visible.ErrorOther(error.message)
}
codeState.value = state
}
private fun setErrorSettingEmailState(error: Throwable) {
val tierView = (tierState.value as? MembershipTierState.Visible)?.tier ?: return
val state = if (error is MembershipErrors.GetVerificationEmail) {
TierEmail.Visible.Error(error)
} else {
TierEmail.Visible.ErrorOther(error.message)
}
val updatedTierState = tierView.copy(
email = state
)
tierState.value = MembershipTierState.Visible(updatedTierState)
}
private fun setValidatingEmailState() {
val tierView = (tierState.value as? MembershipTierState.Visible)?.tier ?: return
val updatedTierState = tierView.copy(
email = TierEmail.Visible.Validating
)
tierState.value = MembershipTierState.Visible(updatedTierState)
}
private fun setValidatedEmailState() {
val tierView = (tierState.value as? MembershipTierState.Visible)?.tier ?: return
val updatedTierState = tierView.copy(
email = TierEmail.Visible.Validated
)
tierState.value = MembershipTierState.Visible(updatedTierState)
}
private fun onManageTierClicked(tierId: TierId) {
val tier = getTierById(tierId) ?: return
Timber.d("Manage tier: $tier")
val navigationCommand = when (tier.paymentMethod) {
MembershipPaymentMethod.METHOD_NONE -> {
MembershipNavigation.OpenUrl(null)
}
MembershipPaymentMethod.METHOD_STRIPE -> {
MembershipNavigation.OpenUrl(tier.stripeManageUrl)
}
MembershipPaymentMethod.METHOD_CRYPTO -> {
MembershipNavigation.OpenUrl(null)
}
MembershipPaymentMethod.METHOD_INAPP_APPLE -> {
MembershipNavigation.OpenUrl(tier.iosManageUrl)
}
MembershipPaymentMethod.METHOD_INAPP_GOOGLE -> {
val androidProductId = tier.androidProductId
val url = if (androidProductId != null) {
//todo обернуть в функцию
"https://play.google.com/store/account/subscriptions?sku=$androidProductId&package=io.anytype.app"
} else {
null
}
MembershipNavigation.OpenUrl(url)
}
}
proceedWithNavigation(navigationCommand)
}
private fun onPayButtonClicked(tierId: TierId) {
proceedWithPurchase(tierId, anyNameState.text.toString())
}
private fun proceedWithPurchase(tierId: TierId, name: String) {
val tier = getTierById(tierId) ?: return
val androidProductId = tier.androidProductId
if (androidProductId == null) {
Timber.e("Tier ${tier.id} has no androidProductId")
return
}
val params = GetMembershipPaymentUrl.Params(
tierId = tier.id.value,
name = name
)
viewModelScope.launch {
getMembershipPaymentUrl.async(params).fold(
onSuccess = { response ->
Timber.d("Payment url: $response")
buyBasePlans(
billingId = response.billingId,
product = androidProductId
)
},
onFailure = { error ->
Timber.e("Error getting payment url: $error")
}
)
}
}
private fun proceedWithGettingEmailStatus() {
viewModelScope.launch {
getMembershipEmailStatus.async(Unit).fold(
onSuccess = { status ->
val tierView =
(tierState.value as? MembershipTierState.Visible)?.tier ?: return@fold
when (status) {
EmailVerificationStatus.STATUS_VERIFIED -> {
if (tierView.id.value == EXPLORER_ID) {
anyEmailState.clearText()
val updatedState = tierView.copy(
email = TierEmail.Hidden,
buttonState = TierButton.ChangeEmail
)
tierState.value = MembershipTierState.Visible(updatedState)
} else {
codeState.value = MembershipEmailCodeState.Visible.Initial
proceedWithNavigation(MembershipNavigation.Code)
}
}
else -> {}
}
Timber.d("Email status: $status")
},
onFailure = { error ->
Timber.e("Error getting email status: $error")
}
)
}
}
private fun proceedWithSettingEmail(email: String) {
setValidatingEmailState()
val params = SetMembershipEmail.Params(email, true)
viewModelScope.launch {
setMembershipEmail.async(params).fold(
onSuccess = {
Timber.d("Email set")
setValidatedEmailState()
codeState.value = MembershipEmailCodeState.Visible.Initial
proceedWithNavigation(MembershipNavigation.Code)
},
onFailure = { error ->
Timber.e("Error setting email: $error")
setErrorSettingEmailState(error)
}
)
}
}
private fun proceedWithValidatingEmailCode(code: String) {
codeState.value = MembershipEmailCodeState.Visible.Loading
viewModelScope.launch {
verifyMembershipEmailCode.async(VerifyMembershipEmailCode.Params(code)).fold(
onSuccess = {
Timber.d("Email code verified")
codeState.value = MembershipEmailCodeState.Visible.Success
delay(500)
proceedWithNavigation(MembershipNavigation.Dismiss)
proceedWithGettingEmailStatus()
},
onFailure = { error ->
Timber.e("Error verifying email code: $error")
setErrorEmailCodeState(error)
}
)
}
}
fun onDismissTier() {
Timber.d("onDismissTier")
proceedWithHideTier()
proceedWithNavigation(MembershipNavigation.Dismiss)
}
fun onDismissCode() {
Timber.d("onDismissCode")
proceedWithNavigation(MembershipNavigation.Dismiss)
codeState.value = MembershipEmailCodeState.Hidden
}
fun onDismissWelcome() {
Timber.d("onDismissWelcome")
proceedWithNavigation(MembershipNavigation.Dismiss)
welcomeState.value = WelcomeState.Hidden
}
private fun proceedWithNavigation(navigationCommand: MembershipNavigation) {
viewModelScope.launch {
navigation.emit(navigationCommand)
}
}
private fun getTierById(tierId: TierId): Tier? {
val membershipStatus = (viewState.value as? MembershipMainState.Default) ?: return null
return membershipStatus.tiers.find { it.id == tierId }
}
private fun showError(message: String) {
errorState.value = MembershipErrorState.Show(message)
}
fun hideError() {
errorState.value = MembershipErrorState.Hidden
}
//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.
*
*/
private fun buyBasePlans(billingId: String, product: String) {
Timber.d("buyBasePlans: billingId:$billingId, product:$product")
//todo check if the user has already purchased the product
val isProductOnServer = false//serverHasSubscription(subscriptions.value, product)
val billingPurchaseState = billingPurchases.value
val isProductOnDevice = if (billingPurchaseState is BillingPurchaseState.HasPurchases) {
deviceHasGooglePlaySubscription(billingPurchaseState.purchases, product)
} else {
false
}
Timber.d(
"Billing product:$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 =
(billingProducts.value as? BillingClientState.Connected)?.productDetails?.firstOrNull { it.productId == product }
?: run {
Timber.e("Could not find Basic product details by product id: $product")
errorState.value = MembershipErrorState.Show("Could not find Basic product details by product id: $product")
return
}
val offerToken: String? = builderSubProductDetails.subscriptionOfferDetails?.let { leastPricedOfferToken(it) }
if (offerToken.isNullOrEmpty()) {
Timber.e("Offer token for subscription is null or empty")
errorState.value = MembershipErrorState.Show("Offer token for subscription is null or empty, couldn't proceed")
return
}
launchFlow(
billingId = billingId,
offerToken = offerToken,
productDetails = 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<ProductDetails.SubscriptionOfferDetails>
): String {
var offerToken = ""
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
}
/**
* 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(
billingId: String,
productDetails: ProductDetails,
offerToken: String
) : BillingFlowParams? {
try {
val anyId = getAccount.async(Unit).getOrNull()
return BillingFlowParams.newBuilder()
.setObfuscatedAccountId(anyId?.id.orEmpty())
.setObfuscatedProfileId(billingId)
.setProductDetailsParamsList(
listOf(
BillingFlowParams.ProductDetailsParams.newBuilder()
.setProductDetails(productDetails)
.setOfferToken(offerToken)
.build()
)
).build()
} catch (e: Exception) {
showError(e.message ?: "Unknown error")
return null
}
}
/**
* 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 offerToken String representing the offer token of the lowest priced offer.
* @param productDetails ProductDetails of the product being purchased.
*
*/
private fun launchFlow(
billingId: String,
offerToken: String,
productDetails: ProductDetails
) {
val billingPurchaseState = billingPurchases.value
if (billingPurchaseState is BillingPurchaseState.HasPurchases && billingPurchaseState.purchases.size > EXPECTED_SUBSCRIPTION_PURCHASE_LIST_SIZE) {
errorState.value = MembershipErrorState.Show("There are more than one subscription purchases on the device.")
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."
)
}
viewModelScope.launch {
val billingParams: BillingFlowParams? = billingFlowParamsBuilder(
productDetails = productDetails,
offerToken = offerToken,
billingId = billingId
)
if (billingParams == null) {
Timber.e("Billing params is null")
errorState.value = MembershipErrorState.Show("Billing params is empty, couldn't proceed")
} else {
_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<Purchase>?, product: String) =
purchaseForProduct(purchases, product) != null
/**
* Return purchase for the provided Product, if it exists.
*/
private fun purchaseForProduct(purchases: List<Purchase>?, 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
const val NAME_VALIDATION_DELAY = 300L
}
}
data class MainResult(
val membershipStatus: MembershipStatus,
val billingClientState: BillingClientState,
val purchases: BillingPurchaseState
)

View file

@ -0,0 +1,41 @@
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.domain.payments.GetMembershipEmailStatus
import com.anytypeio.anytype.domain.payments.GetMembershipPaymentUrl
import com.anytypeio.anytype.domain.payments.IsMembershipNameValid
import com.anytypeio.anytype.domain.payments.SetMembershipEmail
import com.anytypeio.anytype.domain.payments.VerifyMembershipEmailCode
import com.anytypeio.anytype.payments.playbilling.BillingClientLifecycle
import com.anytypeio.anytype.presentation.membership.provider.MembershipProvider
import javax.inject.Inject
class MembershipViewModelFactory @Inject constructor(
private val analytics: Analytics,
private val billingClientLifecycle: BillingClientLifecycle,
private val getAccount: GetAccount,
private val membershipProvider: MembershipProvider,
private val getMembershipPaymentUrl: GetMembershipPaymentUrl,
private val isMembershipNameValid: IsMembershipNameValid,
private val setMembershipEmail: SetMembershipEmail,
private val verifyMembershipEmailCode: VerifyMembershipEmailCode,
private val getMembershipEmailStatus: GetMembershipEmailStatus
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return MembershipViewModel(
analytics = analytics,
billingClientLifecycle = billingClientLifecycle,
getAccount = getAccount,
membershipProvider = membershipProvider,
getMembershipPaymentUrl = getMembershipPaymentUrl,
isMembershipNameValid = isMembershipNameValid,
setMembershipEmail = setMembershipEmail,
verifyMembershipEmailCode = verifyMembershipEmailCode,
getMembershipEmailStatus = getMembershipEmailStatus
) as T
}
}

View file

@ -1,48 +0,0 @@
package com.anytypeio.anytype.payments.viewmodel
import com.anytypeio.anytype.presentation.membership.models.Tier
import com.anytypeio.anytype.presentation.membership.models.TierId
sealed class PaymentsMainState {
object Loading : PaymentsMainState()
data class Default(val tiers: List<Tier>) : PaymentsMainState()
data class PaymentSuccess(val tiers: List<Tier>) : PaymentsMainState()
}
sealed class PaymentsTierState {
object Hidden : PaymentsTierState()
sealed class Visible : PaymentsTierState() {
abstract val tier: Tier
data class Initial(override val tier: Tier) : Visible()
data class Subscribed(override val tier: Tier) : Visible()
}
}
sealed class PaymentsCodeState {
object Hidden : PaymentsCodeState()
sealed class Visible : PaymentsCodeState() {
abstract val tierId: TierId
data class Initial(override val tierId: TierId) : Visible()
data class Loading(override val tierId: TierId) : Visible()
data class Success(override val tierId: TierId) : Visible()
data class Error(override val tierId: TierId, val message: String) : Visible()
}
}
sealed class PaymentsWelcomeState {
object Hidden : PaymentsWelcomeState()
data class Initial(val tier: Tier) : PaymentsWelcomeState()
}
sealed class PaymentsNavigation(val route: String) {
object Main : PaymentsNavigation("main")
object Tier : PaymentsNavigation("tier")
object Code : PaymentsNavigation("code")
object Welcome : PaymentsNavigation("welcome")
object Dismiss : PaymentsNavigation("")
}

View file

@ -1,416 +0,0 @@
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.domain.base.fold
import com.anytypeio.anytype.domain.payments.GetMembershipTiers
import com.anytypeio.anytype.payments.constants.BillingConstants
import com.anytypeio.anytype.presentation.membership.models.Tier
import com.anytypeio.anytype.payments.playbilling.BillingClientLifecycle
import com.anytypeio.anytype.presentation.membership.models.TierId
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,
private val getMembershipTiers: GetMembershipTiers
) : ViewModel() {
val viewState = MutableStateFlow<PaymentsMainState>(PaymentsMainState.Loading)
val codeState = MutableStateFlow<PaymentsCodeState>(PaymentsCodeState.Hidden)
val tierState = MutableStateFlow<PaymentsTierState>(PaymentsTierState.Hidden)
val welcomeState = MutableStateFlow<PaymentsWelcomeState>(PaymentsWelcomeState.Hidden)
val command = MutableStateFlow<PaymentsNavigation?>(null)
private val _tiers = mutableListOf<Tier>()
var activeTierName: MutableStateFlow<String?> = 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<BillingFlowParams>()
val launchBillingCommand = _launchBillingCommand.asSharedFlow()
init {
Timber.d("PaymentsViewModel init")
proceedWithGetTiers()
setupActiveTierName()
}
private fun proceedWithGetTiers() {
viewModelScope.launch {
getMembershipTiers.async(GetMembershipTiers.Params("en", false)).fold(
onSuccess = { result ->
///todo handle the result
},
onFailure = Timber::e
)
}
}
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<Tier> {
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<ProductDetails.SubscriptionOfferDetails>
): 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<ProductDetails.SubscriptionOfferDetails>, tag: String
):
List<ProductDetails.SubscriptionOfferDetails> {
val eligibleOffers = emptyList<ProductDetails.SubscriptionOfferDetails>().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<Purchase>?, product: String) =
purchaseForProduct(purchases, product) != null
/**
* Return purchase for the provided Product, if it exists.
*/
private fun purchaseForProduct(purchases: List<Purchase>?, 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
}
}

View file

@ -1,26 +0,0 @@
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.domain.payments.GetMembershipTiers
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,
private val getMembershipTiers: GetMembershipTiers
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return PaymentsViewModel(
analytics = analytics,
billingClientLifecycle = billingClientLifecycle,
getAccount = getAccount,
getMembershipTiers = getMembershipTiers
) as T
}
}

View file

@ -0,0 +1,169 @@
package com.anytypeio.anytype.payments
import com.android.billingclient.api.ProductDetails
import com.anytypeio.anytype.analytics.base.Analytics
import com.anytypeio.anytype.core_models.membership.Membership
import com.anytypeio.anytype.core_models.membership.MembershipPaymentMethod
import com.anytypeio.anytype.core_models.membership.MembershipTierData
import com.anytypeio.anytype.domain.auth.interactor.GetAccount
import com.anytypeio.anytype.domain.base.AppCoroutineDispatchers
import com.anytypeio.anytype.domain.block.repo.BlockRepository
import com.anytypeio.anytype.domain.payments.GetMembershipEmailStatus
import com.anytypeio.anytype.domain.payments.GetMembershipPaymentUrl
import com.anytypeio.anytype.domain.payments.IsMembershipNameValid
import com.anytypeio.anytype.domain.payments.SetMembershipEmail
import com.anytypeio.anytype.domain.payments.VerifyMembershipEmailCode
import com.anytypeio.anytype.payments.constants.MembershipConstants
import com.anytypeio.anytype.payments.models.TierAnyName
import com.anytypeio.anytype.payments.models.TierButton
import com.anytypeio.anytype.payments.models.TierConditionInfo
import com.anytypeio.anytype.payments.models.TierEmail
import com.anytypeio.anytype.payments.models.Tier
import com.anytypeio.anytype.payments.playbilling.BillingClientLifecycle
import com.anytypeio.anytype.payments.playbilling.BillingClientState
import com.anytypeio.anytype.payments.playbilling.BillingPurchaseState
import com.anytypeio.anytype.payments.viewmodel.MembershipViewModel
import com.anytypeio.anytype.presentation.membership.models.MembershipStatus
import com.anytypeio.anytype.presentation.membership.models.TierId
import com.anytypeio.anytype.presentation.membership.provider.MembershipProvider
import junit.framework.TestCase.assertEquals
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestCoroutineScheduler
import kotlinx.coroutines.test.setMain
import net.bytebuddy.utility.RandomString
import org.junit.Before
import org.mockito.kotlin.stub
import org.mockito.Mock
import org.mockito.Mockito
import org.mockito.MockitoAnnotations
open class MembershipTestsSetup {
val dispatcher = StandardTestDispatcher(TestCoroutineScheduler())
@OptIn(ExperimentalCoroutinesApi::class)
val dispatchers = AppCoroutineDispatchers(
io = dispatcher,
main = dispatcher,
computation = dispatcher
).also { Dispatchers.setMain(dispatcher) }
@Mock
lateinit var analytics: Analytics
@Mock
lateinit var repo: BlockRepository
@Mock
lateinit var billingClientLifecycle: BillingClientLifecycle
@Mock
lateinit var getAccount: GetAccount
@Mock
lateinit var membershipProvider: MembershipProvider
@Mock
lateinit var isMembershipNameValid: IsMembershipNameValid
@Mock
lateinit var setMembershipEmail: SetMembershipEmail
@Mock
lateinit var verifyMembershipEmailCode: VerifyMembershipEmailCode
@Mock
lateinit var getMembershipEmailStatus: GetMembershipEmailStatus
@Mock
lateinit var getMembershipPaymentUrl: GetMembershipPaymentUrl
protected val androidProductId = "id_android_builder"
fun membershipStatus(tiers: List<MembershipTierData>) = MembershipStatus(
activeTier = TierId(MembershipConstants.EXPLORER_ID),
status = Membership.Status.STATUS_ACTIVE,
dateEnds = 1714199910,
paymentMethod = MembershipPaymentMethod.METHOD_NONE,
anyName = "",
tiers = tiers,
formattedDateEnds = "formattedDateEnds-${RandomString.make()}"
)
@Before
open fun setUp() {
MockitoAnnotations.openMocks(this)
}
protected fun validateTierView(
expectedId: Int,
expectedActive: Boolean,
expectedFeatures: List<String>,
expectedConditionInfo: TierConditionInfo.Visible,
expectedAnyName: TierAnyName,
expectedButtonState: TierButton,
expectedEmailState: TierEmail,
tier: Tier
) {
assertEquals(expectedId, tier.id.value)
assertEquals("is Active", expectedActive, tier.isActive)
assertEquals("Features", expectedFeatures, tier.features)
assertEquals("Condition info", expectedConditionInfo, tier.conditionInfo)
assertEquals("Any name", expectedAnyName, tier.membershipAnyName, )
assertEquals("Button state", expectedButtonState, tier.buttonState)
assertEquals("Email state", expectedEmailState, tier.email)
}
protected fun stubMembershipProvider(membershipStatus: MembershipStatus?) {
val flow = if (membershipStatus == null) {
emptyFlow()
} else {
flow {
emit(membershipStatus)
}
}
membershipProvider.stub {
onBlocking { status() }.thenReturn(flow)
}
}
protected fun stubBilling() {
val p = Mockito.mock(ProductDetails::class.java)
val billingState = BillingClientState.Connected(listOf(p))
billingClientLifecycle.stub {
onBlocking { builderSubProductWithProductDetails }.thenReturn(
MutableStateFlow(billingState)
)
}
}
protected fun stubBilling(billingClientState: BillingClientState) {
billingClientLifecycle.stub {
onBlocking { builderSubProductWithProductDetails }.thenReturn(
MutableStateFlow(billingClientState)
)
}
}
protected fun stubPurchaseState(purchaseState: BillingPurchaseState = BillingPurchaseState.NoPurchases) {
billingClientLifecycle.stub {
onBlocking { subscriptionPurchases }.thenReturn(MutableStateFlow(purchaseState))
}
}
protected fun buildViewModel() = MembershipViewModel(
analytics = analytics,
billingClientLifecycle = billingClientLifecycle,
getAccount = getAccount,
membershipProvider = membershipProvider,
getMembershipPaymentUrl = getMembershipPaymentUrl,
isMembershipNameValid = isMembershipNameValid,
setMembershipEmail = setMembershipEmail,
verifyMembershipEmailCode = verifyMembershipEmailCode,
getMembershipEmailStatus = getMembershipEmailStatus,
)
}

View file

@ -0,0 +1,204 @@
package com.anytypeio.anytype.payments
import app.cash.turbine.turbineScope
import com.anytypeio.anytype.core_models.membership.Membership
import com.anytypeio.anytype.core_models.membership.MembershipPaymentMethod
import com.anytypeio.anytype.payments.constants.MembershipConstants.ACTIVE_TIERS_WITH_BANNERS
import com.anytypeio.anytype.payments.constants.MembershipConstants.BUILDER_ID
import com.anytypeio.anytype.payments.constants.MembershipConstants.CO_CREATOR_ID
import com.anytypeio.anytype.payments.constants.MembershipConstants.EXPLORER_ID
import com.anytypeio.anytype.payments.viewmodel.MembershipMainState
import com.anytypeio.anytype.payments.viewmodel.MembershipTierState
import com.anytypeio.anytype.payments.viewmodel.MembershipEmailCodeState
import com.anytypeio.anytype.payments.viewmodel.MembershipErrorState
import com.anytypeio.anytype.payments.viewmodel.WelcomeState
import com.anytypeio.anytype.presentation.membership.models.MembershipStatus
import com.anytypeio.anytype.presentation.membership.models.TierId
import kotlin.test.assertFalse
import kotlin.test.assertIs
import kotlin.test.assertTrue
import kotlinx.coroutines.test.runTest
import net.bytebuddy.utility.RandomString
import org.junit.Test
import org.mockito.kotlin.times
import org.mockito.kotlin.verify
class MembershipViewModelTest : MembershipTestsSetup() {
private val mTiers = listOf(
StubMembershipTierData(
id = EXPLORER_ID,
),
StubMembershipTierData(
id = BUILDER_ID,
androidProductId = androidProductId
),
StubMembershipTierData(
id = CO_CREATOR_ID
)
)
@Test
fun `should be in loading state before first members status`() = runTest {
turbineScope {
stubMembershipProvider(null)
stubBilling()
stubPurchaseState()
val viewModel = buildViewModel()
val errorFlow = viewModel.errorState.testIn(backgroundScope)
val viewStateFlow = viewModel.viewState.testIn(backgroundScope)
val tierStateFlow = viewModel.tierState.testIn(backgroundScope)
val welcomeStateFlow = viewModel.welcomeState.testIn(backgroundScope)
val codeStateFlow = viewModel.codeState.testIn(backgroundScope)
assertIs<MembershipErrorState.Hidden>(errorFlow.awaitItem())
assertIs<MembershipMainState.Loading>(viewStateFlow.awaitItem())
assertIs<MembershipTierState.Hidden>(tierStateFlow.awaitItem())
assertIs<WelcomeState.Hidden>(welcomeStateFlow.awaitItem())
assertIs<MembershipEmailCodeState.Hidden>(codeStateFlow.awaitItem())
viewStateFlow.ensureAllEventsConsumed()
errorFlow.ensureAllEventsConsumed()
tierStateFlow.ensureAllEventsConsumed()
welcomeStateFlow.ensureAllEventsConsumed()
codeStateFlow.ensureAllEventsConsumed()
}
}
@Test
fun `should init billing after getting members status`() = runTest {
turbineScope {
stubMembershipProvider(membershipStatus(mTiers))
stubBilling()
stubPurchaseState()
val viewModel = buildViewModel()
val initBillingFlow = viewModel.initBillingClient.testIn(backgroundScope)
assertFalse(initBillingFlow.awaitItem())
assertTrue(initBillingFlow.awaitItem())
initBillingFlow.ensureAllEventsConsumed()
verify(billingClientLifecycle, times(1)).setupSubIds(listOf(androidProductId))
}
}
@Test
fun `should not billing if no android id is presented`() = runTest {
turbineScope {
stubMembershipProvider(
membershipStatus(
listOf(
StubMembershipTierData(
id = EXPLORER_ID,
),
StubMembershipTierData(
id = BUILDER_ID,
),
StubMembershipTierData(
id = CO_CREATOR_ID
)
)
)
)
stubBilling()
stubPurchaseState()
val viewModel = buildViewModel()
val initBillingFlow = viewModel.initBillingClient.testIn(backgroundScope)
assertFalse(initBillingFlow.awaitItem())
initBillingFlow.ensureAllEventsConsumed()
}
}
@Test
fun `should show with banner when active tiers are none or explorer`() = runTest {
turbineScope {
stubMembershipProvider(
MembershipStatus(
activeTier = TierId(ACTIVE_TIERS_WITH_BANNERS.random()),
status = Membership.Status.STATUS_ACTIVE,
dateEnds = 1714199910,
paymentMethod = MembershipPaymentMethod.METHOD_NONE,
anyName = "",
tiers = mTiers,
formattedDateEnds = "formattedDateEnds-${RandomString.make()}"
)
)
stubBilling()
stubPurchaseState()
val viewModel = buildViewModel()
val errorFlow = viewModel.errorState.testIn(backgroundScope)
val viewStateFlow = viewModel.viewState.testIn(backgroundScope)
val tierStateFlow = viewModel.tierState.testIn(backgroundScope)
val welcomeStateFlow = viewModel.welcomeState.testIn(backgroundScope)
val codeStateFlow = viewModel.codeState.testIn(backgroundScope)
assertIs<MembershipMainState.Loading>(viewStateFlow.awaitItem())
assertIs<MembershipErrorState.Hidden>(errorFlow.awaitItem())
assertIs<MembershipTierState.Hidden>(tierStateFlow.awaitItem())
assertIs<WelcomeState.Hidden>(welcomeStateFlow.awaitItem())
assertIs<MembershipEmailCodeState.Hidden>(codeStateFlow.awaitItem())
val result = viewStateFlow.awaitItem()
assertIs<MembershipMainState.Default>(result)
assertTrue(result.showBanner)
viewStateFlow.ensureAllEventsConsumed()
errorFlow.ensureAllEventsConsumed()
tierStateFlow.ensureAllEventsConsumed()
welcomeStateFlow.ensureAllEventsConsumed()
codeStateFlow.ensureAllEventsConsumed()
}
}
@Test
fun `should don't show banner when active tiers are builder or co-creator or else`() = runTest {
turbineScope {
val tierId = listOf(2, 3, BUILDER_ID, CO_CREATOR_ID, 6, 7, 8, 9, 10).random()
stubMembershipProvider(
MembershipStatus(
activeTier = TierId(tierId),
status = Membership.Status.STATUS_ACTIVE,
dateEnds = 1714199910,
paymentMethod = MembershipPaymentMethod.METHOD_NONE,
anyName = "",
tiers = mTiers,
formattedDateEnds = "formattedDateEnds-${RandomString.make()}"
)
)
stubBilling()
stubPurchaseState()
val viewModel = buildViewModel()
val errorFlow = viewModel.errorState.testIn(backgroundScope)
val viewStateFlow = viewModel.viewState.testIn(backgroundScope)
val tierStateFlow = viewModel.tierState.testIn(backgroundScope)
val welcomeStateFlow = viewModel.welcomeState.testIn(backgroundScope)
val codeStateFlow = viewModel.codeState.testIn(backgroundScope)
assertIs<MembershipMainState.Loading>(viewStateFlow.awaitItem())
assertIs<MembershipErrorState.Hidden>(errorFlow.awaitItem())
assertIs<MembershipTierState.Hidden>(tierStateFlow.awaitItem())
assertIs<WelcomeState.Hidden>(welcomeStateFlow.awaitItem())
assertIs<MembershipEmailCodeState.Hidden>(codeStateFlow.awaitItem())
val result = viewStateFlow.awaitItem()
assertIs<MembershipMainState.Default>(result)
assertFalse(result.showBanner)
viewStateFlow.ensureAllEventsConsumed()
errorFlow.ensureAllEventsConsumed()
tierStateFlow.ensureAllEventsConsumed()
welcomeStateFlow.ensureAllEventsConsumed()
codeStateFlow.ensureAllEventsConsumed()
}
}
}

View file

@ -0,0 +1,45 @@
package com.anytypeio.anytype.payments
import com.anytypeio.anytype.core_models.membership.MembershipPeriodType
import com.anytypeio.anytype.core_models.membership.MembershipTierData
import kotlin.random.Random
import net.bytebuddy.utility.RandomString
fun StubMembershipTierData(
id: Int = Random.nextInt(),
name: String = "name-${RandomString.make()}",
description: String = "description-${RandomString.make()}",
isTest: Boolean = false,
periodType: MembershipPeriodType = MembershipPeriodType.PERIOD_TYPE_YEARS,
periodValue: Int = 0,
priceStripeUsdCents: Int = 0,
anyNamesCountIncluded: Int = Random.nextInt(),
anyNameMinLength: Int = Random.nextInt(),
features: List<String> = emptyList(),
colorStr: String = "colorStr-${RandomString.make()}",
stripeProductId: String? = null,
stripeManageUrl: String? = null,
iosProductId: String? = null,
iosManageUrl: String? = null,
androidProductId: String? = null,
androidManageUrl: String? = null
): MembershipTierData = MembershipTierData(
id = id,
name = name,
description = description,
isTest = isTest,
periodType = periodType,
periodValue = periodValue,
priceStripeUsdCents = priceStripeUsdCents,
anyNamesCountIncluded = anyNamesCountIncluded,
anyNameMinLength = anyNameMinLength,
features = features,
colorStr = colorStr,
stripeProductId = stripeProductId,
stripeManageUrl = stripeManageUrl,
iosProductId = iosProductId,
iosManageUrl = iosManageUrl,
androidProductId = androidProductId,
androidManageUrl = androidManageUrl
)

View file

@ -0,0 +1,176 @@
package com.anytypeio.anytype.payments
import app.cash.turbine.turbineScope
import com.anytypeio.anytype.core_models.membership.Membership
import com.anytypeio.anytype.core_models.membership.MembershipPaymentMethod
import com.anytypeio.anytype.core_models.membership.MembershipPeriodType
import com.anytypeio.anytype.core_models.membership.MembershipTierData
import com.anytypeio.anytype.payments.constants.MembershipConstants
import com.anytypeio.anytype.payments.models.TierAnyName
import com.anytypeio.anytype.payments.models.TierButton
import com.anytypeio.anytype.payments.models.TierConditionInfo
import com.anytypeio.anytype.payments.models.TierEmail
import com.anytypeio.anytype.payments.models.TierPeriod
import com.anytypeio.anytype.payments.models.TierPreview
import com.anytypeio.anytype.payments.playbilling.BillingClientState
import com.anytypeio.anytype.payments.viewmodel.MembershipMainState
import com.anytypeio.anytype.payments.viewmodel.MembershipTierState
import com.anytypeio.anytype.presentation.membership.models.MembershipStatus
import com.anytypeio.anytype.presentation.membership.models.TierId
import junit.framework.TestCase
import kotlin.test.assertIs
import kotlinx.coroutines.test.runTest
import net.bytebuddy.utility.RandomString
import org.junit.Test
/**
* Tier - active and free | without androidId
* TierPreview = [Title|Subtitle|ConditionInfo.Valid]
* Tier = [Title|Subtitle|Features|ConditionInfo.Valid|TierEmail|ButtonSubmit ot ButtonChange]
*/
class TierActiveAndFreeTests : MembershipTestsSetup() {
private fun commonTestSetup(): Pair<List<String>, List<MembershipTierData>> {
val features = listOf("feature-${RandomString.make()}", "feature-${RandomString.make()}")
val tiers = setupTierData(features)
return Pair(features, tiers)
}
private fun setupTierData(features: List<String>): List<MembershipTierData> {
return listOf(
StubMembershipTierData(
id = MembershipConstants.EXPLORER_ID,
androidProductId = null,
features = features,
periodType = MembershipPeriodType.PERIOD_TYPE_UNLIMITED,
priceStripeUsdCents = 0,
)
)
}
private fun setupMembershipStatus(
tiers: List<MembershipTierData>,
email: String
): MembershipStatus {
return MembershipStatus(
activeTier = TierId(MembershipConstants.EXPLORER_ID),
status = Membership.Status.STATUS_ACTIVE,
dateEnds = 0,
paymentMethod = MembershipPaymentMethod.METHOD_NONE,
anyName = "",
tiers = tiers,
formattedDateEnds = "formattedDateEnds-${RandomString.make()}",
userEmail = email
)
}
@Test
fun `when free plan is active, but without email, show email form`() = runTest {
turbineScope {
val (features, tiers) = commonTestSetup()
stubPurchaseState()
stubBilling(billingClientState = BillingClientState.Loading)
stubMembershipProvider(setupMembershipStatus(tiers, ""))
val viewModel = buildViewModel()
val viewStateFlow = viewModel.viewState.testIn(backgroundScope)
val tierStateFlow = viewModel.tierState.testIn(backgroundScope)
assertIs<MembershipMainState.Loading>(viewStateFlow.awaitItem())
assertIs<MembershipTierState.Hidden>(tierStateFlow.awaitItem())
viewStateFlow.awaitItem().let { result ->
assertIs<MembershipMainState.Default>(result)
val tier: TierPreview =
result.tiersPreview.find { it.id.value == MembershipConstants.EXPLORER_ID }!!
TestCase.assertEquals(MembershipConstants.EXPLORER_ID, tier.id.value)
TestCase.assertEquals(true, tier.isActive)
TestCase.assertEquals(
TierConditionInfo.Visible.Valid(
period = TierPeriod.Unlimited,
dateEnds = 0,
payedBy = MembershipPaymentMethod.METHOD_NONE
),
tier.conditionInfo
)
}
viewModel.onTierClicked(TierId(MembershipConstants.EXPLORER_ID))
//STATE : EXPLORER, CURRENT, WITHOUT EMAIL
tierStateFlow.awaitItem().let { result ->
assertIs<MembershipTierState.Visible>(result)
validateTierView(
tier = result.tier,
expectedFeatures = features,
expectedConditionInfo = TierConditionInfo.Visible.Valid(
period = TierPeriod.Unlimited,
dateEnds = 0,
payedBy = MembershipPaymentMethod.METHOD_NONE
),
expectedAnyName = TierAnyName.Hidden,
expectedButtonState = TierButton.Submit.Enabled,
expectedId = MembershipConstants.EXPLORER_ID,
expectedActive = true,
expectedEmailState = TierEmail.Visible.Enter
)
}
}
}
@Test
fun `when free plan is active, with email, don't show email form`() = runTest {
turbineScope {
val (features, tiers) = commonTestSetup()
stubPurchaseState()
stubBilling(billingClientState = BillingClientState.Loading)
stubMembershipProvider(setupMembershipStatus(tiers, "test@any.io"))
val viewModel = buildViewModel()
val viewStateFlow = viewModel.viewState.testIn(backgroundScope)
val tierStateFlow = viewModel.tierState.testIn(backgroundScope)
assertIs<MembershipMainState.Loading>(viewStateFlow.awaitItem())
assertIs<MembershipTierState.Hidden>(tierStateFlow.awaitItem())
viewStateFlow.awaitItem().let { result ->
assertIs<MembershipMainState.Default>(result)
val tier: TierPreview =
result.tiersPreview.find { it.id.value == MembershipConstants.EXPLORER_ID }!!
TestCase.assertEquals(MembershipConstants.EXPLORER_ID, tier.id.value)
TestCase.assertEquals(true, tier.isActive)
TestCase.assertEquals(
TierConditionInfo.Visible.Valid(
period = TierPeriod.Unlimited,
dateEnds = 0,
payedBy = MembershipPaymentMethod.METHOD_NONE
),
tier.conditionInfo
)
}
viewModel.onTierClicked(TierId(MembershipConstants.EXPLORER_ID))
//STATE : EXPLORER, CURRENT, WITHOUT EMAIL
tierStateFlow.awaitItem().let { result ->
assertIs<MembershipTierState.Visible>(result)
validateTierView(
tier = result.tier,
expectedFeatures = features,
expectedConditionInfo = TierConditionInfo.Visible.Valid(
period = TierPeriod.Unlimited,
dateEnds = 0,
payedBy = MembershipPaymentMethod.METHOD_NONE
),
expectedAnyName = TierAnyName.Hidden,
expectedButtonState = TierButton.ChangeEmail,
expectedId = MembershipConstants.EXPLORER_ID,
expectedActive = true,
expectedEmailState = TierEmail.Hidden
)
}
}
}
}

View file

@ -0,0 +1,210 @@
package com.anytypeio.anytype.payments
import app.cash.turbine.turbineScope
import com.anytypeio.anytype.core_models.membership.Membership
import com.anytypeio.anytype.core_models.membership.MembershipPaymentMethod
import com.anytypeio.anytype.core_models.membership.MembershipPeriodType
import com.anytypeio.anytype.core_models.membership.MembershipTierData
import com.anytypeio.anytype.payments.constants.MembershipConstants
import com.anytypeio.anytype.payments.models.TierAnyName
import com.anytypeio.anytype.payments.models.TierButton
import com.anytypeio.anytype.payments.models.TierConditionInfo
import com.anytypeio.anytype.payments.models.TierEmail
import com.anytypeio.anytype.payments.models.TierPeriod
import com.anytypeio.anytype.payments.models.TierPreview
import com.anytypeio.anytype.payments.playbilling.BillingClientState
import com.anytypeio.anytype.payments.viewmodel.MembershipMainState
import com.anytypeio.anytype.payments.viewmodel.MembershipTierState
import com.anytypeio.anytype.presentation.membership.models.MembershipStatus
import com.anytypeio.anytype.presentation.membership.models.TierId
import junit.framework.TestCase
import kotlin.test.assertIs
import kotlinx.coroutines.test.runTest
import net.bytebuddy.utility.RandomString
import org.junit.Test
/**
* Tier - active and non free | without androidId | purchased through ios
*
*/
class TierActivePurchasedOniOSTests : MembershipTestsSetup() {
// Date when the membership ends
private val dateEnds = 1714199910L
// URL for managing iOS payments
private val iosManageUrl = "iosManageUrl-${RandomString.make()}"
private fun commonTestSetup(): Pair<List<String>, List<MembershipTierData>> {
val features = listOf("feature-${RandomString.make()}", "feature-${RandomString.make()}")
val tiers = setupTierData(features)
return Pair(features, tiers)
}
private fun setupTierData(features: List<String>): List<MembershipTierData> {
return listOf(
StubMembershipTierData(
id = MembershipConstants.EXPLORER_ID,
androidProductId = null,
features = features,
periodType = MembershipPeriodType.PERIOD_TYPE_UNLIMITED,
priceStripeUsdCents = 0
),
StubMembershipTierData(
id = MembershipConstants.BUILDER_ID,
features = features,
periodValue = 1,
periodType = MembershipPeriodType.PERIOD_TYPE_YEARS,
priceStripeUsdCents = 9900,
iosManageUrl = iosManageUrl
),
StubMembershipTierData(
id = MembershipConstants.CO_CREATOR_ID,
androidProductId = null,
features = features,
periodValue = 3,
periodType = MembershipPeriodType.PERIOD_TYPE_YEARS,
priceStripeUsdCents = 29900
),
StubMembershipTierData(
id = 22,
androidProductId = null,
features = features,
periodValue = 1,
periodType = MembershipPeriodType.PERIOD_TYPE_MONTHS,
priceStripeUsdCents = 1000
)
)
}
private fun setupMembershipStatus(tiers: List<MembershipTierData>): MembershipStatus {
return MembershipStatus(
activeTier = TierId(MembershipConstants.BUILDER_ID),
status = Membership.Status.STATUS_ACTIVE,
dateEnds = dateEnds,
paymentMethod = MembershipPaymentMethod.METHOD_INAPP_APPLE,
anyName = "TestAnyName",
tiers = tiers,
formattedDateEnds = "formattedDateEnds-${RandomString.make()}"
)
}
@Test
fun `when payed plan is active, show proper valid and enabled manage button`() = runTest {
turbineScope {
val (features, tiers) = commonTestSetup()
stubPurchaseState()
stubBilling(billingClientState = BillingClientState.Loading)
stubMembershipProvider(setupMembershipStatus(tiers))
val viewModel = buildViewModel()
val viewStateFlow = viewModel.viewState.testIn(backgroundScope)
val tierStateFlow = viewModel.tierState.testIn(backgroundScope)
assertIs<MembershipMainState.Loading>(viewStateFlow.awaitItem())
assertIs<MembershipTierState.Hidden>(tierStateFlow.awaitItem())
viewStateFlow.awaitItem().let { result ->
assertIs<MembershipMainState.Default>(result)
val tier: TierPreview =
result.tiersPreview.find { it.id.value == MembershipConstants.BUILDER_ID }!!
TestCase.assertEquals(MembershipConstants.BUILDER_ID, tier.id.value)
TestCase.assertEquals(true, tier.isActive)
TestCase.assertEquals(
TierConditionInfo.Visible.Valid(
dateEnds = dateEnds,
payedBy = MembershipPaymentMethod.METHOD_INAPP_APPLE,
period = TierPeriod.Year(1),
),
tier.conditionInfo
)
}
viewModel.onTierClicked(TierId(MembershipConstants.BUILDER_ID))
tierStateFlow.awaitItem().let { result ->
assertIs<MembershipTierState.Visible>(result)
validateTierView(
tier = result.tier,
expectedFeatures = features,
expectedConditionInfo = TierConditionInfo.Visible.Valid(
dateEnds = dateEnds,
payedBy = MembershipPaymentMethod.METHOD_INAPP_APPLE,
period = TierPeriod.Year(1),
),
expectedAnyName = TierAnyName.Hidden,
expectedButtonState = TierButton.Manage.External.Enabled(iosManageUrl),
expectedId = MembershipConstants.BUILDER_ID,
expectedActive = true,
expectedEmailState = TierEmail.Hidden
)
}
}
}
@Test
fun `when payed plan is active from crypto, show proper valid and hide manage button`() =
runTest {
turbineScope {
val (features, tiers) = commonTestSetup()
stubPurchaseState()
stubBilling(billingClientState = BillingClientState.Loading)
stubMembershipProvider(
MembershipStatus(
activeTier = TierId(MembershipConstants.BUILDER_ID),
status = Membership.Status.STATUS_ACTIVE,
dateEnds = dateEnds,
paymentMethod = MembershipPaymentMethod.METHOD_CRYPTO,
anyName = "TestAnyName",
tiers = tiers,
formattedDateEnds = "formattedDateEnds-${RandomString.make()}"
)
)
val viewModel = buildViewModel()
val viewStateFlow = viewModel.viewState.testIn(backgroundScope)
val tierStateFlow = viewModel.tierState.testIn(backgroundScope)
assertIs<MembershipMainState.Loading>(viewStateFlow.awaitItem())
assertIs<MembershipTierState.Hidden>(tierStateFlow.awaitItem())
viewStateFlow.awaitItem().let { result ->
assertIs<MembershipMainState.Default>(result)
val tier: TierPreview =
result.tiersPreview.find { it.id.value == MembershipConstants.BUILDER_ID }!!
TestCase.assertEquals(MembershipConstants.BUILDER_ID, tier.id.value)
TestCase.assertEquals(true, tier.isActive)
TestCase.assertEquals(
TierConditionInfo.Visible.Valid(
dateEnds = dateEnds,
payedBy = MembershipPaymentMethod.METHOD_CRYPTO,
period = TierPeriod.Year(1),
),
tier.conditionInfo
)
}
viewModel.onTierClicked(TierId(MembershipConstants.BUILDER_ID))
tierStateFlow.awaitItem().let { result ->
assertIs<MembershipTierState.Visible>(result)
validateTierView(
tier = result.tier,
expectedFeatures = features,
expectedConditionInfo = TierConditionInfo.Visible.Valid(
dateEnds = dateEnds,
payedBy = MembershipPaymentMethod.METHOD_CRYPTO,
period = TierPeriod.Year(1),
),
expectedAnyName = TierAnyName.Hidden,
expectedButtonState = TierButton.Hidden,
expectedId = MembershipConstants.BUILDER_ID,
expectedActive = true,
expectedEmailState = TierEmail.Hidden
)
}
}
}
}

View file

@ -0,0 +1,212 @@
package com.anytypeio.anytype.payments
import app.cash.turbine.turbineScope
import com.anytypeio.anytype.core_models.membership.Membership
import com.anytypeio.anytype.core_models.membership.MembershipPaymentMethod
import com.anytypeio.anytype.core_models.membership.MembershipPeriodType
import com.anytypeio.anytype.core_models.membership.MembershipTierData
import com.anytypeio.anytype.payments.constants.MembershipConstants
import com.anytypeio.anytype.payments.models.TierAnyName
import com.anytypeio.anytype.payments.models.TierButton
import com.anytypeio.anytype.payments.models.TierConditionInfo
import com.anytypeio.anytype.payments.models.TierEmail
import com.anytypeio.anytype.payments.models.TierPeriod
import com.anytypeio.anytype.payments.models.TierPreview
import com.anytypeio.anytype.payments.playbilling.BillingClientState
import com.anytypeio.anytype.payments.viewmodel.MembershipMainState
import com.anytypeio.anytype.payments.viewmodel.MembershipTierState
import com.anytypeio.anytype.presentation.membership.models.MembershipStatus
import com.anytypeio.anytype.presentation.membership.models.TierId
import junit.framework.TestCase
import kotlin.test.assertIs
import kotlinx.coroutines.test.runTest
import net.bytebuddy.utility.RandomString
import org.junit.Test
/**
* Tier - active and non free | with androidId | purchased through ios
*
*/
class TierAndroidActivePurchasedOniOS : MembershipTestsSetup() {
// Date when the membership ends
private val dateEnds = 1714199910L
// URL for managing iOS payments
private val iosManageUrl = "iosManageUrl-${RandomString.make()}"
private fun commonTestSetup(): Pair<List<String>, List<MembershipTierData>> {
val features = listOf("feature-${RandomString.make()}", "feature-${RandomString.make()}")
val tiers = setupTierData(features)
return Pair(features, tiers)
}
private fun setupTierData(features: List<String>): List<MembershipTierData> {
return listOf(
StubMembershipTierData(
id = MembershipConstants.EXPLORER_ID,
androidProductId = null,
features = features,
periodType = MembershipPeriodType.PERIOD_TYPE_UNLIMITED,
priceStripeUsdCents = 0
),
StubMembershipTierData(
id = MembershipConstants.BUILDER_ID,
androidProductId = androidProductId,
features = features,
periodValue = 1,
periodType = MembershipPeriodType.PERIOD_TYPE_YEARS,
priceStripeUsdCents = 9900,
iosManageUrl = iosManageUrl
),
StubMembershipTierData(
id = MembershipConstants.CO_CREATOR_ID,
androidProductId = null,
features = features,
periodValue = 3,
periodType = MembershipPeriodType.PERIOD_TYPE_YEARS,
priceStripeUsdCents = 29900
),
StubMembershipTierData(
id = 22,
androidProductId = null,
features = features,
periodValue = 1,
periodType = MembershipPeriodType.PERIOD_TYPE_MONTHS,
priceStripeUsdCents = 1000
)
)
}
private fun setupMembershipStatus(tiers: List<MembershipTierData>): MembershipStatus {
return MembershipStatus(
activeTier = TierId(MembershipConstants.BUILDER_ID),
status = Membership.Status.STATUS_ACTIVE,
dateEnds = dateEnds,
paymentMethod = MembershipPaymentMethod.METHOD_INAPP_APPLE,
anyName = "TestAnyName",
tiers = tiers,
formattedDateEnds = "formattedDateEnds-${RandomString.make()}"
)
}
@Test
fun `when payed plan is active, show proper valid and enabled manage button`() = runTest {
turbineScope {
val (features, tiers) = commonTestSetup()
stubPurchaseState()
stubBilling(billingClientState = BillingClientState.Loading)
stubMembershipProvider(setupMembershipStatus(tiers))
val viewModel = buildViewModel()
val viewStateFlow = viewModel.viewState.testIn(backgroundScope)
val tierStateFlow = viewModel.tierState.testIn(backgroundScope)
assertIs<MembershipMainState.Loading>(viewStateFlow.awaitItem())
assertIs<MembershipTierState.Hidden>(tierStateFlow.awaitItem())
viewStateFlow.awaitItem().let { result ->
assertIs<MembershipMainState.Default>(result)
val tier: TierPreview =
result.tiersPreview.find { it.id.value == MembershipConstants.BUILDER_ID }!!
TestCase.assertEquals(MembershipConstants.BUILDER_ID, tier.id.value)
TestCase.assertEquals(true, tier.isActive)
TestCase.assertEquals(
TierConditionInfo.Visible.Valid(
dateEnds = dateEnds,
payedBy = MembershipPaymentMethod.METHOD_INAPP_APPLE,
period = TierPeriod.Year(1),
),
tier.conditionInfo
)
}
viewModel.onTierClicked(TierId(MembershipConstants.BUILDER_ID))
tierStateFlow.awaitItem().let { result ->
assertIs<MembershipTierState.Visible>(result)
validateTierView(
tier = result.tier,
expectedFeatures = features,
expectedConditionInfo = TierConditionInfo.Visible.Valid(
dateEnds = dateEnds,
payedBy = MembershipPaymentMethod.METHOD_INAPP_APPLE,
period = TierPeriod.Year(1),
),
expectedAnyName = TierAnyName.Hidden,
expectedButtonState = TierButton.Manage.External.Enabled(iosManageUrl),
expectedId = MembershipConstants.BUILDER_ID,
expectedActive = true,
expectedEmailState = TierEmail.Hidden
)
}
}
}
@Test
fun `when payed plan is active from crypto, show proper valid and hide manage button`() =
runTest {
turbineScope {
val (features, tiers) = commonTestSetup()
stubPurchaseState()
stubBilling(billingClientState = BillingClientState.Loading)
stubMembershipProvider(
MembershipStatus(
activeTier = TierId(MembershipConstants.BUILDER_ID),
status = Membership.Status.STATUS_ACTIVE,
dateEnds = dateEnds,
paymentMethod = MembershipPaymentMethod.METHOD_CRYPTO,
anyName = "TestAnyName",
tiers = tiers,
formattedDateEnds = "formattedDateEnds-${RandomString.make()}"
)
)
val viewModel = buildViewModel()
val viewStateFlow = viewModel.viewState.testIn(backgroundScope)
val tierStateFlow = viewModel.tierState.testIn(backgroundScope)
assertIs<MembershipMainState.Loading>(viewStateFlow.awaitItem())
assertIs<MembershipTierState.Hidden>(tierStateFlow.awaitItem())
viewStateFlow.awaitItem().let { result ->
assertIs<MembershipMainState.Default>(result)
val tier: TierPreview =
result.tiersPreview.find { it.id.value == MembershipConstants.BUILDER_ID }!!
TestCase.assertEquals(MembershipConstants.BUILDER_ID, tier.id.value)
TestCase.assertEquals(true, tier.isActive)
TestCase.assertEquals(
TierConditionInfo.Visible.Valid(
dateEnds = dateEnds,
payedBy = MembershipPaymentMethod.METHOD_CRYPTO,
period = TierPeriod.Year(1),
),
tier.conditionInfo
)
}
viewModel.onTierClicked(TierId(MembershipConstants.BUILDER_ID))
tierStateFlow.awaitItem().let { result ->
assertIs<MembershipTierState.Visible>(result)
validateTierView(
tier = result.tier,
expectedFeatures = features,
expectedConditionInfo = TierConditionInfo.Visible.Valid(
dateEnds = dateEnds,
payedBy = MembershipPaymentMethod.METHOD_CRYPTO,
period = TierPeriod.Year(1),
),
expectedAnyName = TierAnyName.Hidden,
expectedButtonState = TierButton.Hidden,
expectedId = MembershipConstants.BUILDER_ID,
expectedActive = true,
expectedEmailState = TierEmail.Hidden
)
}
}
}
}

View file

@ -0,0 +1,264 @@
package com.anytypeio.anytype.payments
import app.cash.turbine.turbineScope
import com.android.billingclient.api.Purchase
import com.anytypeio.anytype.core_models.membership.Membership
import com.anytypeio.anytype.core_models.membership.MembershipPaymentMethod
import com.anytypeio.anytype.core_models.membership.MembershipPeriodType
import com.anytypeio.anytype.core_models.membership.MembershipTierData
import com.anytypeio.anytype.payments.constants.MembershipConstants
import com.anytypeio.anytype.payments.models.TierAnyName
import com.anytypeio.anytype.payments.models.TierButton
import com.anytypeio.anytype.payments.models.TierConditionInfo
import com.anytypeio.anytype.payments.models.TierEmail
import com.anytypeio.anytype.payments.models.TierPeriod
import com.anytypeio.anytype.payments.models.TierPreview
import com.anytypeio.anytype.payments.playbilling.BillingPurchaseState
import com.anytypeio.anytype.payments.viewmodel.MembershipMainState
import com.anytypeio.anytype.payments.viewmodel.MembershipTierState
import com.anytypeio.anytype.presentation.membership.models.MembershipStatus
import com.anytypeio.anytype.presentation.membership.models.TierId
import junit.framework.TestCase
import kotlin.test.assertIs
import kotlinx.coroutines.test.runTest
import net.bytebuddy.utility.RandomString
import org.junit.Test
import org.mockito.Mockito
/**
* Tier - active and non free | with androidId | purchased through Android
* TierPreview = [Title|Subtitle|ConditionInfo.Valid]
* Tier = [Title|Subtitle|Features|ConditionInfo.Valid|ButtonManage]
*/
class TierAndroidActiveTests : MembershipTestsSetup() {
private val dateEnds = 1714199910L
private fun commonTestSetup(): Pair<List<String>, List<MembershipTierData>> {
val features = listOf("feature-${RandomString.make()}", "feature-${RandomString.make()}")
val tiers = setupTierData(features)
return Pair(features, tiers)
}
private fun setupTierData(features: List<String>): List<MembershipTierData> {
return listOf(
StubMembershipTierData(
id = MembershipConstants.EXPLORER_ID,
androidProductId = null,
features = features,
periodType = MembershipPeriodType.PERIOD_TYPE_UNLIMITED,
priceStripeUsdCents = 0
),
StubMembershipTierData(
id = MembershipConstants.BUILDER_ID,
androidProductId = androidProductId,
features = features,
periodValue = 1,
periodType = MembershipPeriodType.PERIOD_TYPE_YEARS,
priceStripeUsdCents = 9900
),
StubMembershipTierData(
id = MembershipConstants.CO_CREATOR_ID,
androidProductId = null,
features = features,
periodValue = 3,
periodType = MembershipPeriodType.PERIOD_TYPE_YEARS,
priceStripeUsdCents = 29900
),
StubMembershipTierData(
id = 22,
androidProductId = null,
features = features,
periodValue = 1,
periodType = MembershipPeriodType.PERIOD_TYPE_MONTHS,
priceStripeUsdCents = 1000
)
)
}
private fun setupMembershipStatus(tiers: List<MembershipTierData>): MembershipStatus {
return MembershipStatus(
activeTier = TierId(MembershipConstants.BUILDER_ID),
status = Membership.Status.STATUS_ACTIVE,
dateEnds = dateEnds,
paymentMethod = MembershipPaymentMethod.METHOD_INAPP_GOOGLE,
anyName = "TestAnyName",
tiers = tiers,
formattedDateEnds = "formattedDateEnds-${RandomString.make()}"
)
}
@Test
fun `test loading billing purchase state`() = runTest {
turbineScope {
val (features, tiers) = commonTestSetup()
stubBilling()
stubPurchaseState(BillingPurchaseState.Loading)
stubMembershipProvider(setupMembershipStatus(tiers))
val viewModel = buildViewModel()
val viewStateFlow = viewModel.viewState.testIn(backgroundScope)
val tierStateFlow = viewModel.tierState.testIn(backgroundScope)
assertIs<MembershipMainState.Loading>(viewStateFlow.awaitItem())
assertIs<MembershipTierState.Hidden>(tierStateFlow.awaitItem())
val validPeriod = TierPeriod.Year(1)
viewStateFlow.awaitItem().let { result ->
assertIs<MembershipMainState.Default>(result)
val tier: TierPreview =
result.tiersPreview.find { it.id.value == MembershipConstants.BUILDER_ID }!!
TestCase.assertEquals(MembershipConstants.BUILDER_ID, tier.id.value)
TestCase.assertEquals(true, tier.isActive)
TestCase.assertEquals(
TierConditionInfo.Visible.Valid(
dateEnds = dateEnds,
payedBy = MembershipPaymentMethod.METHOD_INAPP_GOOGLE,
period = validPeriod
),
tier.conditionInfo
)
}
viewModel.onTierClicked(TierId(MembershipConstants.BUILDER_ID))
//STATE : BUILDER, CURRENT, LOADING PURCHASES
tierStateFlow.awaitItem().let { result ->
assertIs<MembershipTierState.Visible>(result)
validateTierView(
expectedId = MembershipConstants.BUILDER_ID,
expectedActive = true,
expectedFeatures = features,
expectedConditionInfo = TierConditionInfo.Visible.Valid(
dateEnds = dateEnds,
payedBy = MembershipPaymentMethod.METHOD_INAPP_GOOGLE,
period = validPeriod
),
expectedAnyName = TierAnyName.Hidden,
expectedButtonState = TierButton.Manage.Android.Disabled,
tier = result.tier,
expectedEmailState = TierEmail.Hidden
)
}
}
}
@Test
fun `test empty billing purchase state`() = runTest {
turbineScope {
val (features, tiers) = commonTestSetup()
stubBilling()
stubPurchaseState(BillingPurchaseState.NoPurchases)
stubMembershipProvider(setupMembershipStatus(tiers))
val viewModel = buildViewModel()
val viewStateFlow = viewModel.viewState.testIn(backgroundScope)
val tierStateFlow = viewModel.tierState.testIn(backgroundScope)
assertIs<MembershipMainState.Loading>(viewStateFlow.awaitItem())
assertIs<MembershipTierState.Hidden>(tierStateFlow.awaitItem())
val validPeriod = TierPeriod.Year(1)
viewStateFlow.awaitItem().let { result ->
assertIs<MembershipMainState.Default>(result)
val tier: TierPreview =
result.tiersPreview.find { it.id.value == MembershipConstants.BUILDER_ID }!!
TestCase.assertEquals(MembershipConstants.BUILDER_ID, tier.id.value)
TestCase.assertEquals(true, tier.isActive)
TestCase.assertEquals(
TierConditionInfo.Visible.Valid(
dateEnds = dateEnds,
payedBy = MembershipPaymentMethod.METHOD_INAPP_GOOGLE,
period = validPeriod
),
tier.conditionInfo
)
}
viewModel.onTierClicked(TierId(MembershipConstants.BUILDER_ID))
//STATE : BUILDER, CURRENT, EMPTY PURCHASES, NOTHING TO MANAGE
tierStateFlow.awaitItem().let { result ->
assertIs<MembershipTierState.Visible>(result)
validateTierView(
expectedId = MembershipConstants.BUILDER_ID,
expectedActive = true,
expectedFeatures = features,
expectedConditionInfo = TierConditionInfo.Visible.Valid(
dateEnds = dateEnds,
payedBy = MembershipPaymentMethod.METHOD_INAPP_GOOGLE,
period = validPeriod
),
expectedAnyName = TierAnyName.Hidden,
expectedButtonState = TierButton.Manage.Android.Disabled,
tier = result.tier,
expectedEmailState = TierEmail.Hidden
)
}
}
}
@Test
fun `test success billing state`() = runTest {
turbineScope {
val (features, tiers) = commonTestSetup()
stubBilling()
val purchase = Mockito.mock(Purchase::class.java)
Mockito.`when`(purchase.products).thenReturn(listOf(androidProductId))
Mockito.`when`(purchase.isAcknowledged).thenReturn(true)
stubPurchaseState(BillingPurchaseState.HasPurchases(listOf(purchase), false))
stubMembershipProvider(setupMembershipStatus(tiers))
val viewModel = buildViewModel()
val viewStateFlow = viewModel.viewState.testIn(backgroundScope)
val tierStateFlow = viewModel.tierState.testIn(backgroundScope)
assertIs<MembershipMainState.Loading>(viewStateFlow.awaitItem())
assertIs<MembershipTierState.Hidden>(tierStateFlow.awaitItem())
val validPeriod = TierPeriod.Year(1)
viewStateFlow.awaitItem().let { result ->
assertIs<MembershipMainState.Default>(result)
val tier: TierPreview =
result.tiersPreview.find { it.id.value == MembershipConstants.BUILDER_ID }!!
TestCase.assertEquals(MembershipConstants.BUILDER_ID, tier.id.value)
TestCase.assertEquals(true, tier.isActive)
TestCase.assertEquals(
TierConditionInfo.Visible.Valid(
dateEnds = dateEnds,
payedBy = MembershipPaymentMethod.METHOD_INAPP_GOOGLE,
period = validPeriod
),
tier.conditionInfo
)
}
viewModel.onTierClicked(TierId(MembershipConstants.BUILDER_ID))
//STATE : BUILDER, CURRENT, PURCHASE SUCCESS
tierStateFlow.awaitItem().let { result ->
assertIs<MembershipTierState.Visible>(result)
validateTierView(
expectedId = MembershipConstants.BUILDER_ID,
expectedActive = true,
expectedFeatures = features,
expectedConditionInfo = TierConditionInfo.Visible.Valid(
dateEnds = dateEnds,
payedBy = MembershipPaymentMethod.METHOD_INAPP_GOOGLE,
period = validPeriod
),
expectedAnyName = TierAnyName.Hidden,
expectedButtonState = TierButton.Manage.Android.Enabled(androidProductId),
tier = result.tier,
expectedEmailState = TierEmail.Hidden
)
}
}
}
}

View file

@ -0,0 +1,608 @@
package com.anytypeio.anytype.payments
import app.cash.turbine.turbineScope
import com.android.billingclient.api.ProductDetails
import com.anytypeio.anytype.core_models.membership.Membership
import com.anytypeio.anytype.core_models.membership.MembershipPaymentMethod
import com.anytypeio.anytype.core_models.membership.MembershipPeriodType
import com.anytypeio.anytype.core_models.membership.MembershipTierData
import com.anytypeio.anytype.payments.constants.MembershipConstants
import com.anytypeio.anytype.payments.models.BillingPriceInfo
import com.anytypeio.anytype.payments.models.PeriodDescription
import com.anytypeio.anytype.payments.models.PeriodUnit
import com.anytypeio.anytype.payments.models.TierAnyName
import com.anytypeio.anytype.payments.models.TierButton
import com.anytypeio.anytype.payments.models.TierConditionInfo
import com.anytypeio.anytype.payments.models.TierEmail
import com.anytypeio.anytype.payments.models.TierPreview
import com.anytypeio.anytype.payments.playbilling.BillingClientState
import com.anytypeio.anytype.payments.viewmodel.MembershipMainState
import com.anytypeio.anytype.payments.viewmodel.MembershipTierState
import com.anytypeio.anytype.presentation.membership.models.MembershipStatus
import com.anytypeio.anytype.presentation.membership.models.TierId
import junit.framework.TestCase.assertEquals
import kotlin.test.assertIs
import kotlinx.coroutines.test.runTest
import net.bytebuddy.utility.RandomString
import org.junit.Test
import org.mockito.Mockito
/**
* Tests for the not active tier with possible android subscription
*
* TierPreview = [Title|Subtitle|ConditionInfo.Price]
* Tier = [Title|Subtitle|Features|AnyName|ConditionInfo.Price|ButtonPay]
*
* TierPreview has same fields as Tier except for Features, AnyName, ButtonState
*/
class TierAndroidNotActiveTests : MembershipTestsSetup() {
private fun commonTestSetup(): Pair<List<String>, List<MembershipTierData>> {
val features = listOf("feature-${RandomString.make()}", "feature-${RandomString.make()}")
val tiers = setupTierData(features)
return Pair(features, tiers)
}
private fun setupTierData(features: List<String>): List<MembershipTierData> {
return listOf(
StubMembershipTierData(
id = MembershipConstants.EXPLORER_ID,
androidProductId = null,
features = features,
periodType = MembershipPeriodType.PERIOD_TYPE_UNLIMITED,
priceStripeUsdCents = 0
),
StubMembershipTierData(
id = MembershipConstants.BUILDER_ID,
androidProductId = androidProductId,
features = features,
periodValue = 1,
periodType = MembershipPeriodType.PERIOD_TYPE_YEARS,
priceStripeUsdCents = 9900
),
StubMembershipTierData(
id = MembershipConstants.CO_CREATOR_ID,
androidProductId = null,
features = features,
periodValue = 3,
periodType = MembershipPeriodType.PERIOD_TYPE_YEARS,
priceStripeUsdCents = 29900
),
StubMembershipTierData(
id = 22,
androidProductId = null,
features = features,
periodValue = 1,
periodType = MembershipPeriodType.PERIOD_TYPE_MONTHS,
priceStripeUsdCents = 1000
)
)
}
private fun setupMembershipStatus(
tiers: List<MembershipTierData>,
anyName: String = "",
status : Membership.Status = Membership.Status.STATUS_ACTIVE
): MembershipStatus {
return MembershipStatus(
activeTier = TierId(MembershipConstants.EXPLORER_ID),
status = status,
dateEnds = 1714199910,
paymentMethod = MembershipPaymentMethod.METHOD_NONE,
anyName = anyName,
tiers = tiers,
formattedDateEnds = "formattedDateEnds-${RandomString.make()}"
)
}
@Test
fun `test loading billing state`() = runTest {
turbineScope {
val (features, tiers) = commonTestSetup()
stubPurchaseState()
stubBilling(billingClientState = BillingClientState.Loading)
stubMembershipProvider(setupMembershipStatus(tiers))
val viewModel = buildViewModel()
val viewStateFlow = viewModel.viewState.testIn(backgroundScope)
val tierStateFlow = viewModel.tierState.testIn(backgroundScope)
assertIs<MembershipMainState.Loading>(viewStateFlow.awaitItem())
assertIs<MembershipTierState.Hidden>(tierStateFlow.awaitItem())
viewStateFlow.awaitItem().let { result ->
assertIs<MembershipMainState.Default>(result)
val tier: TierPreview = result.tiersPreview.find { it.id.value == MembershipConstants.BUILDER_ID }!!
assertEquals(MembershipConstants.BUILDER_ID, tier.id.value)
assertEquals(false, tier.isActive)
assertEquals(TierConditionInfo.Visible.LoadingBillingClient, tier.conditionInfo)
}
viewModel.onTierClicked(TierId(MembershipConstants.BUILDER_ID))
//STATE : BUILDER, NOT CURRENT, LOADING BILLING
tierStateFlow.awaitItem().let { result ->
assertIs<MembershipTierState.Visible>(result)
validateTierView(
tier = result.tier,
expectedFeatures = features,
expectedConditionInfo = TierConditionInfo.Visible.LoadingBillingClient,
expectedAnyName = TierAnyName.Visible.Disabled,
expectedButtonState = TierButton.Pay.Disabled,
expectedId = MembershipConstants.BUILDER_ID,
expectedActive = false,
expectedEmailState = TierEmail.Hidden
)
}
}
}
@Test
fun `test error billing state`() = runTest {
turbineScope {
val (features, tiers) = commonTestSetup()
val errorMessage = "error-${RandomString.make()}"
stubMembershipProvider(setupMembershipStatus(tiers))
stubPurchaseState()
stubBilling(billingClientState = BillingClientState.Error(errorMessage))
val viewModel = buildViewModel()
val viewStateFlow = viewModel.viewState.testIn(backgroundScope)
val tierStateFlow = viewModel.tierState.testIn(backgroundScope)
assertIs<MembershipMainState.Loading>(viewStateFlow.awaitItem())
assertIs<MembershipTierState.Hidden>(tierStateFlow.awaitItem())
viewStateFlow.awaitItem().let { result ->
assertIs<MembershipMainState.Default>(result)
val tier: TierPreview = result.tiersPreview.find { it.id.value == MembershipConstants.BUILDER_ID }!!
assertEquals(MembershipConstants.BUILDER_ID, tier.id.value)
assertEquals(false, tier.isActive)
assertEquals(TierConditionInfo.Visible.Error(errorMessage), tier.conditionInfo)
}
viewModel.onTierClicked(TierId(MembershipConstants.BUILDER_ID))
//STATE : BUILDER, NOT CURRENT, ERROR BILLING
tierStateFlow.awaitItem().let { result ->
assertIs<MembershipTierState.Visible>(result)
validateTierView(
tier = result.tier,
expectedFeatures = features,
expectedConditionInfo = TierConditionInfo.Visible.Error(errorMessage),
expectedAnyName = TierAnyName.Visible.Disabled,
expectedButtonState = TierButton.Pay.Disabled,
expectedId = MembershipConstants.BUILDER_ID,
expectedActive = false,
expectedEmailState = TierEmail.Hidden
)
}
}
}
@Test
fun `test error product billing state when price is empty`() = runTest {
turbineScope {
val (features, tiers) = commonTestSetup()
stubMembershipProvider(setupMembershipStatus(tiers))
stubPurchaseState()
val product = Mockito.mock(ProductDetails::class.java)
val subscriptionOfferDetails = listOf(Mockito.mock(ProductDetails.SubscriptionOfferDetails::class.java))
val pricingPhases = Mockito.mock(ProductDetails.PricingPhases::class.java)
val pricingPhaseList = listOf(Mockito.mock(ProductDetails.PricingPhase::class.java))
Mockito.`when`(product.productId).thenReturn(androidProductId)
Mockito.`when`(product?.subscriptionOfferDetails).thenReturn(subscriptionOfferDetails)
Mockito.`when`(subscriptionOfferDetails[0].pricingPhases).thenReturn(pricingPhases)
Mockito.`when`(pricingPhases?.pricingPhaseList).thenReturn(pricingPhaseList)
val formattedPrice = "" // You can set any desired formatted price here
Mockito.`when`(pricingPhaseList[0]?.formattedPrice).thenReturn(formattedPrice)
stubBilling(billingClientState = BillingClientState.Connected(listOf(product)))
val viewModel = buildViewModel()
val viewStateFlow = viewModel.viewState.testIn(backgroundScope)
val tierStateFlow = viewModel.tierState.testIn(backgroundScope)
assertIs<MembershipMainState.Loading>(viewStateFlow.awaitItem())
assertIs<MembershipTierState.Hidden>(tierStateFlow.awaitItem())
val expectedConditionInfo = TierConditionInfo.Visible.Error(MembershipConstants.ERROR_PRODUCT_PRICE)
viewStateFlow.awaitItem().let { result ->
assertIs<MembershipMainState.Default>(result)
val tier: TierPreview = result.tiersPreview.find { it.id.value == MembershipConstants.BUILDER_ID }!!
assertEquals(MembershipConstants.BUILDER_ID, tier.id.value)
assertEquals(false, tier.isActive)
assertEquals(expectedConditionInfo, tier.conditionInfo)
}
viewModel.onTierClicked(TierId(MembershipConstants.BUILDER_ID))
//STATE : BUILDER, NOT CURRENT, BUILDER PRODUCT, PRICE IS EMPTY
tierStateFlow.awaitItem().let { result ->
assertIs<MembershipTierState.Visible>(result)
validateTierView(
tier = result.tier,
expectedFeatures = features,
expectedConditionInfo = expectedConditionInfo,
expectedAnyName = TierAnyName.Visible.Disabled,
expectedButtonState = TierButton.Pay.Disabled,
expectedId = MembershipConstants.BUILDER_ID,
expectedActive = false,
expectedEmailState = TierEmail.Hidden
)
}
}
}
@Test
fun `test error product billing state when product not found`() = runTest {
turbineScope {
val (features, tiers) = commonTestSetup()
stubMembershipProvider(setupMembershipStatus(tiers))
stubPurchaseState()
val product = Mockito.mock(ProductDetails::class.java)
val subscriptionOfferDetails = listOf(Mockito.mock(ProductDetails.SubscriptionOfferDetails::class.java))
val pricingPhases = Mockito.mock(ProductDetails.PricingPhases::class.java)
val pricingPhaseList = listOf(Mockito.mock(ProductDetails.PricingPhase::class.java))
Mockito.`when`(product.productId).thenReturn(RandomString.make())
Mockito.`when`(product?.subscriptionOfferDetails).thenReturn(subscriptionOfferDetails)
Mockito.`when`(subscriptionOfferDetails[0].pricingPhases).thenReturn(pricingPhases)
Mockito.`when`(pricingPhases?.pricingPhaseList).thenReturn(pricingPhaseList)
val formattedPrice = "" // You can set any desired formatted price here
Mockito.`when`(pricingPhaseList[0]?.formattedPrice).thenReturn(formattedPrice)
stubBilling(billingClientState = BillingClientState.Connected(listOf(product)))
val viewModel = buildViewModel()
val viewStateFlow = viewModel.viewState.testIn(backgroundScope)
val tierStateFlow = viewModel.tierState.testIn(backgroundScope)
assertIs<MembershipMainState.Loading>(viewStateFlow.awaitItem())
assertIs<MembershipTierState.Hidden>(tierStateFlow.awaitItem())
val expectedConditionInfo = TierConditionInfo.Visible.Error(MembershipConstants.ERROR_PRODUCT_NOT_FOUND)
viewStateFlow.awaitItem().let { result ->
assertIs<MembershipMainState.Default>(result)
val tier: TierPreview = result.tiersPreview.find { it.id.value == MembershipConstants.BUILDER_ID }!!
assertEquals(MembershipConstants.BUILDER_ID, tier.id.value)
assertEquals(false, tier.isActive)
assertEquals(expectedConditionInfo, tier.conditionInfo)
}
viewModel.onTierClicked(TierId(MembershipConstants.BUILDER_ID))
//STATE : BUILDER, NOT CURRENT, BUILDER PRODUCT, PRODUCT NOT FOUND
tierStateFlow.awaitItem().let { result ->
assertIs<MembershipTierState.Visible>(result)
validateTierView(
tier = result.tier,
expectedFeatures = features,
expectedConditionInfo = expectedConditionInfo,
expectedAnyName = TierAnyName.Visible.Disabled,
expectedButtonState = TierButton.Pay.Disabled,
expectedId = MembershipConstants.BUILDER_ID,
expectedActive = false,
expectedEmailState = TierEmail.Hidden
)
}
}
}
@Test
fun `test success product billing state`() = runTest {
turbineScope {
val (features, tiers) = commonTestSetup()
stubPurchaseState()
stubMembershipProvider(setupMembershipStatus(tiers))
val product = Mockito.mock(ProductDetails::class.java)
val subscriptionOfferDetails = listOf(Mockito.mock(ProductDetails.SubscriptionOfferDetails::class.java))
val pricingPhases = Mockito.mock(ProductDetails.PricingPhases::class.java)
val pricingPhaseList = listOf(Mockito.mock(ProductDetails.PricingPhase::class.java))
Mockito.`when`(product.productId).thenReturn(androidProductId)
Mockito.`when`(product?.subscriptionOfferDetails).thenReturn(subscriptionOfferDetails)
Mockito.`when`(subscriptionOfferDetails[0].pricingPhases).thenReturn(pricingPhases)
Mockito.`when`(pricingPhases?.pricingPhaseList).thenReturn(pricingPhaseList)
val formattedPrice = "$9.99" // You can set any desired formatted price here
Mockito.`when`(pricingPhaseList[0]?.formattedPrice).thenReturn(formattedPrice)
Mockito.`when`(pricingPhaseList[0]?.billingPeriod).thenReturn("P1Y")
stubBilling(billingClientState = BillingClientState.Connected(listOf(product)))
val viewModel = buildViewModel()
val viewStateFlow = viewModel.viewState.testIn(backgroundScope)
val tierStateFlow = viewModel.tierState.testIn(backgroundScope)
assertIs<MembershipMainState.Loading>(viewStateFlow.awaitItem())
assertIs<MembershipTierState.Hidden>(tierStateFlow.awaitItem())
val expectedConditionInfo = TierConditionInfo.Visible.PriceBilling(
BillingPriceInfo(
formattedPrice = formattedPrice,
period = PeriodDescription(amount = 1, unit = PeriodUnit.YEARS)
)
)
viewStateFlow.awaitItem().let { result ->
assertIs<MembershipMainState.Default>(result)
val tier: TierPreview = result.tiersPreview.find { it.id.value == MembershipConstants.BUILDER_ID }!!
assertEquals(MembershipConstants.BUILDER_ID, tier.id.value)
assertEquals(false, tier.isActive)
assertEquals(expectedConditionInfo, tier.conditionInfo)
}
viewModel.onTierClicked(TierId(MembershipConstants.BUILDER_ID))
//STATE : BUILDER, NOT CURRENT, BUILDER PRODUCT, CORRECT PRICE
tierStateFlow.awaitItem().let { result ->
assertIs<MembershipTierState.Visible>(result)
validateTierView(
tier = result.tier,
expectedFeatures = features,
expectedConditionInfo = expectedConditionInfo,
expectedAnyName = TierAnyName.Visible.Enter,
expectedButtonState = TierButton.Pay.Disabled,
expectedId = MembershipConstants.BUILDER_ID,
expectedActive = false,
expectedEmailState = TierEmail.Hidden
)
}
}
}
@Test
fun `test unsuccessful product billing state, when billing period is wrong`() = runTest {
turbineScope {
val (features, tiers) = commonTestSetup()
stubPurchaseState()
stubMembershipProvider(setupMembershipStatus(tiers))
val product = Mockito.mock(ProductDetails::class.java)
val subscriptionOfferDetails = listOf(Mockito.mock(ProductDetails.SubscriptionOfferDetails::class.java))
val pricingPhases = Mockito.mock(ProductDetails.PricingPhases::class.java)
val pricingPhaseList = listOf(Mockito.mock(ProductDetails.PricingPhase::class.java))
Mockito.`when`(product.productId).thenReturn(androidProductId)
Mockito.`when`(product?.subscriptionOfferDetails).thenReturn(subscriptionOfferDetails)
Mockito.`when`(subscriptionOfferDetails[0].pricingPhases).thenReturn(pricingPhases)
Mockito.`when`(pricingPhases?.pricingPhaseList).thenReturn(pricingPhaseList)
val formattedPrice = "$9.99" // You can set any desired formatted price here
Mockito.`when`(pricingPhaseList[0]?.formattedPrice).thenReturn(formattedPrice)
Mockito.`when`(pricingPhaseList[0]?.billingPeriod).thenReturn("errorBillingPeriod")
stubBilling(billingClientState = BillingClientState.Connected(listOf(product)))
val viewModel = buildViewModel()
val viewStateFlow = viewModel.viewState.testIn(backgroundScope)
val tierStateFlow = viewModel.tierState.testIn(backgroundScope)
assertIs<MembershipMainState.Loading>(viewStateFlow.awaitItem())
assertIs<MembershipTierState.Hidden>(tierStateFlow.awaitItem())
val expectedConditionInfo = TierConditionInfo.Visible.Error(MembershipConstants.ERROR_PRODUCT_PRICE)
viewStateFlow.awaitItem().let { result ->
assertIs<MembershipMainState.Default>(result)
val tier: TierPreview = result.tiersPreview.find { it.id.value == MembershipConstants.BUILDER_ID }!!
assertEquals(MembershipConstants.BUILDER_ID, tier.id.value)
assertEquals(false, tier.isActive)
assertEquals(expectedConditionInfo, tier.conditionInfo)
}
viewModel.onTierClicked(TierId(MembershipConstants.BUILDER_ID))
//STATE : BUILDER, NOT CURRENT, BUILDER PRODUCT, INCORRECT BILLING PERIOD
tierStateFlow.awaitItem().let { result ->
assertIs<MembershipTierState.Visible>(result)
validateTierView(
tier = result.tier,
expectedFeatures = features,
expectedConditionInfo = expectedConditionInfo,
expectedAnyName = TierAnyName.Visible.Disabled,
expectedButtonState = TierButton.Pay.Disabled,
expectedId = MembershipConstants.BUILDER_ID,
expectedActive = false,
expectedEmailState = TierEmail.Hidden
)
}
}
}
@Test
fun `test unsuccessful product billing state, when billing price is wrong`() = runTest {
turbineScope {
val (features, tiers) = commonTestSetup()
stubPurchaseState()
stubMembershipProvider(setupMembershipStatus(tiers))
val product = Mockito.mock(ProductDetails::class.java)
val subscriptionOfferDetails = listOf(Mockito.mock(ProductDetails.SubscriptionOfferDetails::class.java))
val pricingPhases = Mockito.mock(ProductDetails.PricingPhases::class.java)
val pricingPhaseList = listOf(Mockito.mock(ProductDetails.PricingPhase::class.java))
Mockito.`when`(product.productId).thenReturn(androidProductId)
Mockito.`when`(product?.subscriptionOfferDetails).thenReturn(subscriptionOfferDetails)
Mockito.`when`(subscriptionOfferDetails[0].pricingPhases).thenReturn(pricingPhases)
Mockito.`when`(pricingPhases?.pricingPhaseList).thenReturn(pricingPhaseList)
val formattedPrice = null // You can set any desired formatted price here
Mockito.`when`(pricingPhaseList[0]?.formattedPrice).thenReturn(formattedPrice)
Mockito.`when`(pricingPhaseList[0]?.billingPeriod).thenReturn("P1Y")
stubBilling(billingClientState = BillingClientState.Connected(listOf(product)))
val viewModel = buildViewModel()
val viewStateFlow = viewModel.viewState.testIn(backgroundScope)
val tierStateFlow = viewModel.tierState.testIn(backgroundScope)
assertIs<MembershipMainState.Loading>(viewStateFlow.awaitItem())
assertIs<MembershipTierState.Hidden>(tierStateFlow.awaitItem())
val expectedConditionInfo = TierConditionInfo.Visible.Error(MembershipConstants.ERROR_PRODUCT_PRICE)
viewStateFlow.awaitItem().let { result ->
assertIs<MembershipMainState.Default>(result)
val tier: TierPreview = result.tiersPreview.find { it.id.value == MembershipConstants.BUILDER_ID }!!
assertEquals(MembershipConstants.BUILDER_ID, tier.id.value)
assertEquals(false, tier.isActive)
assertEquals(expectedConditionInfo, tier.conditionInfo)
}
viewModel.onTierClicked(TierId(MembershipConstants.BUILDER_ID))
//STATE : BUILDER, NOT CURRENT, BUILDER PRODUCT, INCORRECT BILLING PRICE
tierStateFlow.awaitItem().let { result ->
assertIs<MembershipTierState.Visible>(result)
validateTierView(
tier = result.tier,
expectedFeatures = features,
expectedConditionInfo = expectedConditionInfo,
expectedAnyName = TierAnyName.Visible.Disabled,
expectedButtonState = TierButton.Pay.Disabled,
expectedId = MembershipConstants.BUILDER_ID,
expectedActive = false,
expectedEmailState = TierEmail.Hidden
)
}
}
}
@Test
fun `when any name already purchased should show it`() = runTest {
val anyName = "anyName-${RandomString.make()}"
turbineScope {
val (features, tiers) = commonTestSetup()
stubPurchaseState()
stubMembershipProvider(
setupMembershipStatus(
tiers = tiers, anyName = anyName
)
)
val product = Mockito.mock(ProductDetails::class.java)
val subscriptionOfferDetails = listOf(Mockito.mock(ProductDetails.SubscriptionOfferDetails::class.java))
val pricingPhases = Mockito.mock(ProductDetails.PricingPhases::class.java)
val pricingPhaseList = listOf(Mockito.mock(ProductDetails.PricingPhase::class.java))
Mockito.`when`(product.productId).thenReturn(androidProductId)
Mockito.`when`(product?.subscriptionOfferDetails).thenReturn(subscriptionOfferDetails)
Mockito.`when`(subscriptionOfferDetails[0].pricingPhases).thenReturn(pricingPhases)
Mockito.`when`(pricingPhases?.pricingPhaseList).thenReturn(pricingPhaseList)
val formattedPrice = "$9.99" // You can set any desired formatted price here
Mockito.`when`(pricingPhaseList[0]?.formattedPrice).thenReturn(formattedPrice)
Mockito.`when`(pricingPhaseList[0]?.billingPeriod).thenReturn("P1Y")
stubBilling(billingClientState = BillingClientState.Connected(listOf(product)))
val viewModel = buildViewModel()
val viewStateFlow = viewModel.viewState.testIn(backgroundScope)
val tierStateFlow = viewModel.tierState.testIn(backgroundScope)
assertIs<MembershipMainState.Loading>(viewStateFlow.awaitItem())
assertIs<MembershipTierState.Hidden>(tierStateFlow.awaitItem())
val expectedConditionInfo = TierConditionInfo.Visible.PriceBilling(
BillingPriceInfo(
formattedPrice = formattedPrice,
period = PeriodDescription(amount = 1, unit = PeriodUnit.YEARS)
)
)
viewStateFlow.awaitItem().let { result ->
assertIs<MembershipMainState.Default>(result)
val tier: TierPreview = result.tiersPreview.find { it.id.value == MembershipConstants.BUILDER_ID }!!
assertEquals(MembershipConstants.BUILDER_ID, tier.id.value)
assertEquals(false, tier.isActive)
assertEquals(expectedConditionInfo, tier.conditionInfo)
}
viewModel.onTierClicked(TierId(MembershipConstants.BUILDER_ID))
//STATE : BUILDER, NOT CURRENT, BUILDER PRODUCT, HAS PURCHASED NAME
tierStateFlow.awaitItem().let { result ->
assertIs<MembershipTierState.Visible>(result)
validateTierView(
tier = result.tier,
expectedFeatures = features,
expectedConditionInfo = expectedConditionInfo,
expectedAnyName = TierAnyName.Visible.Purchased(anyName),
expectedButtonState = TierButton.Pay.Enabled,
expectedId = MembershipConstants.BUILDER_ID,
expectedActive = false,
expectedEmailState = TierEmail.Hidden
)
}
}
}
@Test
fun `test pending state`() = runTest {
turbineScope {
val (features, tiers) = commonTestSetup()
stubPurchaseState()
stubMembershipProvider(
setupMembershipStatus(
tiers = tiers,
status = Membership.Status.STATUS_PENDING
)
)
val product = Mockito.mock(ProductDetails::class.java)
val subscriptionOfferDetails = listOf(Mockito.mock(ProductDetails.SubscriptionOfferDetails::class.java))
val pricingPhases = Mockito.mock(ProductDetails.PricingPhases::class.java)
val pricingPhaseList = listOf(Mockito.mock(ProductDetails.PricingPhase::class.java))
Mockito.`when`(product.productId).thenReturn(androidProductId)
Mockito.`when`(product?.subscriptionOfferDetails).thenReturn(subscriptionOfferDetails)
Mockito.`when`(subscriptionOfferDetails[0].pricingPhases).thenReturn(pricingPhases)
Mockito.`when`(pricingPhases?.pricingPhaseList).thenReturn(pricingPhaseList)
val formattedPrice = "$9.99" // You can set any desired formatted price here
Mockito.`when`(pricingPhaseList[0]?.formattedPrice).thenReturn(formattedPrice)
Mockito.`when`(pricingPhaseList[0]?.billingPeriod).thenReturn("P1Y")
stubBilling(billingClientState = BillingClientState.Connected(listOf(product)))
val viewModel = buildViewModel()
val viewStateFlow = viewModel.viewState.testIn(backgroundScope)
val tierStateFlow = viewModel.tierState.testIn(backgroundScope)
assertIs<MembershipMainState.Loading>(viewStateFlow.awaitItem())
assertIs<MembershipTierState.Hidden>(tierStateFlow.awaitItem())
val expectedConditionInfo = TierConditionInfo.Visible.Pending
viewStateFlow.awaitItem().let { result ->
assertIs<MembershipMainState.Default>(result)
val tier: TierPreview = result.tiersPreview.find { it.id.value == MembershipConstants.BUILDER_ID }!!
assertEquals(MembershipConstants.BUILDER_ID, tier.id.value)
assertEquals(false, tier.isActive)
assertEquals(expectedConditionInfo, tier.conditionInfo)
}
viewModel.onTierClicked(TierId(MembershipConstants.BUILDER_ID))
//STATE : BUILDER, NOT CURRENT, BUILDER PRODUCT, CORRECT PRICE, MEMBERSHIP PENDING
tierStateFlow.awaitItem().let { result ->
assertIs<MembershipTierState.Visible>(result)
validateTierView(
tier = result.tier,
expectedFeatures = features,
expectedConditionInfo = expectedConditionInfo,
expectedAnyName = TierAnyName.Hidden,
expectedButtonState = TierButton.Pay.Disabled,
expectedId = MembershipConstants.BUILDER_ID,
expectedActive = false,
expectedEmailState = TierEmail.Hidden
)
}
}
}
}

View file

@ -0,0 +1,188 @@
package com.anytypeio.anytype.payments
import app.cash.turbine.turbineScope
import com.android.billingclient.api.ProductDetails
import com.android.billingclient.api.Purchase
import com.anytypeio.anytype.core_models.membership.Membership
import com.anytypeio.anytype.core_models.membership.MembershipPaymentMethod
import com.anytypeio.anytype.core_models.membership.MembershipPeriodType
import com.anytypeio.anytype.core_models.membership.MembershipTierData
import com.anytypeio.anytype.payments.constants.MembershipConstants.BUILDER_ID
import com.anytypeio.anytype.payments.constants.MembershipConstants.EXPLORER_ID
import com.anytypeio.anytype.payments.models.TierAnyName
import com.anytypeio.anytype.payments.models.TierButton
import com.anytypeio.anytype.payments.models.TierConditionInfo
import com.anytypeio.anytype.payments.models.TierEmail
import com.anytypeio.anytype.payments.models.TierPeriod
import com.anytypeio.anytype.payments.playbilling.BillingClientState
import com.anytypeio.anytype.payments.playbilling.BillingPurchaseState
import com.anytypeio.anytype.payments.viewmodel.MembershipMainState
import com.anytypeio.anytype.payments.viewmodel.MembershipTierState
import com.anytypeio.anytype.presentation.membership.models.MembershipStatus
import com.anytypeio.anytype.presentation.membership.models.TierId
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.test.runTest
import net.bytebuddy.utility.RandomString
import org.junit.Test
import org.mockito.Mockito
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.stub
class TierBuilderFallbackOnExplorerTest : MembershipTestsSetup() {
private val dateEnds = 1714199910L
private fun commonTestSetup(): Pair<List<String>, List<MembershipTierData>> {
val features = listOf("feature-${RandomString.make()}", "feature-${RandomString.make()}")
val tiers = setupTierData(features)
return Pair(features, tiers)
}
private fun setupTierData(features: List<String>): List<MembershipTierData> {
return listOf(
StubMembershipTierData(
id = EXPLORER_ID,
androidProductId = null,
features = features,
periodType = MembershipPeriodType.PERIOD_TYPE_UNLIMITED,
priceStripeUsdCents = 0
),
StubMembershipTierData(
id = BUILDER_ID,
androidProductId = androidProductId,
features = features,
periodValue = 1,
periodType = MembershipPeriodType.PERIOD_TYPE_YEARS,
priceStripeUsdCents = 9900
)
)
}
@Test
fun `when updating active tier from builder to explorer`() = runTest {
turbineScope {
val (features, tiers) = commonTestSetup()
val product = Mockito.mock(ProductDetails::class.java)
val subscriptionOfferDetails = listOf(Mockito.mock(ProductDetails.SubscriptionOfferDetails::class.java))
val pricingPhases = Mockito.mock(ProductDetails.PricingPhases::class.java)
val pricingPhaseList = listOf(Mockito.mock(ProductDetails.PricingPhase::class.java))
Mockito.`when`(product.productId).thenReturn(androidProductId)
Mockito.`when`(product?.subscriptionOfferDetails).thenReturn(subscriptionOfferDetails)
Mockito.`when`(subscriptionOfferDetails[0].pricingPhases).thenReturn(pricingPhases)
Mockito.`when`(pricingPhases?.pricingPhaseList).thenReturn(pricingPhaseList)
val formattedPrice = "$9.99" // You can set any desired formatted price here
Mockito.`when`(pricingPhaseList[0]?.formattedPrice).thenReturn(formattedPrice)
Mockito.`when`(pricingPhaseList[0]?.billingPeriod).thenReturn("P1Y")
stubBilling(billingClientState = BillingClientState.Connected(listOf(product)))
val purchase = Mockito.mock(Purchase::class.java)
Mockito.`when`(purchase.products).thenReturn(listOf(androidProductId))
Mockito.`when`(purchase.isAcknowledged).thenReturn(true)
stubPurchaseState(BillingPurchaseState.HasPurchases(listOf(purchase), false))
val flow = flow {
emit(
MembershipStatus(
activeTier = TierId(BUILDER_ID),
status = Membership.Status.STATUS_ACTIVE,
dateEnds = dateEnds,
paymentMethod = MembershipPaymentMethod.METHOD_INAPP_GOOGLE,
anyName = "TestAnyName",
tiers = tiers,
formattedDateEnds = "formattedDateEnds-${RandomString.make()}"
)
)
delay(300)
emit(
MembershipStatus(
activeTier = TierId(EXPLORER_ID),
status = Membership.Status.STATUS_ACTIVE,
dateEnds = 0L,
paymentMethod = MembershipPaymentMethod.METHOD_NONE,
anyName = "TestAnyName",
tiers = tiers,
formattedDateEnds = ""
)
)
}
membershipProvider.stub {
onBlocking { status() } doReturn flow
}
val validPeriod = TierPeriod.Year(1)
val viewModel = buildViewModel()
val mainStateFlow = viewModel.viewState.testIn(backgroundScope)
val tierStateFlow = viewModel.tierState.testIn(backgroundScope)
val firstMainItem = mainStateFlow.awaitItem()
assertIs<MembershipMainState.Loading>(firstMainItem)
val firstTierItem = tierStateFlow.awaitItem()
assertIs<MembershipTierState.Hidden>(firstTierItem)
delay(100)
viewModel.onTierClicked(TierId(BUILDER_ID))
val secondMainItem = mainStateFlow.awaitItem()
secondMainItem.let {
assertIs<MembershipMainState.Default>(secondMainItem)
val builderTier2 = secondMainItem.tiers.find { it.id == TierId(BUILDER_ID) }
val explorerTier2 = secondMainItem.tiers.find { it.id == TierId(EXPLORER_ID) }
assertEquals(true, builderTier2?.isActive)
assertEquals(false, explorerTier2?.isActive)
}
val thirdMainItem = mainStateFlow.awaitItem()
thirdMainItem.let {
assertIs<MembershipMainState.Default>(thirdMainItem)
val builderTier3 = thirdMainItem.tiers.find { it.id == TierId(BUILDER_ID) }
val explorerTier3 = thirdMainItem.tiers.find { it.id == TierId(EXPLORER_ID) }
assertEquals(false, builderTier3?.isActive)
assertEquals(true, explorerTier3?.isActive)
}
val secondTierItem = tierStateFlow.awaitItem()
secondTierItem.let {
assertIs<MembershipTierState.Visible>(secondTierItem)
validateTierView(
expectedId = BUILDER_ID,
expectedActive = true,
expectedFeatures = features,
expectedConditionInfo = TierConditionInfo.Visible.Valid(
dateEnds = dateEnds,
payedBy = MembershipPaymentMethod.METHOD_INAPP_GOOGLE,
period = validPeriod
),
expectedAnyName = TierAnyName.Hidden,
expectedButtonState = TierButton.Manage.Android.Enabled(productId = androidProductId),
tier = secondTierItem.tier,
expectedEmailState = TierEmail.Hidden
)
}
val expectedConditionInfo = TierConditionInfo.Visible.Pending
val thirdTierItem = tierStateFlow.awaitItem()
thirdTierItem.let {
assertIs<MembershipTierState.Visible>(thirdTierItem)
validateTierView(
expectedId = BUILDER_ID,
expectedActive = false,
expectedFeatures = features,
expectedConditionInfo = expectedConditionInfo,
expectedAnyName = TierAnyName.Hidden,
expectedButtonState = TierButton.Hidden,
tier = thirdTierItem.tier,
expectedEmailState = TierEmail.Hidden
)
}
}
}
}

View file

@ -0,0 +1,849 @@
package com.anytypeio.anytype.payments
import app.cash.turbine.turbineScope
import com.anytypeio.anytype.core_models.membership.Membership
import com.anytypeio.anytype.core_models.membership.MembershipPaymentMethod
import com.anytypeio.anytype.core_models.membership.MembershipPeriodType
import com.anytypeio.anytype.payments.constants.MembershipConstants
import com.anytypeio.anytype.payments.models.TierConditionInfo
import com.anytypeio.anytype.payments.models.TierPeriod
import com.anytypeio.anytype.payments.playbilling.BillingPurchaseState
import com.anytypeio.anytype.payments.viewmodel.MembershipMainState
import com.anytypeio.anytype.presentation.membership.models.MembershipStatus
import com.anytypeio.anytype.presentation.membership.models.TierId
import kotlin.test.assertIs
import kotlinx.coroutines.test.runTest
import net.bytebuddy.utility.RandomString
import org.junit.Assert.assertEquals
import org.junit.Test
class TierConditionInfoTests : MembershipTestsSetup() {
@Test
fun `when tier not active and free 1`() = runTest {
turbineScope {
val tiers = listOf(
StubMembershipTierData(
id = MembershipConstants.EXPLORER_ID,
name = "Explorer",
periodType = MembershipPeriodType.PERIOD_TYPE_UNLIMITED,
priceStripeUsdCents = 0,
periodValue = 0
)
)
stubMembershipProvider(
MembershipStatus(
activeTier = TierId(MembershipConstants.CO_CREATOR_ID),
status = Membership.Status.STATUS_ACTIVE,
dateEnds = 1714199910,
paymentMethod = MembershipPaymentMethod.METHOD_CRYPTO,
anyName = RandomString.make(),
tiers = tiers,
formattedDateEnds = "formattedDateEnds-${RandomString.make()}"
)
)
stubBilling()
stubPurchaseState(BillingPurchaseState.Loading)
val viewModel = buildViewModel()
val viewStateFlow = viewModel.viewState.testIn(backgroundScope)
assertIs<MembershipMainState.Loading>(viewStateFlow.awaitItem())
val result = viewStateFlow.awaitItem()
assertIs<MembershipMainState.Default>(result)
val tier = result.tiersPreview[0]
//Asserts
assertEquals(MembershipConstants.EXPLORER_ID, tier.id.value)
assertEquals(
TierConditionInfo.Visible.Free(
period = TierPeriod.Unlimited
), tier.conditionInfo
)
}
}
@Test
fun `when tier not active and free 2`() = runTest {
turbineScope {
val tiers = listOf(
StubMembershipTierData(
id = MembershipConstants.EXPLORER_ID,
name = "Explorer",
periodType = MembershipPeriodType.PERIOD_TYPE_UNKNOWN,
priceStripeUsdCents = 0,
periodValue = 0
)
)
stubMembershipProvider(
MembershipStatus(
activeTier = TierId(MembershipConstants.CO_CREATOR_ID),
status = Membership.Status.STATUS_ACTIVE,
dateEnds = 1714199910,
paymentMethod = MembershipPaymentMethod.METHOD_CRYPTO,
anyName = RandomString.make(),
tiers = tiers,
formattedDateEnds = "formattedDateEnds-${RandomString.make()}"
)
)
stubBilling()
stubPurchaseState(BillingPurchaseState.Loading)
val viewModel = buildViewModel()
val viewStateFlow = viewModel.viewState.testIn(backgroundScope)
assertIs<MembershipMainState.Loading>(viewStateFlow.awaitItem())
val result = viewStateFlow.awaitItem()
assertIs<MembershipMainState.Default>(result)
val tier = result.tiersPreview[0]
//Asserts
assertEquals(MembershipConstants.EXPLORER_ID, tier.id.value)
assertEquals(
TierConditionInfo.Visible.Free(
period = TierPeriod.Unknown
), tier.conditionInfo
)
}
}
@Test
fun `when tier not active and free 3`() = runTest {
turbineScope {
val tiers = listOf(
StubMembershipTierData(
id = MembershipConstants.EXPLORER_ID,
name = "Explorer",
periodType = MembershipPeriodType.PERIOD_TYPE_YEARS,
priceStripeUsdCents = 0,
periodValue = 3
)
)
stubMembershipProvider(
MembershipStatus(
activeTier = TierId(MembershipConstants.CO_CREATOR_ID),
status = Membership.Status.STATUS_ACTIVE,
dateEnds = 1714199910,
paymentMethod = MembershipPaymentMethod.METHOD_CRYPTO,
anyName = RandomString.make(),
tiers = tiers,
formattedDateEnds = "formattedDateEnds-${RandomString.make()}"
)
)
stubBilling()
stubPurchaseState(BillingPurchaseState.Loading)
val viewModel = buildViewModel()
val viewStateFlow = viewModel.viewState.testIn(backgroundScope)
assertIs<MembershipMainState.Loading>(viewStateFlow.awaitItem())
val result = viewStateFlow.awaitItem()
assertIs<MembershipMainState.Default>(result)
val tier = result.tiersPreview[0]
//Asserts
assertEquals(MembershipConstants.EXPLORER_ID, tier.id.value)
assertEquals(
TierConditionInfo.Visible.Free(
period = TierPeriod.Year(3)
), tier.conditionInfo
)
}
}
@Test
fun `when tier not active and not free 1`() = runTest {
turbineScope {
val tiers = listOf(
StubMembershipTierData(
id = MembershipConstants.EXPLORER_ID,
name = "Explorer",
periodType = MembershipPeriodType.PERIOD_TYPE_UNLIMITED,
priceStripeUsdCents = 9999,
periodValue = 0
)
)
stubMembershipProvider(
MembershipStatus(
activeTier = TierId(MembershipConstants.CO_CREATOR_ID),
status = Membership.Status.STATUS_ACTIVE,
dateEnds = 1714199910,
paymentMethod = MembershipPaymentMethod.METHOD_CRYPTO,
anyName = RandomString.make(),
tiers = tiers,
formattedDateEnds = "formattedDateEnds-${RandomString.make()}"
)
)
stubBilling()
stubPurchaseState(BillingPurchaseState.Loading)
val viewModel = buildViewModel()
val viewStateFlow = viewModel.viewState.testIn(backgroundScope)
assertIs<MembershipMainState.Loading>(viewStateFlow.awaitItem())
val result = viewStateFlow.awaitItem()
assertIs<MembershipMainState.Default>(result)
val tier = result.tiersPreview[0]
//Asserts
assertEquals(MembershipConstants.EXPLORER_ID, tier.id.value)
assertEquals(
TierConditionInfo.Visible.Price(
price = "$99.99",
period = TierPeriod.Unlimited
), tier.conditionInfo
)
}
}
@Test
fun `when tier not active and not free 2`() = runTest {
turbineScope {
val tiers = listOf(
StubMembershipTierData(
id = MembershipConstants.EXPLORER_ID,
name = "Explorer",
periodType = MembershipPeriodType.PERIOD_TYPE_UNKNOWN,
priceStripeUsdCents = 9999,
periodValue = 3
)
)
stubMembershipProvider(
MembershipStatus(
activeTier = TierId(MembershipConstants.CO_CREATOR_ID),
status = Membership.Status.STATUS_ACTIVE,
dateEnds = 1714199910,
paymentMethod = MembershipPaymentMethod.METHOD_CRYPTO,
anyName = RandomString.make(),
tiers = tiers,
formattedDateEnds = "formattedDateEnds-${RandomString.make()}"
)
)
stubBilling()
stubPurchaseState(BillingPurchaseState.Loading)
val viewModel = buildViewModel()
val viewStateFlow = viewModel.viewState.testIn(backgroundScope)
assertIs<MembershipMainState.Loading>(viewStateFlow.awaitItem())
val result = viewStateFlow.awaitItem()
assertIs<MembershipMainState.Default>(result)
val tier = result.tiersPreview[0]
//Asserts
assertEquals(MembershipConstants.EXPLORER_ID, tier.id.value)
assertEquals(
TierConditionInfo.Visible.Price(
price = "$99.99",
period = TierPeriod.Unknown,
), tier.conditionInfo
)
}
}
@Test
fun `when tier not active and not free 3`() = runTest {
turbineScope {
val tiers = listOf(
StubMembershipTierData(
id = MembershipConstants.EXPLORER_ID,
name = "Explorer",
periodType = MembershipPeriodType.PERIOD_TYPE_YEARS,
priceStripeUsdCents = 9999,
periodValue = 3
)
)
stubMembershipProvider(
MembershipStatus(
activeTier = TierId(MembershipConstants.CO_CREATOR_ID),
status = Membership.Status.STATUS_ACTIVE,
dateEnds = 1714199910,
paymentMethod = MembershipPaymentMethod.METHOD_CRYPTO,
anyName = RandomString.make(),
tiers = tiers,
formattedDateEnds = "formattedDateEnds-${RandomString.make()}"
)
)
stubBilling()
stubPurchaseState(BillingPurchaseState.Loading)
val viewModel = buildViewModel()
val viewStateFlow = viewModel.viewState.testIn(backgroundScope)
assertIs<MembershipMainState.Loading>(viewStateFlow.awaitItem())
val result = viewStateFlow.awaitItem()
assertIs<MembershipMainState.Default>(result)
val tier = result.tiersPreview[0]
//Asserts
assertEquals(MembershipConstants.EXPLORER_ID, tier.id.value)
assertEquals(
TierConditionInfo.Visible.Price(
price = "$99.99",
period = TierPeriod.Year(3),
), tier.conditionInfo
)
}
}
@Test
fun `when tier active and free 1`() = runTest {
turbineScope {
val tiers = listOf(
StubMembershipTierData(
id = MembershipConstants.EXPLORER_ID,
name = "Explorer",
periodType = MembershipPeriodType.PERIOD_TYPE_UNLIMITED,
priceStripeUsdCents = 0,
periodValue = 0
)
)
stubMembershipProvider(
MembershipStatus(
activeTier = TierId(MembershipConstants.EXPLORER_ID),
status = Membership.Status.STATUS_ACTIVE,
dateEnds = 1714199910,
paymentMethod = MembershipPaymentMethod.METHOD_CRYPTO,
anyName = RandomString.make(),
tiers = tiers,
formattedDateEnds = "formattedDateEnds-${RandomString.make()}"
)
)
stubBilling()
stubPurchaseState(BillingPurchaseState.Loading)
val viewModel = buildViewModel()
val viewStateFlow = viewModel.viewState.testIn(backgroundScope)
assertIs<MembershipMainState.Loading>(viewStateFlow.awaitItem())
val result = viewStateFlow.awaitItem()
assertIs<MembershipMainState.Default>(result)
val tier = result.tiersPreview[0]
//Asserts
assertEquals(MembershipConstants.EXPLORER_ID, tier.id.value)
assertEquals(
TierConditionInfo.Visible.Valid(
period = TierPeriod.Unlimited,
dateEnds = 1714199910,
payedBy = MembershipPaymentMethod.METHOD_CRYPTO
), tier.conditionInfo
)
}
}
@Test
fun `when tier active and free 2`() = runTest {
turbineScope {
val tiers = listOf(
StubMembershipTierData(
id = MembershipConstants.EXPLORER_ID,
name = "Explorer",
periodType = MembershipPeriodType.PERIOD_TYPE_UNKNOWN,
priceStripeUsdCents = 0,
periodValue = 0
)
)
stubMembershipProvider(
MembershipStatus(
activeTier = TierId(MembershipConstants.EXPLORER_ID),
status = Membership.Status.STATUS_ACTIVE,
dateEnds = 1714199910,
paymentMethod = MembershipPaymentMethod.METHOD_CRYPTO,
anyName = RandomString.make(),
tiers = tiers,
formattedDateEnds = "formattedDateEnds-${RandomString.make()}"
)
)
stubBilling()
stubPurchaseState(BillingPurchaseState.Loading)
val viewModel = buildViewModel()
val viewStateFlow = viewModel.viewState.testIn(backgroundScope)
assertIs<MembershipMainState.Loading>(viewStateFlow.awaitItem())
val result = viewStateFlow.awaitItem()
assertIs<MembershipMainState.Default>(result)
val tier = result.tiersPreview[0]
//Asserts
assertEquals(MembershipConstants.EXPLORER_ID, tier.id.value)
assertEquals(
TierConditionInfo.Visible.Valid(
period = TierPeriod.Unknown,
dateEnds = 1714199910,
payedBy = MembershipPaymentMethod.METHOD_CRYPTO
), tier.conditionInfo
)
}
}
@Test
fun `when tier active and free 3`() = runTest {
turbineScope {
val tiers = listOf(
StubMembershipTierData(
id = MembershipConstants.EXPLORER_ID,
name = "Explorer",
periodType = MembershipPeriodType.PERIOD_TYPE_YEARS,
priceStripeUsdCents = 0,
periodValue = 2
)
)
stubMembershipProvider(
MembershipStatus(
activeTier = TierId(MembershipConstants.EXPLORER_ID),
status = Membership.Status.STATUS_ACTIVE,
dateEnds = 1714199910,
paymentMethod = MembershipPaymentMethod.METHOD_CRYPTO,
anyName = RandomString.make(),
tiers = tiers,
formattedDateEnds = "formattedDateEnds-${RandomString.make()}"
)
)
stubBilling()
stubPurchaseState(BillingPurchaseState.Loading)
val viewModel = buildViewModel()
val viewStateFlow = viewModel.viewState.testIn(backgroundScope)
assertIs<MembershipMainState.Loading>(viewStateFlow.awaitItem())
val result = viewStateFlow.awaitItem()
assertIs<MembershipMainState.Default>(result)
val tier = result.tiersPreview[0]
//Asserts
assertEquals(MembershipConstants.EXPLORER_ID, tier.id.value)
assertEquals(
TierConditionInfo.Visible.Valid(
period = TierPeriod.Year(2),
dateEnds = 1714199910,
payedBy = MembershipPaymentMethod.METHOD_CRYPTO
), tier.conditionInfo
)
}
}
@Test
fun `when tier active and not for free 1`() = runTest {
turbineScope {
val tiers = listOf(
StubMembershipTierData(
id = MembershipConstants.BUILDER_ID,
name = "Builder",
periodType = MembershipPeriodType.PERIOD_TYPE_YEARS,
priceStripeUsdCents = 999,
periodValue = 2
)
)
stubMembershipProvider(
MembershipStatus(
activeTier = TierId(MembershipConstants.BUILDER_ID),
status = Membership.Status.STATUS_ACTIVE,
dateEnds = 1714199910,
paymentMethod = MembershipPaymentMethod.METHOD_INAPP_GOOGLE,
anyName = RandomString.make(),
tiers = tiers,
formattedDateEnds = "formattedDateEnds-${RandomString.make()}"
)
)
stubBilling()
stubPurchaseState(BillingPurchaseState.Loading)
val viewModel = buildViewModel()
val viewStateFlow = viewModel.viewState.testIn(backgroundScope)
assertIs<MembershipMainState.Loading>(viewStateFlow.awaitItem())
val result = viewStateFlow.awaitItem()
assertIs<MembershipMainState.Default>(result)
val tier = result.tiersPreview[0]
//Asserts
assertEquals(MembershipConstants.BUILDER_ID, tier.id.value)
assertEquals(
TierConditionInfo.Visible.Valid(
dateEnds = 1714199910,
payedBy = MembershipPaymentMethod.METHOD_INAPP_GOOGLE,
period = TierPeriod.Year(2)
), tier.conditionInfo
)
}
}
@Test
fun `should convert free 4 year tier`() = runTest {
turbineScope {
val tiers = listOf(
StubMembershipTierData(
id = MembershipConstants.EXPLORER_ID,
name = "Explorer",
colorStr = "#000000",
features = listOf("feature1", "feature2"),
periodType = MembershipPeriodType.PERIOD_TYPE_YEARS,
priceStripeUsdCents = 0,
periodValue = 4
)
)
stubMembershipProvider(
MembershipStatus(
activeTier = TierId(MembershipConstants.CO_CREATOR_ID),
status = Membership.Status.STATUS_ACTIVE,
dateEnds = 1714199910,
paymentMethod = MembershipPaymentMethod.METHOD_CRYPTO,
anyName = RandomString.make(),
tiers = tiers,
formattedDateEnds = "formattedDateEnds-${RandomString.make()}"
)
)
stubBilling()
stubPurchaseState(BillingPurchaseState.Loading)
val viewModel = buildViewModel()
val viewStateFlow = viewModel.viewState.testIn(backgroundScope)
assertIs<MembershipMainState.Loading>(viewStateFlow.awaitItem())
val result = viewStateFlow.awaitItem()
assertIs<MembershipMainState.Default>(result)
val tier = result.tiersPreview[0]
//Asserts
assertEquals(MembershipConstants.EXPLORER_ID, tier.id.value)
assertEquals(
TierConditionInfo.Visible.Free(
period = TierPeriod.Year(4)
), tier.conditionInfo
)
}
}
@Test
fun `should convert free 3 months tier`() = runTest {
turbineScope {
val tiers = listOf(
StubMembershipTierData(
id = MembershipConstants.EXPLORER_ID,
name = "Explorer",
colorStr = "#000000",
features = listOf("feature1", "feature2"),
periodType = MembershipPeriodType.PERIOD_TYPE_MONTHS,
priceStripeUsdCents = 0,
periodValue = 3
)
)
stubMembershipProvider(
MembershipStatus(
activeTier = TierId(MembershipConstants.CO_CREATOR_ID),
status = Membership.Status.STATUS_ACTIVE,
dateEnds = 1714199910,
paymentMethod = MembershipPaymentMethod.METHOD_CRYPTO,
anyName = RandomString.make(),
tiers = tiers,
formattedDateEnds = "formattedDateEnds-${RandomString.make()}"
)
)
stubBilling()
stubPurchaseState(BillingPurchaseState.Loading)
val viewModel = buildViewModel()
val viewStateFlow = viewModel.viewState.testIn(backgroundScope)
assertIs<MembershipMainState.Loading>(viewStateFlow.awaitItem())
val result = viewStateFlow.awaitItem()
assertIs<MembershipMainState.Default>(result)
val tier = result.tiersPreview[0]
//Asserts
assertEquals(MembershipConstants.EXPLORER_ID, tier.id.value)
assertEquals(
TierConditionInfo.Visible.Free(
period = TierPeriod.Month(3)
), tier.conditionInfo
)
}
}
@Test
fun `should convert free 12 weeks tier`() = runTest {
turbineScope {
val tiers = listOf(
StubMembershipTierData(
id = MembershipConstants.EXPLORER_ID,
name = "Explorer",
colorStr = "#000000",
features = listOf("feature1", "feature2"),
periodType = MembershipPeriodType.PERIOD_TYPE_WEEKS,
priceStripeUsdCents = 0,
periodValue = 12
)
)
stubMembershipProvider(
MembershipStatus(
activeTier = TierId(MembershipConstants.CO_CREATOR_ID),
status = Membership.Status.STATUS_ACTIVE,
dateEnds = 1714199910,
paymentMethod = MembershipPaymentMethod.METHOD_CRYPTO,
anyName = RandomString.make(),
tiers = tiers,
formattedDateEnds = "formattedDateEnds-${RandomString.make()}"
)
)
stubBilling()
stubPurchaseState(BillingPurchaseState.Loading)
val viewModel = buildViewModel()
val viewStateFlow = viewModel.viewState.testIn(backgroundScope)
assertIs<MembershipMainState.Loading>(viewStateFlow.awaitItem())
val result = viewStateFlow.awaitItem()
assertIs<MembershipMainState.Default>(result)
val tier = result.tiersPreview[0]
//Asserts
assertEquals(MembershipConstants.EXPLORER_ID, tier.id.value)
assertEquals(false, tier.isActive)
assertEquals(
TierConditionInfo.Visible.Free(
period = TierPeriod.Week(12)
), tier.conditionInfo
)
}
}
@Test
fun `should convert free 7 days tier`() = runTest {
turbineScope {
val tiers = listOf(
StubMembershipTierData(
id = MembershipConstants.EXPLORER_ID,
name = "Explorer",
colorStr = "#000000",
features = listOf("feature1", "feature2"),
periodType = MembershipPeriodType.PERIOD_TYPE_DAYS,
priceStripeUsdCents = 0,
periodValue = 7
)
)
stubMembershipProvider(
MembershipStatus(
activeTier = TierId(MembershipConstants.CO_CREATOR_ID),
status = Membership.Status.STATUS_ACTIVE,
dateEnds = 1714199910,
paymentMethod = MembershipPaymentMethod.METHOD_CRYPTO,
anyName = RandomString.make(),
tiers = tiers,
formattedDateEnds = "formattedDateEnds-${RandomString.make()}"
)
)
stubBilling()
stubPurchaseState(BillingPurchaseState.Loading)
val viewModel = buildViewModel()
val viewStateFlow = viewModel.viewState.testIn(backgroundScope)
assertIs<MembershipMainState.Loading>(viewStateFlow.awaitItem())
val result = viewStateFlow.awaitItem()
assertIs<MembershipMainState.Default>(result)
val tier = result.tiersPreview[0]
//Asserts
assertEquals(MembershipConstants.EXPLORER_ID, tier.id.value)
assertEquals(false, tier.isActive)
assertEquals(
TierConditionInfo.Visible.Free(
period = TierPeriod.Day(7)
), tier.conditionInfo
)
}
}
@Test
fun `should convert free unknown period tier`() = runTest {
turbineScope {
val tiers = listOf(
StubMembershipTierData(
id = MembershipConstants.EXPLORER_ID,
name = "Explorer",
colorStr = "#000000",
features = listOf("feature1", "feature2"),
periodType = MembershipPeriodType.PERIOD_TYPE_UNKNOWN,
priceStripeUsdCents = 0,
periodValue = 7
)
)
stubMembershipProvider(
MembershipStatus(
activeTier = TierId(MembershipConstants.CO_CREATOR_ID),
status = Membership.Status.STATUS_ACTIVE,
dateEnds = 1714199910,
paymentMethod = MembershipPaymentMethod.METHOD_CRYPTO,
anyName = RandomString.make(),
tiers = tiers,
formattedDateEnds = "formattedDateEnds-${RandomString.make()}"
)
)
stubBilling()
stubPurchaseState(BillingPurchaseState.Loading)
val viewModel = buildViewModel()
val viewStateFlow = viewModel.viewState.testIn(backgroundScope)
assertIs<MembershipMainState.Loading>(viewStateFlow.awaitItem())
val result = viewStateFlow.awaitItem()
assertIs<MembershipMainState.Default>(result)
val tier = result.tiersPreview[0]
//Asserts
assertEquals(MembershipConstants.EXPLORER_ID, tier.id.value)
assertEquals(false, tier.isActive)
assertEquals(
TierConditionInfo.Visible.Free(period = TierPeriod.Unknown),
tier.conditionInfo
)
}
}
//endregion
//region PAID NON ACTIVE TIERS
@Test
fun `should convert price unknown period tier`() = runTest {
turbineScope {
val tiers = listOf(
StubMembershipTierData(
id = MembershipConstants.EXPLORER_ID,
name = "Explorer",
colorStr = "#000000",
features = listOf("feature1", "feature2"),
periodType = MembershipPeriodType.PERIOD_TYPE_UNKNOWN,
priceStripeUsdCents = 0,
periodValue = 7
)
)
stubMembershipProvider(
MembershipStatus(
activeTier = TierId(MembershipConstants.CO_CREATOR_ID),
status = Membership.Status.STATUS_ACTIVE,
dateEnds = 1714199910,
paymentMethod = MembershipPaymentMethod.METHOD_CRYPTO,
anyName = RandomString.make(),
tiers = tiers,
formattedDateEnds = "formattedDateEnds-${RandomString.make()}"
)
)
stubBilling()
stubPurchaseState(BillingPurchaseState.Loading)
val viewModel = buildViewModel()
val viewStateFlow = viewModel.viewState.testIn(backgroundScope)
assertIs<MembershipMainState.Loading>(viewStateFlow.awaitItem())
val result = viewStateFlow.awaitItem()
assertIs<MembershipMainState.Default>(result)
val tier = result.tiersPreview[0]
//Asserts
assertEquals(MembershipConstants.EXPLORER_ID, tier.id.value)
assertEquals(false, tier.isActive)
assertEquals(
TierConditionInfo.Visible.Free(period = TierPeriod.Unknown),
tier.conditionInfo
)
}
}
@Test
fun `should convert price tier with unlimited period`() = runTest {
turbineScope {
val tiers = listOf(
StubMembershipTierData(
id = MembershipConstants.EXPLORER_ID,
name = "Explorer",
colorStr = "#000000",
features = listOf("feature1", "feature2"),
periodType = MembershipPeriodType.PERIOD_TYPE_UNLIMITED,
priceStripeUsdCents = 999, // Example price in cents
periodValue = 0
)
)
stubMembershipProvider(
MembershipStatus(
activeTier = TierId(MembershipConstants.CO_CREATOR_ID),
status = Membership.Status.STATUS_ACTIVE,
dateEnds = 1714199910,
paymentMethod = MembershipPaymentMethod.METHOD_CRYPTO,
anyName = RandomString.make(),
tiers = tiers,
formattedDateEnds = "formattedDateEnds-${RandomString.make()}"
)
)
stubBilling()
stubPurchaseState(BillingPurchaseState.Loading)
val viewModel = buildViewModel()
val viewStateFlow = viewModel.viewState.testIn(backgroundScope)
assertIs<MembershipMainState.Loading>(viewStateFlow.awaitItem())
val result = viewStateFlow.awaitItem()
assertIs<MembershipMainState.Default>(result)
val tier = result.tiersPreview[0]
//Asserts
assertEquals(MembershipConstants.EXPLORER_ID, tier.id.value)
assertEquals(false, tier.isActive)
assertEquals(
TierConditionInfo.Visible.Price(
price = "$9.99", // Example price in cents
period = TierPeriod.Unlimited
), tier.conditionInfo
)
}
}
@Test
fun `should convert price tier with 3 years period`() = runTest {
turbineScope {
val tiers = listOf(
StubMembershipTierData(
id = MembershipConstants.EXPLORER_ID,
name = "Explorer",
colorStr = "#000000",
features = listOf("feature1", "feature2"),
periodType = MembershipPeriodType.PERIOD_TYPE_YEARS,
priceStripeUsdCents = 4999, // Example price in cents
periodValue = 3
)
)
stubMembershipProvider(
MembershipStatus(
activeTier = TierId(MembershipConstants.CO_CREATOR_ID),
status = Membership.Status.STATUS_ACTIVE,
dateEnds = 1714199910,
paymentMethod = MembershipPaymentMethod.METHOD_CRYPTO,
anyName = RandomString.make(),
tiers = tiers,
formattedDateEnds = "formattedDateEnds-${RandomString.make()}"
)
)
stubBilling()
stubPurchaseState(BillingPurchaseState.Loading)
val viewModel = buildViewModel()
val viewStateFlow = viewModel.viewState.testIn(backgroundScope)
assertIs<MembershipMainState.Loading>(viewStateFlow.awaitItem())
val result = viewStateFlow.awaitItem()
assertIs<MembershipMainState.Default>(result)
val tier = result.tiersPreview[0]
//Asserts
assertEquals(MembershipConstants.EXPLORER_ID, tier.id.value)
assertEquals(false, tier.isActive)
assertEquals(
TierConditionInfo.Visible.Price(
price = "$49.99", // Example price in cents
period = TierPeriod.Year(3)
), tier.conditionInfo
)
}
}
}