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

@ -63,6 +63,7 @@ import com.anytypeio.anytype.di.feature.cover.UnsplashModule
import com.anytypeio.anytype.di.feature.gallery.DaggerGalleryInstallationComponent
import com.anytypeio.anytype.di.feature.home.DaggerHomeScreenComponent
import com.anytypeio.anytype.di.feature.library.DaggerLibraryComponent
import com.anytypeio.anytype.di.feature.membership.DaggerMembershipComponent
import com.anytypeio.anytype.di.feature.multiplayer.DaggerRequestJoinSpaceComponent
import com.anytypeio.anytype.di.feature.multiplayer.DaggerShareSpaceComponent
import com.anytypeio.anytype.di.feature.multiplayer.DaggerSpaceJoinRequestComponent
@ -73,7 +74,6 @@ import com.anytypeio.anytype.di.feature.onboarding.DaggerOnboardingStartComponen
import com.anytypeio.anytype.di.feature.onboarding.login.DaggerOnboardingMnemonicLoginComponent
import com.anytypeio.anytype.di.feature.onboarding.signup.DaggerOnboardingMnemonicComponent
import com.anytypeio.anytype.di.feature.onboarding.signup.DaggerOnboardingSoulCreationComponent
import com.anytypeio.anytype.di.feature.payments.DaggerPaymentsComponent
import com.anytypeio.anytype.di.feature.relations.DaggerRelationCreateFromLibraryComponent
import com.anytypeio.anytype.di.feature.relations.DaggerRelationEditComponent
import com.anytypeio.anytype.di.feature.relations.LimitObjectTypeModule
@ -1132,8 +1132,8 @@ class ComponentManager(
.build()
}
val paymentsComponent = Component {
DaggerPaymentsComponent.factory().create(findComponentDependencies())
val membershipComponent = Component {
DaggerMembershipComponent.factory().create(findComponentDependencies())
}
val galleryInstallationsComponent =

View file

@ -0,0 +1,115 @@
package com.anytypeio.anytype.di.feature.membership
import android.content.Context
import androidx.lifecycle.ViewModelProvider
import com.anytypeio.anytype.analytics.base.Analytics
import com.anytypeio.anytype.core_utils.di.scope.PerScreen
import com.anytypeio.anytype.di.common.ComponentDependencies
import com.anytypeio.anytype.domain.auth.interactor.GetAccount
import com.anytypeio.anytype.domain.auth.repo.AuthRepository
import com.anytypeio.anytype.domain.base.AppCoroutineDispatchers
import com.anytypeio.anytype.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.playbilling.BillingClientLifecycle
import com.anytypeio.anytype.ui.payments.MembershipFragment
import com.anytypeio.anytype.payments.viewmodel.MembershipViewModelFactory
import com.anytypeio.anytype.presentation.membership.provider.MembershipProvider
import dagger.Binds
import dagger.Component
import dagger.Module
import dagger.Provides
@Component(
dependencies = [MembershipComponentDependencies::class],
modules = [
MembershipModule::class,
MembershipModule.Declarations::class
]
)
@PerScreen
interface MembershipComponent {
@Component.Factory
interface Factory {
fun create(dependencies: MembershipComponentDependencies): MembershipComponent
}
fun inject(fragment: MembershipFragment)
}
@Module
object MembershipModule {
@JvmStatic
@Provides
@PerScreen
fun provideGetAccountUseCase(
repo: AuthRepository,
dispatchers: AppCoroutineDispatchers
): GetAccount = GetAccount(repo = repo, dispatcher = dispatchers)
@JvmStatic
@Provides
@PerScreen
fun provideGetPaymentsUrl(
repo: BlockRepository,
dispatchers: AppCoroutineDispatchers
): GetMembershipPaymentUrl = GetMembershipPaymentUrl(repo = repo, dispatchers = dispatchers)
@JvmStatic
@Provides
@PerScreen
fun provideIsNameValid(
repo: BlockRepository,
dispatchers: AppCoroutineDispatchers
): IsMembershipNameValid = IsMembershipNameValid(repo = repo, dispatchers = dispatchers)
@JvmStatic
@Provides
@PerScreen
fun provideGetEmailStatus(
repo: BlockRepository,
dispatchers: AppCoroutineDispatchers
): GetMembershipEmailStatus = GetMembershipEmailStatus(repo = repo, dispatchers = dispatchers)
@JvmStatic
@Provides
@PerScreen
fun provideSetMembershipEmail(
repo: BlockRepository,
dispatchers: AppCoroutineDispatchers
): SetMembershipEmail = SetMembershipEmail(repo = repo, dispatchers = dispatchers)
@JvmStatic
@Provides
@PerScreen
fun provideVerifyEmailCode(
repo: BlockRepository,
dispatchers: AppCoroutineDispatchers
): VerifyMembershipEmailCode = VerifyMembershipEmailCode(repo = repo, dispatchers = dispatchers)
@Module
interface Declarations {
@PerScreen
@Binds
fun bindViewModelFactory(
factory: MembershipViewModelFactory
): ViewModelProvider.Factory
}
}
interface MembershipComponentDependencies : ComponentDependencies {
fun analytics(): Analytics
fun context(): Context
fun billingListener(): BillingClientLifecycle
fun authRepository(): AuthRepository
fun blockRepository(): BlockRepository
fun appCoroutineDispatchers(): AppCoroutineDispatchers
fun provideMembershipProvider(): MembershipProvider
}

View file

@ -1,77 +0,0 @@
package com.anytypeio.anytype.di.feature.payments
import android.content.Context
import androidx.lifecycle.ViewModelProvider
import com.anytypeio.anytype.analytics.base.Analytics
import com.anytypeio.anytype.core_utils.di.scope.PerScreen
import com.anytypeio.anytype.di.common.ComponentDependencies
import com.anytypeio.anytype.domain.auth.interactor.GetAccount
import com.anytypeio.anytype.domain.auth.repo.AuthRepository
import com.anytypeio.anytype.domain.base.AppCoroutineDispatchers
import com.anytypeio.anytype.domain.block.repo.BlockRepository
import com.anytypeio.anytype.domain.payments.GetMembershipTiers
import com.anytypeio.anytype.payments.playbilling.BillingClientLifecycle
import com.anytypeio.anytype.ui.payments.PaymentsFragment
import com.anytypeio.anytype.payments.viewmodel.PaymentsViewModelFactory
import dagger.Binds
import dagger.Component
import dagger.Module
import dagger.Provides
@Component(
dependencies = [PaymentsComponentDependencies::class],
modules = [
PaymentsModule::class,
PaymentsModule.Declarations::class
]
)
@PerScreen
interface PaymentsComponent {
@Component.Factory
interface Factory {
fun create(dependencies: PaymentsComponentDependencies): PaymentsComponent
}
fun inject(fragment: PaymentsFragment)
}
@Module
object PaymentsModule {
@JvmStatic
@Provides
@PerScreen
fun provideGetAccountUseCase(
repo: AuthRepository,
dispatchers: AppCoroutineDispatchers
): GetAccount = GetAccount(repo = repo, dispatcher = dispatchers)
@JvmStatic
@Provides
@PerScreen
fun provideGetTiersUseCase(
repo: BlockRepository,
dispatchers: AppCoroutineDispatchers
): GetMembershipTiers = GetMembershipTiers(repo = repo, dispatchers = dispatchers)
@Module
interface Declarations {
@PerScreen
@Binds
fun bindViewModelFactory(
factory: PaymentsViewModelFactory
): ViewModelProvider.Factory
}
}
interface PaymentsComponentDependencies : ComponentDependencies {
fun analytics(): Analytics
fun context(): Context
fun billingListener(): BillingClientLifecycle
fun authRepository(): AuthRepository
fun blockRepository(): BlockRepository
fun appCoroutineDispatchers(): AppCoroutineDispatchers
}

View file

@ -16,6 +16,7 @@ import com.anytypeio.anytype.domain.library.StorelessSubscriptionContainer
import com.anytypeio.anytype.domain.misc.UrlBuilder
import com.anytypeio.anytype.domain.`object`.SetObjectDetails
import com.anytypeio.anytype.domain.workspace.SpaceManager
import com.anytypeio.anytype.presentation.membership.provider.MembershipProvider
import com.anytypeio.anytype.presentation.spaces.SpaceGradientProvider
import com.anytypeio.anytype.ui.settings.ProfileSettingsFragment
import com.anytypeio.anytype.ui_settings.account.ProfileSettingsViewModel
@ -49,7 +50,8 @@ object ProfileModule {
setObjectDetails: SetObjectDetails,
configStorage: ConfigStorage,
urlBuilder: UrlBuilder,
setDocumentImageIcon: SetDocumentImageIcon
setDocumentImageIcon: SetDocumentImageIcon,
membershipProvider: MembershipProvider
): ProfileSettingsViewModel.Factory = ProfileSettingsViewModel.Factory(
deleteAccount = deleteAccount,
analytics = analytics,
@ -57,7 +59,8 @@ object ProfileModule {
setObjectDetails = setObjectDetails,
configStorage = configStorage,
urlBuilder = urlBuilder,
setDocumentImageIcon = setDocumentImageIcon
setDocumentImageIcon = setDocumentImageIcon,
membershipProvider = membershipProvider
)
@Provides

View file

@ -32,7 +32,7 @@ import com.anytypeio.anytype.di.feature.onboarding.OnboardingStartDependencies
import com.anytypeio.anytype.di.feature.onboarding.login.OnboardingMnemonicLoginDependencies
import com.anytypeio.anytype.di.feature.onboarding.signup.OnboardingMnemonicDependencies
import com.anytypeio.anytype.di.feature.onboarding.signup.OnboardingSoulCreationDependencies
import com.anytypeio.anytype.di.feature.payments.PaymentsComponentDependencies
import com.anytypeio.anytype.di.feature.membership.MembershipComponentDependencies
import com.anytypeio.anytype.di.feature.relations.RelationCreateFromLibraryDependencies
import com.anytypeio.anytype.di.feature.relations.RelationEditDependencies
import com.anytypeio.anytype.di.feature.settings.AboutAppDependencies
@ -118,7 +118,7 @@ interface MainComponent :
ShareSpaceDependencies,
SpaceJoinRequestDependencies,
RequestJoinSpaceDependencies,
PaymentsComponentDependencies,
MembershipComponentDependencies,
GalleryInstallationComponentDependencies,
NotificationDependencies
{
@ -314,8 +314,8 @@ abstract class ComponentDependenciesModule {
@Binds
@IntoMap
@ComponentDependenciesKey(PaymentsComponentDependencies::class)
abstract fun providePaymentsComponentDependencies(component: MainComponent): ComponentDependencies
@ComponentDependenciesKey(MembershipComponentDependencies::class)
abstract fun provideMembershipComponentDependencies(component: MainComponent): ComponentDependencies
@Binds
@IntoMap

View file

@ -0,0 +1,263 @@
package com.anytypeio.anytype.ui.payments
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.material.MaterialTheme
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.ui.res.stringResource
import androidx.fragment.app.viewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.anytypeio.anytype.BuildConfig
import com.anytypeio.anytype.R
import com.anytypeio.anytype.core_ui.common.ComposeDialogView
import com.anytypeio.anytype.core_ui.views.BaseTwoButtonsDarkThemeAlertDialog
import com.anytypeio.anytype.core_utils.ext.setupBottomSheetBehavior
import com.anytypeio.anytype.core_utils.ext.subscribe
import com.anytypeio.anytype.core_utils.ext.toast
import com.anytypeio.anytype.core_utils.intents.SystemAction
import com.anytypeio.anytype.core_utils.intents.proceedWithAction
import com.anytypeio.anytype.core_utils.ui.BaseBottomSheetComposeFragment
import com.anytypeio.anytype.di.common.componentManager
import com.anytypeio.anytype.payments.playbilling.BillingClientLifecycle
import com.anytypeio.anytype.payments.screens.CodeScreen
import com.anytypeio.anytype.payments.screens.MainMembershipScreen
import com.anytypeio.anytype.payments.screens.WelcomeScreen
import com.anytypeio.anytype.payments.screens.TierViewScreen
import com.anytypeio.anytype.payments.viewmodel.MembershipErrorState
import com.anytypeio.anytype.ui.settings.typography
import com.anytypeio.anytype.payments.viewmodel.MembershipNavigation
import com.anytypeio.anytype.payments.viewmodel.MembershipViewModel
import com.anytypeio.anytype.payments.viewmodel.MembershipViewModelFactory
import com.anytypeio.anytype.payments.viewmodel.TierAction
import com.google.accompanist.navigation.material.BottomSheetNavigator
import com.google.accompanist.navigation.material.ExperimentalMaterialNavigationApi
import com.google.accompanist.navigation.material.ModalBottomSheetLayout
import com.google.accompanist.navigation.material.bottomSheet
import com.google.accompanist.navigation.material.rememberBottomSheetNavigator
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import javax.inject.Inject
import timber.log.Timber
class MembershipFragment : BaseBottomSheetComposeFragment() {
@Inject
lateinit var factory: MembershipViewModelFactory
private val vm by viewModels<MembershipViewModel> { factory }
private lateinit var navController: NavHostController
@Inject
lateinit var billingClientLifecycle: BillingClientLifecycle
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycleScope.subscribe(vm.initBillingClient) { init ->
if (init) {
lifecycle.addObserver(billingClientLifecycle)
}
}
}
@OptIn(ExperimentalMaterialNavigationApi::class)
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return ComposeDialogView(context = requireContext(), dialog = requireDialog()).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
MaterialTheme(typography = typography) {
val bottomSheetNavigator = rememberBottomSheetNavigator()
navController = rememberNavController(bottomSheetNavigator)
SetupNavigation(bottomSheetNavigator, navController)
ErrorScreen()
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ErrorScreen() {
val errorStateScreen = vm.errorState.collectAsStateWithLifecycle()
when (val state = errorStateScreen.value) {
MembershipErrorState.Hidden -> {
//do nothing
}
is MembershipErrorState.Show -> {
BaseTwoButtonsDarkThemeAlertDialog(
dialogText = state.message,
dismissButtonText = stringResource(id = R.string.membership_error_button_text_dismiss),
actionButtonText = stringResource(id = R.string.membership_error_button_text_action),
onActionButtonClick = { vm.onTierAction(TierAction.ContactUsError(state.message)) },
onDismissButtonClick = { vm.hideError() },
onDismissRequest = { vm.hideError() }
)
}
}
}
@OptIn(ExperimentalMaterialNavigationApi::class)
@Composable
private fun SetupNavigation(
bottomSheetNavigator: BottomSheetNavigator,
navController: NavHostController
) {
ModalBottomSheetLayout(bottomSheetNavigator = bottomSheetNavigator) {
NavigationGraph(navController = navController)
}
}
@OptIn(ExperimentalMaterialNavigationApi::class)
@Composable
private fun NavigationGraph(navController: NavHostController) {
NavHost(navController = navController, startDestination = MembershipNavigation.Main.route) {
composable(MembershipNavigation.Main.route) {
InitMainScreen()
}
bottomSheet(MembershipNavigation.Tier.route) {
InitTierScreen()
}
bottomSheet(MembershipNavigation.Code.route) {
InitCodeScreen()
}
bottomSheet(MembershipNavigation.Welcome.route) {
InitWelcomeScreen()
}
}
}
@Composable
private fun InitMainScreen() {
skipCollapsed()
expand()
MainMembershipScreen(
state = vm.viewState.collectAsStateWithLifecycle().value,
tierClicked = vm::onTierClicked,
tierAction = vm::onTierAction
)
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun InitTierScreen() {
TierViewScreen(
state = vm.tierState.collectAsStateWithLifecycle().value,
onDismiss = vm::onDismissTier,
actionTier = vm::onTierAction,
anyNameTextField = vm.anyNameState,
anyEmailTextField = vm.anyEmailState
)
}
@Composable
private fun InitCodeScreen() {
CodeScreen(
state = vm.codeState.collectAsStateWithLifecycle().value,
action = vm::onTierAction,
onDismiss = vm::onDismissCode
)
}
@Composable
private fun InitWelcomeScreen() {
WelcomeScreen(
state = vm.welcomeState.collectAsStateWithLifecycle().value,
onDismiss = vm::onDismissWelcome
)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupBottomSheetBehavior(DEFAULT_PADDING_TOP)
subscribe(vm.navigation) { command ->
Timber.d("MembershipFragment command: $command")
when (command) {
MembershipNavigation.Tier -> navController.navigate(MembershipNavigation.Tier.route)
MembershipNavigation.Code -> navController.navigate(MembershipNavigation.Code.route)
MembershipNavigation.Welcome -> {
navController.popBackStack(MembershipNavigation.Main.route, false)
navController.navigate(MembershipNavigation.Welcome.route)
}
MembershipNavigation.Dismiss -> navController.popBackStack()
is MembershipNavigation.OpenUrl -> {
try {
if (command.url == null) {
toast("Url is null")
return@subscribe
}
Intent(Intent.ACTION_VIEW).apply {
data = Uri.parse(command.url)
}.let {
startActivity(it)
}
} catch (e: Throwable) {
toast("Couldn't parse url: ${command.url}")
}
}
MembershipNavigation.Main -> {}
is MembershipNavigation.OpenEmail -> {
val mail = resources.getString(R.string.payments_email_to)
val subject = resources.getString(R.string.payments_email_subject, command.accountId)
val body = resources.getString(R.string.payments_email_body)
val mailBody = mail +
"?subject=$subject" +
"&body=$body"
proceedWithAction(SystemAction.MailTo(mailBody))
}
is MembershipNavigation.OpenErrorEmail -> {
val deviceModel = android.os.Build.MODEL
val osVersion = android.os.Build.VERSION.RELEASE
val appVersion = BuildConfig.VERSION_NAME
val currentDateTime: String
val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
currentDateTime = sdf.format(Date())
val mail = resources.getString(R.string.membership_support_email)
val subject = resources.getString(R.string.membership_support_subject, command.accountId)
val body = getString(
R.string.membership_support_body,
command.error, currentDateTime, deviceModel, osVersion, appVersion
)
val mailBody = mail +
"?subject=$subject" +
"&body=$body"
proceedWithAction(SystemAction.MailTo(mailBody))
}
}
}
subscribe(vm.launchBillingCommand) { event ->
billingClientLifecycle.launchBillingFlow(
activity = requireActivity(),
params = event
)
}
}
override fun onDestroy() {
lifecycle.removeObserver(billingClientLifecycle)
super.onDestroy()
}
override fun injectDependencies() {
componentManager().membershipComponent.get().inject(this)
}
override fun releaseDependencies() {
componentManager().membershipComponent.release()
}
}

View file

@ -1,166 +0,0 @@
package com.anytypeio.anytype.ui.payments
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.fragment.app.viewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.anytypeio.anytype.core_ui.common.ComposeDialogView
import com.anytypeio.anytype.core_utils.ext.subscribe
import com.anytypeio.anytype.core_utils.ui.BaseBottomSheetComposeFragment
import com.anytypeio.anytype.di.common.componentManager
import com.anytypeio.anytype.payments.playbilling.BillingClientLifecycle
import com.anytypeio.anytype.payments.screens.CodeScreen
import com.anytypeio.anytype.payments.screens.MainPaymentsScreen
import com.anytypeio.anytype.payments.screens.PaymentWelcomeScreen
import com.anytypeio.anytype.payments.screens.TierScreen
import com.anytypeio.anytype.ui.settings.typography
import com.anytypeio.anytype.payments.viewmodel.PaymentsNavigation
import com.anytypeio.anytype.payments.viewmodel.PaymentsViewModel
import com.anytypeio.anytype.payments.viewmodel.PaymentsViewModelFactory
import com.google.accompanist.navigation.material.BottomSheetNavigator
import com.google.accompanist.navigation.material.ExperimentalMaterialNavigationApi
import com.google.accompanist.navigation.material.ModalBottomSheetLayout
import com.google.accompanist.navigation.material.bottomSheet
import com.google.accompanist.navigation.material.rememberBottomSheetNavigator
import javax.inject.Inject
class PaymentsFragment : BaseBottomSheetComposeFragment() {
@Inject
lateinit var factory: PaymentsViewModelFactory
private val vm by viewModels<PaymentsViewModel> { factory }
private lateinit var navController: NavHostController
@Inject
lateinit var billingClientLifecycle: BillingClientLifecycle
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycle.addObserver(billingClientLifecycle)
}
@OptIn(ExperimentalMaterialNavigationApi::class)
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return ComposeDialogView(context = requireContext(), dialog = requireDialog()).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
MaterialTheme(typography = typography) {
val bottomSheetNavigator = rememberBottomSheetNavigator()
navController = rememberNavController(bottomSheetNavigator)
SetupNavigation(bottomSheetNavigator, navController)
}
}
}
}
override fun onStart() {
super.onStart()
jobs += subscribe(vm.command) { command ->
when (command) {
PaymentsNavigation.Tier -> navController.navigate(PaymentsNavigation.Tier.route)
PaymentsNavigation.Code -> navController.navigate(PaymentsNavigation.Code.route)
PaymentsNavigation.Welcome -> {
navController.popBackStack(PaymentsNavigation.Main.route, false)
navController.navigate(PaymentsNavigation.Welcome.route)
}
PaymentsNavigation.Dismiss -> navController.popBackStack()
else -> {}
}
}
jobs += subscribe(vm.launchBillingCommand) { event ->
billingClientLifecycle.launchBillingFlow(
activity = requireActivity(),
params = event
)
}
}
@OptIn(ExperimentalMaterialNavigationApi::class)
@Composable
private fun SetupNavigation(
bottomSheetNavigator: BottomSheetNavigator,
navController: NavHostController
) {
ModalBottomSheetLayout(bottomSheetNavigator = bottomSheetNavigator) {
NavigationGraph(navController = navController)
}
}
@OptIn(ExperimentalMaterialNavigationApi::class)
@Composable
private fun NavigationGraph(navController: NavHostController) {
NavHost(navController = navController, startDestination = PaymentsNavigation.Main.route) {
composable(PaymentsNavigation.Main.route) {
InitMainPaymentsScreen()
}
bottomSheet(PaymentsNavigation.Tier.route) {
InitTierScreen()
}
bottomSheet(PaymentsNavigation.Code.route) {
InitCodeScreen()
}
bottomSheet(PaymentsNavigation.Welcome.route) {
InitWelcomeScreen()
}
}
}
@Composable
private fun InitMainPaymentsScreen() {
skipCollapsed()
expand()
MainPaymentsScreen(
state = vm.viewState.collectAsStateWithLifecycle().value,
tierClicked = vm::onTierClicked
)
}
@Composable
private fun InitTierScreen() {
TierScreen(
state = vm.tierState.collectAsStateWithLifecycle().value,
onDismiss = vm::onDismissTier,
actionPay = vm::onPayButtonClicked,
actionSubmitEmail = vm::onSubmitEmailButtonClicked
)
}
@Composable
private fun InitCodeScreen() {
CodeScreen(
state = vm.codeState.collectAsStateWithLifecycle().value,
actionResend = { },
actionCode = vm::onActionCode,
onDismiss = vm::onDismissCode
)
}
@Composable
private fun InitWelcomeScreen() {
PaymentWelcomeScreen(
state = vm.welcomeState.collectAsStateWithLifecycle().value,
onDismiss = vm::onDismissWelcome
)
}
override fun injectDependencies() {
componentManager().paymentsComponent.get().inject(this)
}
override fun releaseDependencies() {
componentManager().paymentsComponent.release()
}
}

View file

@ -30,8 +30,6 @@ import com.anytypeio.anytype.core_utils.tools.FeatureToggles
import com.anytypeio.anytype.core_utils.ui.BaseBottomSheetComposeFragment
import com.anytypeio.anytype.di.common.componentManager
import com.anytypeio.anytype.other.MediaPermissionHelper
import com.anytypeio.anytype.payments.viewmodel.PaymentsViewModel
import com.anytypeio.anytype.payments.viewmodel.PaymentsViewModelFactory
import com.anytypeio.anytype.ui.auth.account.DeleteAccountWarning
import com.anytypeio.anytype.ui.profile.KeychainPhraseDialog
import com.anytypeio.anytype.ui_settings.account.ProfileSettingsScreen
@ -46,14 +44,10 @@ class ProfileSettingsFragment : BaseBottomSheetComposeFragment() {
@Inject
lateinit var factory: ProfileSettingsViewModel.Factory
@Inject
lateinit var factoryPayments: PaymentsViewModelFactory
@Inject
lateinit var toggles: FeatureToggles
private val vm by viewModels<ProfileSettingsViewModel> { factory }
private val viewModelPayments by viewModels<PaymentsViewModel> { factoryPayments }
private val onKeychainPhraseClicked = {
val bundle =
@ -120,7 +114,7 @@ class ProfileSettingsFragment : BaseBottomSheetComposeFragment() {
findNavController().navigate(R.id.paymentsScreen)
}
),
activeTierName = viewModelPayments.activeTierName.collectAsStateWithLifecycle().value,
membershipStatus = vm.membershipStatusState.collectAsStateWithLifecycle().value,
onSpacesClicked = throttledClick(
onClick = {
runCatching {

View file

@ -274,7 +274,7 @@
<dialog
android:id="@+id/paymentsScreen"
android:name="com.anytypeio.anytype.ui.payments.PaymentsFragment" />
android:name="com.anytypeio.anytype.ui.payments.MembershipFragment" />
<dialog
android:id="@+id/galleryInstallationScreen"

View file

@ -479,7 +479,15 @@ sealed class Command {
sealed class Membership {
data class GetStatus(val noCache: Boolean) : Membership()
data class IsNameValid(val tier: Int, val name: String) : Membership()
data class IsNameValid(
val tier: Int,
val name: String,
val nameType: NameServiceNameType
) : Membership()
data class ResolveName(
val name: String,
val nameType: NameServiceNameType
) : Membership()
data class GetPaymentUrl(
val tier: Int,
val paymentMethod: MembershipPaymentMethod,

View file

@ -1,52 +1,77 @@
package com.anytypeio.anytype.core_models.membership
sealed class MembershipErrors() {
data object NotLoggedIn : MembershipErrors()
data class PaymentNodeError(val message: String) : MembershipErrors()
data class CacheError(val message: String) : MembershipErrors()
data class BadAnyName(val message: String) : MembershipErrors()
sealed class MembershipErrors : Exception() {
sealed class GetStatus : MembershipErrors() {
data object MembershipNotFound : GetStatus()
data class MembershipWrongState(val message: String) : GetStatus()
data class MembershipNotFound(override val message: String) : GetStatus()
data class MembershipWrongState(override val message: String) : GetStatus()
}
sealed class IsNameValid : MembershipErrors() {
data object TooShort : IsNameValid()
data object TooLong : IsNameValid()
data object HasInvalidChars : IsNameValid()
data object TierFeaturesNoName : IsNameValid()
data object TierNotFound : IsNameValid()
data class Null(override val message: String) : IsNameValid()
data class BadInput(override val message: String) : IsNameValid()
data class UnknownError(override val message: String) : IsNameValid()
data class NotLoggedIn(override val message: String) : IsNameValid()
data class PaymentNodeError(override val message: String) : IsNameValid()
data class CacheError(override val message: String) : IsNameValid()
data class TooShort(override val message: String) : IsNameValid()
data class TooLong(override val message: String) : IsNameValid()
data class HasInvalidChars(override val message: String) : IsNameValid()
data class TierFeaturesNoName(override val message: String) : IsNameValid()
data class TierNotFound(override val message: String) : IsNameValid()
data class CanNotReserve(override val message: String) : IsNameValid()
data class CanNotConnect(override val message: String) : IsNameValid()
data class NameIsReserved(override val message: String) : IsNameValid()
}
sealed class ResolveName : MembershipErrors() {
data class Null(override val message: String) : ResolveName()
data class UnknownError(override val message: String) : ResolveName()
data class BadInput(override val message: String) : ResolveName()
data class CanNotConnect(override val message: String) : ResolveName()
data object NotAvailable : ResolveName()
}
sealed class GetPaymentUrl : MembershipErrors() {
data class TierNotFound(val message: String) : GetPaymentUrl()
data class TierInvalid(val message: String) : GetPaymentUrl()
data class PaymentMethodInvalid(val message: String) : GetPaymentUrl()
data class BadAnyName(val message: String) : GetPaymentUrl()
data class MembershipAlreadyExists(val message: String) : GetPaymentUrl()
data class TierNotFound(override val message: String) : GetPaymentUrl()
data class TierInvalid(override val message: String) : GetPaymentUrl()
data class PaymentMethodInvalid(override val message: String) : GetPaymentUrl()
data class BadAnyName(override val message: String) : GetPaymentUrl()
data class MembershipAlreadyExists(override val message: String) : GetPaymentUrl()
}
sealed class FinalizePayment : MembershipErrors() {
data class MembershipNotFound(val message: String) : FinalizePayment()
data class MembershipWrongState(val message: String) : FinalizePayment()
data class MembershipNotFound(override val message: String) : FinalizePayment()
data class MembershipWrongState(override val message: String) : FinalizePayment()
}
sealed class GetVerificationEmail : MembershipErrors() {
data class EmailWrongFormat(val message: String) : GetVerificationEmail()
data class EmailAlreadyVerified(val message: String) : GetVerificationEmail()
data class EmailAlreadySent(val message: String) : GetVerificationEmail()
data class EmailFailedToSend(val message: String) : GetVerificationEmail()
data class MembershipAlreadyExists(val message: String) : GetVerificationEmail()
data class EmailWrongFormat(override val message: String) : GetVerificationEmail()
data class EmailAlreadyVerified(override val message: String) : GetVerificationEmail()
data class EmailAlreadySent(override val message: String) : GetVerificationEmail()
data class EmailFailedToSend(override val message: String) : GetVerificationEmail()
data class MembershipAlreadyExists(override val message: String) : GetVerificationEmail()
data class Null(override val message: String) : GetVerificationEmail()
data class BadInput(override val message: String) : GetVerificationEmail()
data class UnknownError(override val message: String) : GetVerificationEmail()
data class NotLoggedIn(override val message: String) : GetVerificationEmail()
data class PaymentNodeError(override val message: String) : GetVerificationEmail()
data class CacheError(override val message: String) : GetVerificationEmail()
data class CanNotConnect(override val message: String) : GetVerificationEmail()
}
sealed class VerifyEmailCode : MembershipErrors() {
data class EmailAlreadyVerified(val message: String) : VerifyEmailCode()
data class CodeExpired(val message: String) : VerifyEmailCode()
data class CodeWrong(val message: String) : VerifyEmailCode()
data class MembershipNotFound(val message: String) : VerifyEmailCode()
data class MembershipAlreadyActive(val message: String) : VerifyEmailCode()
data class EmailAlreadyVerified(override val message: String) : VerifyEmailCode()
data class CodeExpired(override val message: String) : VerifyEmailCode()
data class CodeWrong(override val message: String) : VerifyEmailCode()
data class MembershipNotFound(override val message: String) : VerifyEmailCode()
data class MembershipAlreadyActive(override val message: String) : VerifyEmailCode()
data class Null(override val message: String) : VerifyEmailCode()
data class BadInput(override val message: String) : VerifyEmailCode()
data class UnknownError(override val message: String) : VerifyEmailCode()
data class NotLoggedIn(override val message: String) : VerifyEmailCode()
data class PaymentNodeError(override val message: String) : VerifyEmailCode()
data class CacheError(override val message: String) : VerifyEmailCode()
data class CanNotConnect(override val message: String) : VerifyEmailCode()
}
}

View file

@ -2,7 +2,7 @@ package com.anytypeio.anytype.core_models.membership
data class Membership(
val tier: Int,
val membershipStatusModel: MembershipStatusModel,
val membershipStatusModel: Status,
val dateStarted: Long,
val dateEnds: Long,
val isAutoRenew: Boolean,
@ -14,13 +14,13 @@ data class Membership(
) {
data class Event(val membership: Membership)
}
enum class MembershipStatusModel {
STATUS_UNKNOWN,
STATUS_PENDING,
STATUS_ACTIVE,
STATUS_PENDING_FINALIZATION
enum class Status {
STATUS_UNKNOWN,
STATUS_PENDING,
STATUS_ACTIVE,
STATUS_PENDING_FINALIZATION
}
}
enum class MembershipPaymentMethod {
@ -61,7 +61,6 @@ enum class MembershipPeriodType {
}
data class GetPaymentUrlResponse(
val paymentUrl: String,
val billingId: String
)

View file

@ -16,16 +16,17 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
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.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.anytypeio.anytype.core_models.membership.Membership.Status
import com.anytypeio.anytype.core_ui.R
import com.anytypeio.anytype.core_ui.views.BodyCalloutRegular
import com.anytypeio.anytype.core_ui.views.BodyRegular
@ -36,6 +37,7 @@ import com.anytypeio.anytype.core_ui.views.ButtonWarningLoading
import com.anytypeio.anytype.core_ui.views.Caption1Regular
import com.anytypeio.anytype.core_ui.views.HeadlineHeading
import com.anytypeio.anytype.core_ui.views.Title1
import com.anytypeio.anytype.presentation.membership.models.MembershipStatus
@Composable
fun Toolbar(
@ -133,15 +135,14 @@ fun OptionMembership(
@DrawableRes image: Int,
text: String,
onClick: () -> Unit = {},
activeTierName: String?
membershipStatus: MembershipStatus?
) {
val tierName = remember { activeTierName }
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.height(52.dp)
.clickable(onClick = onClick)
.noRippleThrottledClickable { onClick() }
) {
Image(
@ -159,39 +160,59 @@ fun OptionMembership(
),
style = BodyRegular
)
if (tierName == null) {
Box(modifier = Modifier
.weight(1.0f, true),
contentAlignment = Alignment.CenterEnd
) {
Text(
modifier = Modifier
.padding(
horizontal = 20.dp
)
.background(
color = colorResource(R.color.glyph_selected),
shape = RoundedCornerShape(6.dp)
)
.padding(horizontal = 11.dp, vertical = 5.dp),
text = "Join",
color = colorResource(R.color.text_button_label),
style = Caption1Regular
)
when (membershipStatus?.status) {
Status.STATUS_ACTIVE -> {
Box(
modifier = Modifier.weight(1.0f, true),
contentAlignment = Alignment.CenterEnd
) {
Text(
modifier = Modifier
.padding(horizontal = 38.dp),
text = membershipStatus.tiers.firstOrNull { it.id == membershipStatus.activeTier?.value }?.name.orEmpty(),
color = colorResource(R.color.text_secondary),
style = BodyRegular
)
Arrow()
}
}
} else {
Box(
modifier = Modifier.weight(1.0f, true),
contentAlignment = Alignment.CenterEnd
) {
Text(
Status.STATUS_UNKNOWN -> {
Box(
modifier = Modifier
.padding(horizontal = 38.dp),
text = tierName,
color = colorResource(R.color.text_secondary),
style = BodyRegular
)
Arrow()
.weight(1.0f, true),
contentAlignment = Alignment.CenterEnd
) {
Text(
modifier = Modifier
.padding(
horizontal = 20.dp
)
.background(
color = colorResource(R.color.glyph_selected),
shape = RoundedCornerShape(6.dp)
)
.padding(horizontal = 11.dp, vertical = 5.dp),
text = stringResource(R.string.membership_btn_join),
color = colorResource(R.color.text_button_label),
style = Caption1Regular
)
}
}
else -> {
Box(
modifier = Modifier.weight(1.0f, true),
contentAlignment = Alignment.CenterEnd
) {
Text(
modifier = Modifier
.padding(horizontal = 38.dp),
text = stringResource(id = R.string.membership_price_pending),
color = colorResource(R.color.text_secondary),
style = BodyRegular
)
}
}
}
}
@ -346,16 +367,6 @@ fun Announcement(
}
}
@Preview
@Composable
fun MyOptionMembership() {
OptionMembership(
image = R.drawable.ic_membership,
text = "Membership",
activeTierName = "Builder"
)
}
@Preview
@Composable
fun WarningPreview() {

View file

@ -304,6 +304,51 @@ fun ButtonSecondary(
}
}
@Composable
fun ButtonSecondaryDarkTheme(
text: String = "",
onClick: () -> Unit,
enabled: Boolean = true,
modifier: Modifier = Modifier,
size: ButtonSize
) {
val interactionSource = remember { MutableInteractionSource() }
val isPressed = interactionSource.collectIsPressedAsState()
val backgroundColor =
if (isPressed.value) colorResource(id = R.color.shape_transparent) else Color.Transparent
val borderColor = if (enabled) colorResource(id = R.color.shape_primary) else colorResource(
id = R.color.shape_secondary
)
CompositionLocalProvider(LocalRippleTheme provides NoRippleTheme) {
Button(
onClick = onClick,
interactionSource = interactionSource,
enabled = enabled,
shape = RoundedCornerShape(size.cornerSize),
border = BorderStroke(width = 1.dp, color = borderColor),
colors = ButtonDefaults.buttonColors(
backgroundColor = backgroundColor,
contentColor = colorResource(id = R.color.text_white),
disabledBackgroundColor = Color.Transparent,
disabledContentColor = colorResource(id = R.color.text_tertiary)
),
modifier = modifier
.defaultMinSize(minWidth = 1.dp, minHeight = 1.dp),
elevation = ButtonDefaults.elevation(
defaultElevation = 0.dp,
pressedElevation = 0.dp
),
contentPadding = size.contentPadding
) {
Text(
text = text,
style = size.textStyle
)
}
}
}
@Composable
fun ButtonSecondaryLoading(
text: String = "",
@ -535,6 +580,20 @@ fun MyPrimaryButton() {
)
}
@Composable
@Preview
fun MyPrimaryButtonDisabled() {
ButtonPrimary(
onClick = {},
size = ButtonSize.Large,
text = "Login",
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp),
enabled = false
)
}
@Composable
@Preview
fun MyPrimaryButtonDark() {

View file

@ -1,16 +1,21 @@
package com.anytypeio.anytype.core_ui.views
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.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.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AlertDialogDefaults
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
@ -21,6 +26,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@ExperimentalMaterial3Api
@ -72,4 +78,78 @@ fun BaseAlertDialog(
}
}
}
}
@ExperimentalMaterial3Api
@Composable
fun BaseTwoButtonsDarkThemeAlertDialog(
dialogText: String,
actionButtonText: String,
dismissButtonText: String,
onActionButtonClick: () -> Unit,
onDismissButtonClick: () -> Unit,
onDismissRequest: () -> Unit
) {
val modifier = Modifier
.shadow(
elevation = 40.dp, spotColor = Color(0x40000000), ambientColor = Color(0x40000000)
)
.wrapContentWidth()
.wrapContentHeight()
.background(
color = Color(0xFF1F1E1D), shape = RoundedCornerShape(size = 8.dp)
)
.padding(start = 32.dp, top = 32.dp, end = 32.dp, bottom = 32.dp)
BasicAlertDialog(onDismissRequest = onDismissRequest) {
Surface(
modifier = modifier,
shape = MaterialTheme.shapes.large,
tonalElevation = AlertDialogDefaults.TonalElevation,
color = Color.Transparent
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = dialogText,
style = UXBody,
textAlign = TextAlign.Center,
color = Color(0xFFFFFFFF),
modifier = Modifier.padding(horizontal = 10.dp)
)
Spacer(modifier = Modifier.height(18.dp))
Row {
ButtonPrimaryDarkTheme(
text = actionButtonText,
onClick = onActionButtonClick,
size = ButtonSize.Large,
modifier = Modifier.weight(1f)
)
Spacer(modifier = Modifier.width(8.dp))
ButtonSecondaryDarkTheme(
text = dismissButtonText,
onClick = onDismissButtonClick,
size = ButtonSize.Large,
modifier = Modifier.weight(1f)
)
}
}
}
}
}
@ExperimentalMaterial3Api
@Composable
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES, name = "Light Mode")
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_NO, name = "Dark Mode")
fun BaseAlertDialogPreview() {
BaseTwoButtonsDarkThemeAlertDialog(
dialogText = "This is a dialog",
actionButtonText = "Contact us",
dismissButtonText = "Ok",
onDismissButtonClick = {},
onActionButtonClick = {},
onDismissRequest = {}
)
}

View file

@ -105,5 +105,23 @@
<color name="context_menu_background">#302F2B</color>
<color name="date_selected_container_color">#483308</color>
<color name="payments_tier_current_background">#1F1E1D</color>
<color name="membership_info_gradient_green">#00000000</color>
<color name="membership_info_gradient_yellow">#00000000</color>
<color name="membership_info_gradient_pink">#00000000</color>
<color name="membership_info_gradient_purple">#00000000</color>
<color name="tier_gradient_red_start">#00000000</color>
<color name="tier_gradient_red_end">#F05F5F</color>
<color name="tier_gradient_blue_start">#00000000</color>
<color name="tier_gradient_blue_end">#A5AEFF</color>
<color name="tier_gradient_teal_start">#00000000</color>
<color name="tier_gradient_teal_end">#24BFD4</color>
<color name="tier_gradient_purple_start">#00000000</color>
<color name="tier_gradient_purple_end">#E86DE3</color>
</resources>

View file

@ -226,6 +226,23 @@
<color name="widget_edit_view_stroke_color_active">@color/palette_system_amber_50</color>
<color name="widget_edit_view_stroke_color_inactive">@color/shape_primary</color>
<color name="payments_tier_current_background">#F6F6F6</color>
<color name="payments_tier_current_background">#F2F2F2</color>
<color name="tier_gradient_red_start">#FBEAEA</color>
<color name="tier_gradient_red_end">#F05F5F</color>
<color name="tier_gradient_blue_start">#E4E7FF</color>
<color name="tier_gradient_blue_end">#A5AEFF</color>
<color name="tier_gradient_teal_start">#CFFAFF</color>
<color name="tier_gradient_teal_end">#24BFD4</color>
<color name="tier_gradient_purple_start">#FBEAFF</color>
<color name="tier_gradient_purple_end">#E86DE3</color>
<color name="membership_info_gradient_green">#CFF6CF</color>
<color name="membership_info_gradient_yellow">#FEF2C6</color>
<color name="membership_info_gradient_pink">#FFEBEB</color>
<color name="membership_info_gradient_purple">#EBEDFE</color>
</resources>

View file

@ -1007,7 +1007,7 @@ class BlockDataRepository(
}
override suspend fun membershipIsNameValid(command: Command.Membership.IsNameValid) {
remote.membershipIsNameValid(command)
return remote.membershipIsNameValid(command)
}
override suspend fun membershipGetPaymentUrl(command: Command.Membership.GetPaymentUrl): GetPaymentUrlResponse {

View file

@ -3,11 +3,17 @@ package com.anytypeio.anytype.device
import android.content.Context
import androidx.core.os.ConfigurationCompat
import com.anytypeio.anytype.domain.misc.LocaleProvider
import java.util.Locale
class DefaultLocalProvider(
private val context: Context
): LocaleProvider {
override fun language(): String? {
return ConfigurationCompat.getLocales(context.resources.configuration)[0]?.language
) : LocaleProvider {
private val defaultLocale by lazy {
ConfigurationCompat.getLocales(context.resources.configuration).get(0)
?: Locale.getDefault()
}
override fun language(): String = defaultLocale.language
override fun locale(): Locale = defaultLocale
}

View file

@ -1,5 +1,8 @@
package com.anytypeio.anytype.domain.misc
import java.util.Locale
interface LocaleProvider {
fun language() : String?
fun language() : String
fun locale() : Locale
}

View file

@ -0,0 +1,17 @@
package com.anytypeio.anytype.domain.payments
import com.anytypeio.anytype.core_models.membership.EmailVerificationStatus
import com.anytypeio.anytype.domain.base.AppCoroutineDispatchers
import com.anytypeio.anytype.domain.base.ResultInteractor
import com.anytypeio.anytype.domain.block.repo.BlockRepository
import javax.inject.Inject
class GetMembershipEmailStatus @Inject constructor(
dispatchers: AppCoroutineDispatchers,
private val repo: BlockRepository
) : ResultInteractor<Unit, EmailVerificationStatus>(dispatchers.io) {
override suspend fun doWork(params: Unit): EmailVerificationStatus {
return repo.membershipGetVerificationEmailStatus()
}
}

View file

@ -0,0 +1,33 @@
package com.anytypeio.anytype.domain.payments
import com.anytypeio.anytype.core_models.Command
import com.anytypeio.anytype.core_models.membership.GetPaymentUrlResponse
import com.anytypeio.anytype.core_models.membership.MembershipPaymentMethod
import com.anytypeio.anytype.core_models.membership.NameServiceNameType
import com.anytypeio.anytype.domain.base.AppCoroutineDispatchers
import com.anytypeio.anytype.domain.base.ResultInteractor
import com.anytypeio.anytype.domain.block.repo.BlockRepository
import javax.inject.Inject
class GetMembershipPaymentUrl @Inject constructor(
dispatchers: AppCoroutineDispatchers,
private val repo: BlockRepository
) : ResultInteractor<GetMembershipPaymentUrl.Params, GetPaymentUrlResponse>(dispatchers.io) {
override suspend fun doWork(params: Params): GetPaymentUrlResponse {
val command = Command.Membership.GetPaymentUrl(
tier = params.tierId,
name = params.name,
nameType = params.nameType,
paymentMethod = params.paymentMethod
)
return repo.membershipGetPaymentUrl(command)
}
data class Params(
val tierId: Int,
val name: String,
val nameType: NameServiceNameType = NameServiceNameType.ANY_NAME,
val paymentMethod: MembershipPaymentMethod = MembershipPaymentMethod.METHOD_INAPP_GOOGLE
)
}

View file

@ -0,0 +1,29 @@
package com.anytypeio.anytype.domain.payments
import com.anytypeio.anytype.core_models.Command
import com.anytypeio.anytype.core_models.membership.NameServiceNameType
import com.anytypeio.anytype.domain.base.AppCoroutineDispatchers
import com.anytypeio.anytype.domain.base.ResultInteractor
import com.anytypeio.anytype.domain.block.repo.BlockRepository
import javax.inject.Inject
class IsMembershipNameValid @Inject constructor(
dispatchers: AppCoroutineDispatchers,
private val repo: BlockRepository
) : ResultInteractor<IsMembershipNameValid.Params, Unit>(dispatchers.io) {
override suspend fun doWork(params: Params) {
val command = Command.Membership.IsNameValid(
tier = params.tier,
name = params.name,
nameType = params.nameType
)
repo.membershipIsNameValid(command)
}
data class Params(
val tier: Int,
val name: String,
val nameType: NameServiceNameType = NameServiceNameType.ANY_NAME
)
}

View file

@ -0,0 +1,26 @@
package com.anytypeio.anytype.domain.payments
import com.anytypeio.anytype.core_models.Command
import com.anytypeio.anytype.domain.base.AppCoroutineDispatchers
import com.anytypeio.anytype.domain.base.ResultInteractor
import com.anytypeio.anytype.domain.block.repo.BlockRepository
import javax.inject.Inject
class SetMembershipEmail @Inject constructor(
dispatchers: AppCoroutineDispatchers,
private val repo: BlockRepository
) : ResultInteractor<SetMembershipEmail.Params, Unit>(dispatchers.io) {
override suspend fun doWork(params: Params) {
val command = Command.Membership.GetVerificationEmail(
email = params.email,
subscribeToNewsletter = params.subscribeToNewsletter
)
repo.membershipGetVerificationEmail(command)
}
data class Params(
val email: String,
val subscribeToNewsletter: Boolean
)
}

View file

@ -0,0 +1,24 @@
package com.anytypeio.anytype.domain.payments
import com.anytypeio.anytype.core_models.Command
import com.anytypeio.anytype.domain.base.AppCoroutineDispatchers
import com.anytypeio.anytype.domain.base.ResultInteractor
import com.anytypeio.anytype.domain.block.repo.BlockRepository
import javax.inject.Inject
class VerifyMembershipEmailCode @Inject constructor(
dispatchers: AppCoroutineDispatchers,
private val repo: BlockRepository
) : ResultInteractor<VerifyMembershipEmailCode.Params, Unit>(dispatchers.io) {
override suspend fun doWork(params: Params) {
val command = Command.Membership.VerifyEmailCode(
code = params.code
)
repo.membershipVerifyEmailCode(command)
}
data class Params(
val code: String
)
}

View file

@ -168,17 +168,17 @@ sentryTimber = { module = "io.sentry:sentry-android-timber", version.ref = "sent
navigationCompose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationComposeVersion" }
appUpdater = { module = "com.github.PLPsiSoft:AndroidAppUpdater", version = "9913ce80da7871c84af24b9adc2bf2414ca294f0" }
composeQrCode = { module = "com.lightspark:compose-qr-code", version.ref = "composeQrCodeVersion" }
playBilling = { module = "com.android.billingclient:billing", version = "6.2.0" }
playBilling = { module = "com.android.billingclient:billing", version = "7.0.0" }
[bundles]
[plugins]
application = { id = "com.android.application", version = "8.4.0" }
library = { id = "com.android.library", version = "8.4.0" }
application = { id = "com.android.application", version = "8.4.1" }
library = { id = "com.android.library", version = "8.4.1" }
kotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlinVersion" }
dokka = { id = "org.jetbrains.dokka", version.ref = "dokkaVersion" }
kserialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlinVersion" }
wire = { id = "com.squareup.wire", version = "4.9.8" }
firebaseDistribution = { id = "com.google.firebase.appdistribution", version = "4.2.0" }
firebaseDistribution = { id = "com.google.firebase.appdistribution", version = "5.0.0" }
kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlinVersion" }
gms = { id = "com.google.gms.google-services", version = "4.4.1" }

View file

@ -1447,7 +1447,12 @@
<string name="payments_tier_custom_description">Membership tailored to your specific needs and preferences</string>
<string name="payments_button_learn">Learn more</string>
<string name="payments_button_manage">Manage payment</string>
<string name="payments_button_contact">Contact</string>
<string name="payments_button_info">More info</string>
<string name="payments_button_submit">Submit</string>
<string name="payments_button_pay">Pay by card</string>
<string name="payments_button_change_email">Change e-mail</string>
<string name="payments_member_link">Membership levels details</string>
<string name="payments_privacy_link">Privacy policy</string>
@ -1463,7 +1468,10 @@
<string name="payments_tier_details_name_domain">.any</string>
<string name="payments_tier_details_name_min">Min 7 characters</string>
<string name="payments_tier_details_name_error">This name is already taken!</string>
<string name="payments_tier_details_name_success">This name is up for grabs!</string>
<string name="payments_tier_details_name_validated">This name [%1$s] is up for grabs!</string>
<string name="payments_tier_details_name_validating">Validating name</string>
<string name="payments_tier_details_free_forever">Forever</string>
<string name="payments_tier_details_valid_until">Valid until %1$s</string>
<string name="payments_tier_details_info_explorer">Dive into the network and enjoy the thrill of one-on-one collaboration</string>
<string-array name="payments_benefits_explorer">
@ -1492,28 +1500,92 @@
<string name="payments_detials_button_pay">Pay by Card</string>
<string name="payments_detials_button_submit">Submit</string>
<string name="payments_details_whats_included">Whats included</string>
<string name="payments_email_title">Get your free membership</string>
<string name="payments_email_subtitle">We need your email to keep spam at bay and the fun in play!</string>
<string name="payments_email_title">Get updates and enjoy free perks!</string>
<string name="payments_email_subtitle">It is not linked to your account in any way.</string>
<string name="payments_email_checkbox_text">I\'d like to get updates on products and enjoy free perks!</string>
<string name="payments_email_hint">E-mail</string>
<string name="payments_tier_current_title">Your current status:</string>
<string name="payments_tier_current_valid">Valid until</string>
<string name="payments_tier_current_paid_by">Paid by Card</string>
<string name="payments_tier_current_paid_by">Paid by</string>
<string name="payments_tier_current_paid_by_card">Card</string>
<string name="payments_tier_current_paid_by_crypto">Crypto</string>
<string name="payments_tier_current_paid_by_apple">Apple subscription</string>
<string name="payments_tier_current_paid_by_google">Google subscription</string>
<string name="payments_tier_current_button">Manage payment</string>
<string name="payments_tier_current_change_email_button">Change e-mail</string>
<!-- Payments Code -->
<string name="payments_code_title">Enter the code sent to your email</string>
<string name="payments_code_resend">Resend</string>
<string name="payments_code_resend_in">Resend in %1$n sec</string>
<string name="payments_code_resend_in">Resend in %1$d sec</string>
<!-- Payments Welcome -->
<!-- Membership Welcome -->
<string name="payments_welcome_title">Welcome to the network,\n%1$s</string>
<string name="payments_welcome_subtitle">Big cheers for your curiosity!</string>
<string name="payments_welcome_button">Continue</string>
<!-- Membership Email -->
<string name="membership_support_email">support@anytype.io</string>
<string name="membership_support_subject">Membership Screen Error Report - %1$s</string>
<string name="membership_support_body">
Hi Anytype Support Team,\n\n
I encountered an error on the Membership Screen in the app. Below are the details:\n
- Error Message: %1$s\n
- Description: \n
- Date and Time: %2$s\n
- Device Model: %3$s\n
- Operating System: %4$s\n
- App Version: %5$s\n
Additional Comments:\n\n
Thank you for your assistance in resolving this issue.\n\n
Best regards, \n
</string>
<string name="payments_email_to">membership-upgrade@anytype.io</string>
<string name="payments_email_subject">Upgrade %1$s</string>
<string name="payments_email_body">Hello Anytype team! I would like to extend my current membership for more (please choose an option):\n
- Extra remote storage\n
- More space editors\n
- Additional shared spaces\n
Specifically,
Please provide specific details of your needs here.</string>
<!-- Membership name errors -->
<string name="membership_name_bad_input">Something wrong with input, try again</string>
<string name="membership_name_cache_error">Error on our side, try again later</string>
<string name="membership_name_cant_connect">Can not connect, check your internet connection</string>
<string name="membership_name_invalid_chars">Name has invalid characters</string>
<string name="membership_name_not_logged">You are not logged in! 😵‍💫</string>
<string name="membership_name_payment_node_error">Error on our side, try again later</string>
<string name="membership_name_tier_features_no_name">Name is not available for this tier</string>
<string name="membership_name_tier_not_found">Tier not found, try again later</string>
<string name="membership_name_too_long">Name is too long</string>
<string name="membership_name_too_short">Name is too short</string>
<string name="membership_any_name_not_reserved">This name cannot be reserved</string>
<string name="membership_any_name_null_error">Empty error</string>
<string name="membership_any_name_unknown">Unknown error</string>
<string name="membership_email_wrong_format">Email has wrong format</string>
<string name="membership_email_already_verified">Email is already verified</string>
<string name="membership_email_already_sent">Email is already sent, try again after 1 minute</string>
<string name="membership_email_failed_to_send">Email failed to send</string>
<string name="membership_email_membership_already_exists">Membership already exists</string>
<string name="membership_email_code_expired">Code expired</string>
<string name="membership_email_code_wrong">Code is wrong</string>
<string name="membership_email_code_success">Code is correct</string>
<string name="membership_email_membership_not_found">Membership not found</string>
<string name="membership_email_membership_already_active">Membership already active</string>
<!-- Notifications -->
<string name="notifications_alert_error_unknown">An unknown error has occurred. Please try again later.</string>
<string name="notifications_alert_error_bad_input">Invalid input detected. Please check your data and try again.</string>
@ -1536,6 +1608,7 @@
<string name="three_dots_text_placeholder">...</string>
<string name="membership_btn_join">Join</string>
<string name="notifications_prompt_get_notified">Get notified</string>
<string name="notifications_prompt_secondary_button_text">Not now</string>
@ -1545,4 +1618,36 @@
<string name="clipboard_panel_create_bookmark_from_clipboard">Create bookmark from clipboard</string>
<string name="error_unexpected_layout">Error: unexpected layout</string>
<string name="membership_price_pending">Pending...</string>"
<string name="per_period">per %1$s</string>
<string name="free_for">Free for %1$s</string>
<string name="free_for_unknown">Free until...</string>
<string name="free_until">Free until</string>
<string name="membership_valid_forever">Valid forever</string>
<string name="membership_valid_for_unknown">Valid until...</string>
<string name="membership_agree_start">By continuing you agree to our</string>
<string name="membership_agree_terms">Terms of Use</string>
<string name="membership_agree_middle">and</string>
<string name="membership_agree_privacy">Privacy Policy</string>
<plurals name="period_years">
<item quantity="one">year</item>
<item quantity="other">%d years</item>
</plurals>
<plurals name="period_months">
<item quantity="one">month</item>
<item quantity="other">%d months</item>
</plurals>
<plurals name="period_weeks">
<item quantity="one">week</item>
<item quantity="other">%d weeks</item>
</plurals>
<plurals name="period_days">
<item quantity="one">day</item>
<item quantity="other">%d days</item>
</plurals>
<string name="membership_error_button_text_action">Contact us</string>
<string name="membership_error_button_text_dismiss">Ok</string>
</resources>

View file

@ -970,7 +970,7 @@ class BlockMiddleware(
}
override suspend fun membershipIsNameValid(command: Command.Membership.IsNameValid) {
return middleware.membershipIsNameValid(command)
middleware.membershipIsNameValid(command)
}
override suspend fun membershipGetPaymentUrl(command: Command.Membership.GetPaymentUrl): GetPaymentUrlResponse {
@ -982,7 +982,7 @@ class BlockMiddleware(
}
override suspend fun membershipFinalize(command: Command.Membership.Finalize) {
return middleware.membershipFinalize(command)
middleware.membershipFinalize(command)
}
override suspend fun membershipGetVerificationEmailStatus(): EmailVerificationStatus {
@ -990,11 +990,11 @@ class BlockMiddleware(
}
override suspend fun membershipGetVerificationEmail(command: Command.Membership.GetVerificationEmail) {
return middleware.membershipGetVerificationEmail(command)
middleware.membershipGetVerificationEmail(command)
}
override suspend fun membershipVerifyEmailCode(command: Command.Membership.VerifyEmailCode) {
return middleware.membershipVerifyEmailCode(command)
middleware.membershipVerifyEmailCode(command)
}
override suspend fun membershipGetTiers(command: Command.Membership.GetTiers): List<MembershipTierData> {

View file

@ -2613,7 +2613,8 @@ class Middleware @Inject constructor(
fun membershipIsNameValid(command: Command.Membership.IsNameValid) {
val request = Rpc.Membership.IsNameValid.Request(
requestedTier = command.tier,
nsName = command.name
nsName = command.name,
nsNameType = command.nameType.toMw()
)
if (BuildConfig.DEBUG) logRequest(request)
val response = service.membershipIsNameValid(request)
@ -2632,7 +2633,6 @@ class Middleware @Inject constructor(
val response = service.membershipRegisterPaymentRequest(request)
if (BuildConfig.DEBUG) logResponse(response)
return GetPaymentUrlResponse(
paymentUrl = response.paymentUrl,
billingId = response.billingId
)
}

View file

@ -0,0 +1,71 @@
package com.anytypeio.anytype.middleware.mappers
import anytype.Rpc
import com.anytypeio.anytype.core_models.membership.MembershipErrors
fun Rpc.Membership.IsNameValid.Response.Error.toCore(): MembershipErrors.IsNameValid {
return when (this.code) {
IsNameValidErrorCode.NULL -> MembershipErrors.IsNameValid.Null("Null error code")
IsNameValidErrorCode.UNKNOWN_ERROR -> MembershipErrors.IsNameValid.UnknownError(description)
IsNameValidErrorCode.BAD_INPUT -> MembershipErrors.IsNameValid.BadInput(description)
IsNameValidErrorCode.TOO_SHORT -> MembershipErrors.IsNameValid.TooShort(description)
IsNameValidErrorCode.TOO_LONG -> MembershipErrors.IsNameValid.TooLong(description)
IsNameValidErrorCode.HAS_INVALID_CHARS -> MembershipErrors.IsNameValid.HasInvalidChars(description)
IsNameValidErrorCode.TIER_FEATURES_NO_NAME -> MembershipErrors.IsNameValid.TierFeaturesNoName(description)
IsNameValidErrorCode.TIER_NOT_FOUND -> MembershipErrors.IsNameValid.TierNotFound(description)
IsNameValidErrorCode.NOT_LOGGED_IN -> MembershipErrors.IsNameValid.NotLoggedIn(description)
IsNameValidErrorCode.PAYMENT_NODE_ERROR -> MembershipErrors.IsNameValid.PaymentNodeError(description)
IsNameValidErrorCode.CACHE_ERROR -> MembershipErrors.IsNameValid.CacheError(description)
IsNameValidErrorCode.CAN_NOT_RESERVE -> MembershipErrors.IsNameValid.CanNotReserve(description)
IsNameValidErrorCode.CAN_NOT_CONNECT -> MembershipErrors.IsNameValid.CanNotConnect(description)
IsNameValidErrorCode.NAME_IS_RESERVED -> MembershipErrors.IsNameValid.NameIsReserved(description)
}
}
fun Rpc.NameService.ResolveName.Response.Error.toCore(): MembershipErrors.ResolveName {
return when (this.code) {
ResolveNameErrorCode.NULL -> MembershipErrors.ResolveName.Null("Null error code")
ResolveNameErrorCode.UNKNOWN_ERROR -> MembershipErrors.ResolveName.UnknownError(description)
ResolveNameErrorCode.BAD_INPUT -> MembershipErrors.ResolveName.BadInput(description)
ResolveNameErrorCode.CAN_NOT_CONNECT -> MembershipErrors.ResolveName.CanNotConnect(description)
}
}
fun Rpc.Membership.GetVerificationEmail.Response.Error.toCore(): MembershipErrors.GetVerificationEmail {
return when (this.code) {
GetVerificationEmailErrorCode.EMAIL_WRONG_FORMAT -> MembershipErrors.GetVerificationEmail.EmailWrongFormat(description)
GetVerificationEmailErrorCode.EMAIL_ALREADY_VERIFIED -> MembershipErrors.GetVerificationEmail.EmailAlreadyVerified(description)
GetVerificationEmailErrorCode.EMAIL_FAILED_TO_SEND -> MembershipErrors.GetVerificationEmail.EmailFailedToSend(description)
GetVerificationEmailErrorCode.MEMBERSHIP_ALREADY_EXISTS -> MembershipErrors.GetVerificationEmail.MembershipAlreadyExists(description)
GetVerificationEmailErrorCode.NULL -> MembershipErrors.GetVerificationEmail.Null("Null error code")
GetVerificationEmailErrorCode.UNKNOWN_ERROR -> MembershipErrors.GetVerificationEmail.UnknownError(description)
GetVerificationEmailErrorCode.BAD_INPUT -> MembershipErrors.GetVerificationEmail.BadInput(description)
GetVerificationEmailErrorCode.NOT_LOGGED_IN -> MembershipErrors.GetVerificationEmail.NotLoggedIn(description)
GetVerificationEmailErrorCode.PAYMENT_NODE_ERROR -> MembershipErrors.GetVerificationEmail.PaymentNodeError(description)
GetVerificationEmailErrorCode.CACHE_ERROR -> MembershipErrors.GetVerificationEmail.CacheError(description)
GetVerificationEmailErrorCode.EMAIL_ALREDY_SENT -> MembershipErrors.GetVerificationEmail.EmailAlreadySent(description)
GetVerificationEmailErrorCode.CAN_NOT_CONNECT -> MembershipErrors.GetVerificationEmail.CanNotConnect(description)
}
}
fun Rpc.Membership.VerifyEmailCode.Response.Error.toCore(): MembershipErrors.VerifyEmailCode {
return when (this.code) {
VerifyEmailErrorCode.NULL -> MembershipErrors.VerifyEmailCode.Null("Null error code")
VerifyEmailErrorCode.UNKNOWN_ERROR -> MembershipErrors.VerifyEmailCode.UnknownError(description)
VerifyEmailErrorCode.BAD_INPUT -> MembershipErrors.VerifyEmailCode.BadInput(description)
VerifyEmailErrorCode.NOT_LOGGED_IN -> MembershipErrors.VerifyEmailCode.NotLoggedIn(description)
VerifyEmailErrorCode.PAYMENT_NODE_ERROR -> MembershipErrors.VerifyEmailCode.PaymentNodeError(description)
VerifyEmailErrorCode.CACHE_ERROR -> MembershipErrors.VerifyEmailCode.CacheError(description)
VerifyEmailErrorCode.CAN_NOT_CONNECT -> MembershipErrors.VerifyEmailCode.CanNotConnect(description)
VerifyEmailErrorCode.EMAIL_ALREADY_VERIFIED -> MembershipErrors.VerifyEmailCode.EmailAlreadyVerified(description)
VerifyEmailErrorCode.CODE_EXPIRED -> MembershipErrors.VerifyEmailCode.CodeExpired(description)
VerifyEmailErrorCode.CODE_WRONG -> MembershipErrors.VerifyEmailCode.CodeWrong(description)
VerifyEmailErrorCode.MEMBERSHIP_NOT_FOUND -> MembershipErrors.VerifyEmailCode.MembershipNotFound(description)
VerifyEmailErrorCode.MEMBERSHIP_ALREADY_ACTIVE -> MembershipErrors.VerifyEmailCode.MembershipAlreadyActive(description)
}
}
typealias IsNameValidErrorCode = Rpc.Membership.IsNameValid.Response.Error.Code
typealias ResolveNameErrorCode = Rpc.NameService.ResolveName.Response.Error.Code
typealias GetVerificationEmailErrorCode = Rpc.Membership.GetVerificationEmail.Response.Error.Code
typealias VerifyEmailErrorCode = Rpc.Membership.VerifyEmailCode.Response.Error.Code

View file

@ -47,7 +47,6 @@ import com.anytypeio.anytype.core_models.membership.EmailVerificationStatus
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.MembershipStatusModel
import com.anytypeio.anytype.core_models.membership.MembershipTierData
import com.anytypeio.anytype.core_models.membership.NameServiceNameType
import com.anytypeio.anytype.core_models.multiplayer.SpaceMemberPermissions
@ -55,6 +54,7 @@ import com.anytypeio.anytype.core_models.primitives.SpaceId
import com.anytypeio.anytype.core_models.restrictions.DataViewRestriction
import com.anytypeio.anytype.core_models.restrictions.DataViewRestrictions
import com.anytypeio.anytype.core_models.restrictions.ObjectRestriction
import com.anytypeio.anytype.core_utils.ext.orNull
import com.anytypeio.anytype.middleware.interactor.toCoreModels
import com.google.gson.GsonBuilder
@ -978,12 +978,12 @@ fun MNotification.toCoreModel(): Notification {
//endregion
//region MEMBERSHIP
fun MMembershipStatus.toCoreModel(): MembershipStatusModel {
fun MMembershipStatus.toCoreModel(): Membership.Status {
return when (this) {
MMembershipStatus.StatusUnknown -> MembershipStatusModel.STATUS_UNKNOWN
MMembershipStatus.StatusPending -> MembershipStatusModel.STATUS_PENDING
MMembershipStatus.StatusActive -> MembershipStatusModel.STATUS_ACTIVE
MMembershipStatus.StatusPendingRequiresFinalization -> MembershipStatusModel.STATUS_PENDING_FINALIZATION
MMembershipStatus.StatusUnknown -> Membership.Status.STATUS_UNKNOWN
MMembershipStatus.StatusPending -> Membership.Status.STATUS_PENDING
MMembershipStatus.StatusActive -> Membership.Status.STATUS_ACTIVE
MMembershipStatus.StatusPendingRequiresFinalization -> Membership.Status.STATUS_PENDING_FINALIZATION
}
}
@ -1036,12 +1036,12 @@ fun MMembershipTierData.toCoreModel() : MembershipTierData {
anyNameMinLength = anyNameMinLength,
features = features,
colorStr = colorStr,
stripeProductId = stripeProductId,
stripeManageUrl = stripeManageUrl,
iosProductId = iosProductId,
iosManageUrl = iosManageUrl,
androidProductId = androidProductId,
androidManageUrl = androidManageUrl
stripeProductId = stripeProductId.ifEmpty { null },
stripeManageUrl = stripeManageUrl.ifEmpty { null },
iosProductId = iosProductId.ifEmpty { null },
iosManageUrl = iosManageUrl.ifEmpty { null },
androidProductId = androidProductId.ifEmpty { null },
androidManageUrl = androidManageUrl.ifEmpty { null },
)
}

View file

@ -6,13 +6,16 @@ import com.anytypeio.anytype.core_models.exceptions.LoginException
import com.anytypeio.anytype.core_models.exceptions.MigrationNeededException
import com.anytypeio.anytype.core_models.exceptions.NeedToUpdateApplicationException
import com.anytypeio.anytype.core_models.exceptions.SpaceLimitReachedException
import com.anytypeio.anytype.core_models.membership.MembershipErrors
import com.anytypeio.anytype.core_models.multiplayer.SpaceInviteError
import com.anytypeio.anytype.core_utils.tools.FeatureToggles
import com.anytypeio.anytype.data.auth.exception.AnytypeNeedsUpgradeException
import com.anytypeio.anytype.data.auth.exception.NotFoundObjectException
import com.anytypeio.anytype.data.auth.exception.UndoRedoExhaustedException
import com.anytypeio.anytype.middleware.mappers.toCore
import javax.inject.Inject
import service.Service
import timber.log.Timber
class MiddlewareServiceImplementation @Inject constructor(
featureToggles: FeatureToggles
@ -2017,11 +2020,15 @@ class MiddlewareServiceImplementation @Inject constructor(
override fun membershipIsNameValid(request: Rpc.Membership.IsNameValid.Request): Rpc.Membership.IsNameValid.Response {
val encoded = Service.membershipIsNameValid(
Rpc.Membership.IsNameValid.Request.ADAPTER.encode(request)
) ?: return Rpc.Membership.IsNameValid.Response(
error = Rpc.Membership.IsNameValid.Response.Error(
code = Rpc.Membership.IsNameValid.Response.Error.Code.NULL
)
)
val response = Rpc.Membership.IsNameValid.Response.ADAPTER.decode(encoded)
val error = response.error
if (error != null && error.code != Rpc.Membership.IsNameValid.Response.Error.Code.NULL) {
throw Exception(error.description)
throw error.toCore()
} else {
return response
}
@ -2082,11 +2089,15 @@ class MiddlewareServiceImplementation @Inject constructor(
override fun membershipGetVerificationEmail(request: Rpc.Membership.GetVerificationEmail.Request): Rpc.Membership.GetVerificationEmail.Response {
val encoded = Service.membershipGetVerificationEmail(
Rpc.Membership.GetVerificationEmail.Request.ADAPTER.encode(request)
) ?: return Rpc.Membership.GetVerificationEmail.Response(
error = Rpc.Membership.GetVerificationEmail.Response.Error(
code = Rpc.Membership.GetVerificationEmail.Response.Error.Code.NULL
)
)
val response = Rpc.Membership.GetVerificationEmail.Response.ADAPTER.decode(encoded)
val error = response.error
if (error != null && error.code != Rpc.Membership.GetVerificationEmail.Response.Error.Code.NULL) {
throw Exception(error.description)
throw error.toCore()
} else {
return response
}
@ -2095,11 +2106,15 @@ class MiddlewareServiceImplementation @Inject constructor(
override fun membershipVerifyEmailCode(request: Rpc.Membership.VerifyEmailCode.Request): Rpc.Membership.VerifyEmailCode.Response {
val encoded = Service.membershipVerifyEmailCode(
Rpc.Membership.VerifyEmailCode.Request.ADAPTER.encode(request)
) ?: return Rpc.Membership.VerifyEmailCode.Response(
error = Rpc.Membership.VerifyEmailCode.Response.Error(
code = Rpc.Membership.VerifyEmailCode.Response.Error.Code.NULL
)
)
val response = Rpc.Membership.VerifyEmailCode.Response.ADAPTER.decode(encoded)
val error = response.error
if (error != null && error.code != Rpc.Membership.VerifyEmailCode.Response.Error.Code.NULL) {
throw Exception(error.description)
throw error.toCore()
} else {
return response
}

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
)
}
}
}

View file

@ -1,21 +1,19 @@
package com.anytypeio.anytype.presentation.membership.models
import com.anytypeio.anytype.core_models.membership.MembershipPaymentMethod
import com.anytypeio.anytype.core_models.membership.MembershipStatusModel
import com.anytypeio.anytype.core_models.membership.Membership.Status
import com.anytypeio.anytype.core_models.membership.MembershipTierData
sealed class MembershipStatus {
object Unknown : MembershipStatus()
object Pending : MembershipStatus()
object Finalization : MembershipStatus()
data class Active(
val tier: MembershipTierData?,
val status: MembershipStatusModel,
val dateEnds: Long,
val paymentMethod: MembershipPaymentMethod,
val anyName: String
) : MembershipStatus()
}
data class MembershipStatus(
val activeTier: TierId,
val status: Status,
val paymentMethod: MembershipPaymentMethod,
val anyName: String,
val tiers: List<MembershipTierData>,
val dateEnds: Long,
val formattedDateEnds: String,
val userEmail: String = ""
)
@JvmInline
value class TierId(val value: String)
value class TierId(val value: Int)

View file

@ -1,51 +0,0 @@
package com.anytypeio.anytype.presentation.membership.models
sealed class Tier {
abstract val id: TierId
abstract val isCurrent: Boolean
abstract val validUntil: String
abstract val prettyName: String
data class Explorer(
override val id: TierId,
override val isCurrent: Boolean,
override val validUntil: String,
override val prettyName: String = "Explorer",
val price: String = "",
val email: String = "",
val isChecked: Boolean = true
) : Tier()
data class Builder(
override val id: TierId,
override val isCurrent: Boolean,
override val validUntil: String,
override val prettyName: String = "Builder",
val price: String = "",
val interval: String = "",
val name: String = "",
val nameIsTaken: Boolean = false,
val nameIsFree: Boolean = false
) : Tier()
data class CoCreator(
override val id: TierId,
override val isCurrent: Boolean,
override val validUntil: String,
override val prettyName: String = "Co-Creator",
val price: String = "",
val interval: String = "",
val name: String = "",
val nameIsTaken: Boolean = false,
val nameIsFree: Boolean = false
) : Tier()
data class Custom(
override val id: TierId,
override val isCurrent: Boolean,
override val validUntil: String,
override val prettyName: String = "Custom",
val price: String = ""
) : Tier()
}

View file

@ -2,24 +2,23 @@ package com.anytypeio.anytype.presentation.membership.provider
import com.anytypeio.anytype.core_models.Command
import com.anytypeio.anytype.core_models.membership.Membership
import com.anytypeio.anytype.core_models.membership.MembershipStatusModel.STATUS_ACTIVE
import com.anytypeio.anytype.core_models.membership.MembershipStatusModel.STATUS_PENDING
import com.anytypeio.anytype.core_models.membership.MembershipStatusModel.STATUS_PENDING_FINALIZATION
import com.anytypeio.anytype.core_models.membership.MembershipStatusModel.STATUS_UNKNOWN
import com.anytypeio.anytype.core_models.membership.MembershipTierData
import com.anytypeio.anytype.core_utils.ext.formatToDateString
import com.anytypeio.anytype.domain.account.AwaitAccountStartManager
import com.anytypeio.anytype.domain.base.AppCoroutineDispatchers
import com.anytypeio.anytype.domain.block.repo.BlockRepository
import com.anytypeio.anytype.domain.misc.LocaleProvider
import com.anytypeio.anytype.domain.workspace.MembershipChannel
import com.anytypeio.anytype.presentation.membership.models.MembershipStatus
import com.anytypeio.anytype.presentation.membership.models.TierId
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.scan
import timber.log.Timber
@ -56,65 +55,55 @@ interface MembershipProvider {
.observe()
.scan(initial) { _, events ->
events.lastOrNull()?.membership
}.mapNotNull { status ->
val tiers = proceedWithGettingTiers()
toMembershipStatus(status, tiers)
}.filterNotNull()
.map { membership ->
val tiers = proceedWithGettingTiers().filter { SHOW_TEST_TIERS || !it.isTest }.sortedBy { it.id }
val newStatus = toMembershipStatus(
membership = membership,
tiers = tiers
)
Timber.d("MembershipProvider, newState: $newStatus")
newStatus
}
}
private suspend fun proceedWithGettingMembership(): Membership? {
val command = Command.Membership.GetStatus(
noCache = false
noCache = true
)
return repo.membershipStatus(command)
}
private suspend fun proceedWithGettingTiers(): List<MembershipTierData> {
val tiersParams = Command.Membership.GetTiers(
noCache = false,
locale = localeProvider.language() ?: DEFAULT_LOCALE
noCache = true,
locale = localeProvider.language()
)
return repo.membershipGetTiers(tiersParams)
}
private fun toMembershipStatus(
membership: Membership?,
tiers: List<MembershipTierData>
): MembershipStatus {
return when (membership?.membershipStatusModel) {
STATUS_PENDING -> MembershipStatus.Pending
STATUS_PENDING_FINALIZATION -> MembershipStatus.Finalization
STATUS_ACTIVE -> toActiveMembershipStatus(membership, tiers)
STATUS_UNKNOWN, null -> {
Timber.e("Invalid or unknown membership status")
MembershipStatus.Unknown
}
else -> MembershipStatus.Unknown
}
}
private fun toActiveMembershipStatus(
membership: Membership,
tiers: List<MembershipTierData>
): MembershipStatus {
val tier = tiers.firstOrNull { it.id == membership.tier }
return if (tier != null) {
MembershipStatus.Active(
tier = tier,
status = membership.membershipStatusModel,
dateEnds = membership.dateEnds,
paymentMethod = membership.paymentMethod,
anyName = membership.nameServiceName
)
} else {
Timber.e("Membership tier not found: ${membership.tier}")
MembershipStatus.Unknown
}
return MembershipStatus(
activeTier = TierId(membership.tier),
status = membership.membershipStatusModel,
dateEnds = membership.dateEnds,
paymentMethod = membership.paymentMethod,
anyName = membership.nameServiceName,
tiers = tiers,
formattedDateEnds = membership.dateEnds.formatToDateString(
pattern = DATE_FORMAT,
locale = localeProvider.locale()
),
userEmail = membership.userEmail
)
}
companion object {
const val DEFAULT_LOCALE = "en"
const val SHOW_TEST_TIERS = false
const val DATE_FORMAT = "d MMM yyyy"
}
}
}

View file

@ -58,6 +58,7 @@ import com.anytypeio.anytype.core_ui.foundation.noRippleClickable
import com.anytypeio.anytype.core_ui.views.BodyRegular
import com.anytypeio.anytype.core_ui.views.Caption1Regular
import com.anytypeio.anytype.core_ui.views.Title1
import com.anytypeio.anytype.presentation.membership.models.MembershipStatus
import com.anytypeio.anytype.presentation.profile.ProfileIconView
import com.anytypeio.anytype.ui_settings.BuildConfig
import com.anytypeio.anytype.ui_settings.R
@ -79,7 +80,7 @@ fun ProfileSettingsScreen(
onAboutClicked: () -> Unit,
onSpacesClicked: () -> Unit,
onMembershipClicked: () -> Unit,
activeTierName: String?
membershipStatus: MembershipStatus?
) {
LazyColumn(
modifier = Modifier
@ -124,18 +125,16 @@ fun ProfileSettingsScreen(
onClick = onDataManagementClicked
)
}
if (BuildConfig.DEBUG) {
item {
Divider(paddingStart = 60.dp)
}
item {
OptionMembership(
image = R.drawable.ic_membership,
text = stringResource(R.string.settings_membership),
onClick = onMembershipClicked,
activeTierName = activeTierName
)
}
item {
Divider(paddingStart = 60.dp)
}
item {
OptionMembership(
image = R.drawable.ic_membership,
text = stringResource(R.string.settings_membership),
onClick = onMembershipClicked,
membershipStatus = membershipStatus
)
}
item {
Divider(paddingStart = 60.dp)
@ -500,7 +499,7 @@ private fun ProfileSettingPreview() {
onAboutClicked = {},
onSpacesClicked = {},
onMembershipClicked = {},
activeTierName = "Pro"
membershipStatus = null
)
}

View file

@ -23,6 +23,8 @@ import com.anytypeio.anytype.domain.`object`.SetObjectDetails
import com.anytypeio.anytype.domain.search.PROFILE_SUBSCRIPTION_ID
import com.anytypeio.anytype.presentation.common.BaseViewModel
import com.anytypeio.anytype.presentation.extension.sendScreenSettingsDeleteEvent
import com.anytypeio.anytype.presentation.membership.models.MembershipStatus
import com.anytypeio.anytype.presentation.membership.provider.MembershipProvider
import com.anytypeio.anytype.presentation.profile.ProfileIconView
import com.anytypeio.anytype.presentation.profile.profileIcon
import kotlinx.coroutines.Job
@ -40,13 +42,15 @@ class ProfileSettingsViewModel(
private val setObjectDetails: SetObjectDetails,
private val configStorage: ConfigStorage,
private val urlBuilder: UrlBuilder,
private val setImageIcon: SetDocumentImageIcon
private val setImageIcon: SetDocumentImageIcon,
private val membershipProvider: MembershipProvider
) : BaseViewModel() {
private val jobs = mutableListOf<Job>()
val isLoggingOut = MutableStateFlow(false)
val debugSyncReportUri = MutableStateFlow<Uri?>(null)
val membershipStatusState = MutableStateFlow<MembershipStatus?>(null)
private val profileId = configStorage.get().profile
@ -80,6 +84,11 @@ class ProfileSettingsViewModel(
eventName = EventsDictionary.screenSettingsAccount
)
}
viewModelScope.launch {
membershipProvider.status().collect { status ->
membershipStatusState.value = status
}
}
}
fun onNameChange(name: String) {
@ -179,7 +188,8 @@ class ProfileSettingsViewModel(
private val setObjectDetails: SetObjectDetails,
private val configStorage: ConfigStorage,
private val urlBuilder: UrlBuilder,
private val setDocumentImageIcon: SetDocumentImageIcon
private val setDocumentImageIcon: SetDocumentImageIcon,
private val membershipProvider: MembershipProvider
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
@ -190,7 +200,8 @@ class ProfileSettingsViewModel(
setObjectDetails = setObjectDetails,
configStorage = configStorage,
urlBuilder = urlBuilder,
setImageIcon = setDocumentImageIcon
setImageIcon = setDocumentImageIcon,
membershipProvider = membershipProvider
) as T
}
}