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:
parent
a15a3f22f1
commit
6493728687
72 changed files with 7540 additions and 2118 deletions
|
@ -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 =
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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 = {}
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
|
@ -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" }
|
|
@ -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">What’s 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>
|
|
@ -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> {
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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
|
|
@ -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 },
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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 }
|
|
@ -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()
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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 = { _ -> }
|
||||
)
|
||||
}
|
|
@ -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 = {}
|
||||
)
|
||||
}
|
|
@ -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 = "")
|
||||
)
|
||||
}
|
|
@ -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
|
||||
)
|
||||
)
|
|
@ -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 = {}
|
||||
)
|
||||
}
|
|
@ -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,
|
|
@ -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
|
||||
)
|
|
@ -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"
|
|
@ -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
|
||||
)
|
||||
)
|
||||
) { }
|
||||
}
|
|
@ -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("")
|
||||
}
|
|
@ -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
|
||||
)
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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("")
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue