diff --git a/app/src/main/java/com/tangem/tap/data/DefaultAppInfoProvider.kt b/app/src/main/java/com/tangem/tap/data/DefaultAppInfoProvider.kt index 69194e58e1..e99cc1f74f 100644 --- a/app/src/main/java/com/tangem/tap/data/DefaultAppInfoProvider.kt +++ b/app/src/main/java/com/tangem/tap/data/DefaultAppInfoProvider.kt @@ -1,13 +1,17 @@ package com.tangem.tap.data +import android.content.Context import android.os.Build import com.tangem.utils.info.AppInfoProvider import com.tangem.wallet.BuildConfig +import dagger.hilt.android.qualifiers.ApplicationContext import java.util.Locale import java.util.TimeZone import javax.inject.Inject -internal class DefaultAppInfoProvider @Inject constructor() : AppInfoProvider { +internal class DefaultAppInfoProvider @Inject constructor( + @ApplicationContext private val context: Context, +) : AppInfoProvider { override val platform: String get() = "Android" override val device: String @@ -18,6 +22,8 @@ internal class DefaultAppInfoProvider @Inject constructor() : AppInfoProvider { get() = Build.VERSION.SDK_INT override val language: String get() = Locale.getDefault().toLanguageTag() + override val deviceScale: Float + get() = context.resources.displayMetrics.density override val timezone: String get() = TimeZone.getDefault().id override val appVersion: String = BuildConfig.VERSION_NAME diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemPay.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemPay.kt index a7dd87e4db..f00122021a 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemPay.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemPay.kt @@ -55,6 +55,7 @@ internal sealed class TangemPay( "version" to ProviderSuspend { appInfoProvider.appVersion }, "platform" to ProviderSuspend { "Android" }, "X-API-KEY" to ProviderSuspend { getBffStaticToken(apiEnvironment) }, + "X-Device-Scale" to ProviderSuspend { appInfoProvider.deviceScale.toString() }, ) private fun getBffStaticToken(apiEnvironment: ApiEnvironment): String { diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt index 55b9d94ee3..366654a008 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt @@ -29,6 +29,11 @@ interface TangemPayApi { @GET("v1/customer/me") suspend fun getCustomerMe(@Header("Authorization") authHeader: String): ApiResponse + @GET("v1/customer/tariff-plan/transitions") + suspend fun getTariffPlanTransitions( + @Header("Authorization") authHeader: String, + ): ApiResponse + /** Fiat bank requisites for the Virtual Account on-ramp (VA MVP0, TWI-1638). */ @GET("v1/account/bank-credentials/{product_instance_id}") suspend fun getBankCredentials( diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CustomerMeResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CustomerMeResponse.kt index 9f911e63f2..09496e1a6f 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CustomerMeResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CustomerMeResponse.kt @@ -39,6 +39,13 @@ data class CustomerMeResponse( @Json(name = "type") val type: String?, @Json(name = "name") val name: String?, @Json(name = "description_items") val descriptionItems: List?, + @Json(name = "images") val images: List? = null, + ) + + @JsonClass(generateAdapter = true) + data class Image( + @Json(name = "type") val type: String?, + @Json(name = "url") val url: String?, ) @JsonClass(generateAdapter = true) diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/TariffPlanTransitionsResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/TariffPlanTransitionsResponse.kt new file mode 100644 index 0000000000..baf988e7d5 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/TariffPlanTransitionsResponse.kt @@ -0,0 +1,15 @@ +package com.tangem.datasource.api.pay.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class TariffPlanTransitionsResponse( + @Json(name = "result") val result: List?, +) + +@JsonClass(generateAdapter = true) +data class TariffPlanTransitionResponse( + @Json(name = "type") val type: String?, + @Json(name = "tariff_plan") val tariffPlan: CustomerMeResponse.TariffPlan?, +) \ No newline at end of file diff --git a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt index 4277b0fab3..cfe9d5fd71 100644 --- a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt @@ -70,6 +70,7 @@ internal class ProdApiConfigsManagerTest { every { appInfoProvider.osVersion } returns "Android 16" every { appInfoProvider.language } returns Locale.getDefault().toLanguageTag() every { appInfoProvider.device } returns "${Build.MANUFACTURER} ${Build.MODEL}" + every { appInfoProvider.deviceScale } returns DEVICE_SCALE manager = ProdApiConfigsManager(apiConfigs = createApiConfigs()) } @@ -298,6 +299,7 @@ internal class ProdApiConfigsManagerTest { "version" to ProviderSuspend { VERSION_NAME }, "platform" to ProviderSuspend { "Android" }, "X-API-KEY" to ProviderSuspend { TANGEM_PAY_BFF_KEY_DEV }, + "X-Device-Scale" to ProviderSuspend { DEVICE_SCALE.toString() }, ), ), ) @@ -313,6 +315,7 @@ internal class ProdApiConfigsManagerTest { "version" to ProviderSuspend { VERSION_NAME }, "platform" to ProviderSuspend { "Android" }, "X-API-KEY" to ProviderSuspend { TANGEM_PAY_BFF_KEY_DEV }, + "X-Device-Scale" to ProviderSuspend { DEVICE_SCALE.toString() }, ), ), ) @@ -457,6 +460,7 @@ internal class ProdApiConfigsManagerTest { private companion object { const val VERSION_NAME = "debug" + const val DEVICE_SCALE = 3f const val EXPRESS_SESSION_ID = "express_session_id" const val STAKE_KIT_API_KEY = "stake_kit_api_key" const val P2P_API_KEY = "p2p_api_key" diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 5809f451ba..06714e33d1 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -1978,6 +1978,13 @@ Pay Support Payment account Session expired + Select plan + Confirm selection + Compare plans + Select + Cancel + Upgrade plan + Downgrade plan Invalid PIN: avoid sequences or repeats Replace card This generates a new set of card details. Your old details will stop working. You can\'t undo this. diff --git a/core/utils/src/main/java/com/tangem/utils/info/AppInfoProvider.kt b/core/utils/src/main/java/com/tangem/utils/info/AppInfoProvider.kt index 4ef568d2e6..3f29789121 100644 --- a/core/utils/src/main/java/com/tangem/utils/info/AppInfoProvider.kt +++ b/core/utils/src/main/java/com/tangem/utils/info/AppInfoProvider.kt @@ -27,6 +27,11 @@ interface AppInfoProvider { /** Current locale as a BCP 47 language tag (e.g. `"en-US"`, `"zh-CN"`). */ val language: String + /** + * Display density as a float (e.g. `"2.0"`, `"2.75"`, `"3.0"`). + */ + val deviceScale: Float + /** IANA time-zone id of the device's current time zone, e.g. `"Europe/Moscow"`, `"UTC"`. */ val timezone: String diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/converter/TangemPayTariffPlanConverter.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/converter/TangemPayTariffPlanConverter.kt new file mode 100644 index 0000000000..abd2b2b864 --- /dev/null +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/converter/TangemPayTariffPlanConverter.kt @@ -0,0 +1,37 @@ +package com.tangem.data.pay.converter + +import com.tangem.datasource.api.pay.models.response.CustomerMeResponse +import com.tangem.domain.models.account.TangemPayTariffPlan + +internal object TangemPayTariffPlanConverter { + + fun convert(value: CustomerMeResponse.TariffPlan?): TangemPayTariffPlan? { + val id = value?.id ?: return null + val name = value.name ?: return null + return TangemPayTariffPlan( + id = id, + type = TangemPayTariffPlan.Type.fromString(value.type), + name = name, + descriptionItems = value.descriptionItems.orEmpty().mapNotNull(::convertDescriptionItem), + images = value.images.orEmpty().mapNotNull(::convertImage), + ) + } + + private fun convertDescriptionItem(item: CustomerMeResponse.DescriptionItem): TangemPayTariffPlan.DescriptionItem? { + val title = item.title ?: return null + return TangemPayTariffPlan.DescriptionItem( + section = TangemPayTariffPlan.Section.fromString(item.type), + order = item.order ?: 0, + title = title, + body = item.body.orEmpty(), + ) + } + + private fun convertImage(image: CustomerMeResponse.Image): TangemPayTariffPlan.Image? { + val url = image.url ?: return null + return TangemPayTariffPlan.Image( + type = TangemPayTariffPlan.Image.Type.fromString(image.type), + url = url, + ) + } +} \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt index a1f8b9698e..fa7e147cfd 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt @@ -124,8 +124,21 @@ internal interface TangemPayDataModule { @IntoSet fun bindTangemPayUserWalletDataCleaner(impl: TangemPayUserWalletDataCleaner): UserWalletDataCleaner + @Binds + @Singleton + fun bindTariffPlanTransitionsRepository( + repository: DefaultTariffPlanTransitionsRepository, + ): TangemPayTariffPlanTransitionsRepository + companion object { + @Provides + fun provideGetTangemPayTariffPlanTransitionsUseCase( + repository: TangemPayTariffPlanTransitionsRepository, + ): GetTangemPayTariffPlanTransitionsUseCase { + return GetTangemPayTariffPlanTransitionsUseCase(repository) + } + @Provides @Singleton fun providePaymentAccountStatusesStore( diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTariffPlanTransitionsRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTariffPlanTransitionsRepository.kt new file mode 100644 index 0000000000..5ae0560c06 --- /dev/null +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTariffPlanTransitionsRepository.kt @@ -0,0 +1,36 @@ +package com.tangem.data.pay.repository + +import arrow.core.Either +import arrow.core.raise.either +import com.tangem.data.pay.converter.TangemPayTariffPlanConverter +import com.tangem.datasource.api.pay.TangemPayApi +import com.tangem.datasource.api.pay.models.response.TariffPlanTransitionResponse +import com.tangem.domain.models.account.TangemPayTariffPlanTransition +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.repository.TangemPayTariffPlanTransitionsRepository +import com.tangem.domain.visa.error.VisaApiError +import javax.inject.Inject + +internal class DefaultTariffPlanTransitionsRepository @Inject constructor( + private val tangemPayApi: TangemPayApi, + private val requestHelper: TangemPayRequestPerformer, +) : TangemPayTariffPlanTransitionsRepository { + + override suspend fun getTransitions( + userWalletId: UserWalletId, + ): Either> = either { + val response = requestHelper.performRequest(userWalletId) { authHeader -> + tangemPayApi.getTariffPlanTransitions(authHeader = authHeader) + }.bind() + + response.result.orEmpty().mapNotNull { it.toDomain() } + } + + private fun TariffPlanTransitionResponse.toDomain(): TangemPayTariffPlanTransition? { + val plan = TangemPayTariffPlanConverter.convert(tariffPlan) ?: return null + return TangemPayTariffPlanTransition( + type = TangemPayTariffPlanTransition.Type.fromString(type), + plan = plan, + ) + } +} \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/util/CustomerInfoConverter.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/util/CustomerInfoConverter.kt index f88311fc8b..0c6144a874 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/util/CustomerInfoConverter.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/util/CustomerInfoConverter.kt @@ -1,6 +1,7 @@ package com.tangem.data.pay.util import arrow.core.getOrElse +import com.tangem.data.pay.converter.TangemPayTariffPlanConverter import com.tangem.datasource.api.pay.models.response.CryptoBalance import com.tangem.datasource.api.pay.models.response.CustomerMeResponse import com.tangem.datasource.api.pay.models.response.FiatBalance @@ -63,21 +64,8 @@ internal object CustomerInfoConverter : Converter, + @SerialName("images") val images: List = emptyList(), ) { @Serializable data class DescriptionItem( @@ -18,6 +20,37 @@ data class TangemPayTariffPlan( @SerialName("body") val body: String, ) + @Serializable + data class Image( + @SerialName("type") val type: Type, + @SerialName("url") val url: String, + ) { + @Serializable + enum class Type { + @SerialName("THUMBNAIL") + THUMBNAIL, + + @SerialName("MAIN") + MAIN, + + @SerialName("BANNER") + BANNER, + + @SerialName("UNKNOWN") + UNKNOWN, + ; + + companion object { + fun fromString(value: String?) = when (value?.uppercase(Locale.US)) { + "THUMBNAIL" -> THUMBNAIL + "MAIN" -> MAIN + "BANNER" -> BANNER + else -> UNKNOWN + } + } + } + } + @Serializable enum class Type { @SerialName("BASIC") diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/account/TangemPayTariffPlanTransition.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/account/TangemPayTariffPlanTransition.kt new file mode 100644 index 0000000000..e2b5e2d9cd --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/account/TangemPayTariffPlanTransition.kt @@ -0,0 +1,41 @@ +package com.tangem.domain.models.account + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import java.util.Locale + +@Serializable +data class TangemPayTariffPlanTransition( + @SerialName("type") val type: Type, + @SerialName("tariff_plan") val plan: TangemPayTariffPlan, +) { + + @Serializable + enum class Type { + @SerialName("UPGRADE") + UPGRADE, + + @SerialName("DOWNGRADE") + DOWNGRADE, + + @SerialName("SYSTEM_DOWNGRADE") + SYSTEM_DOWNGRADE, + + @SerialName("ACTIVATION") + ACTIVATION, + + @SerialName("UNKNOWN") + UNKNOWN, + ; + + companion object { + fun fromString(value: String?) = when (value?.uppercase(Locale.US)) { + "UPGRADE" -> UPGRADE + "DOWNGRADE" -> DOWNGRADE + "SYSTEM_DOWNGRADE" -> SYSTEM_DOWNGRADE + "ACTIVATION" -> ACTIVATION + else -> UNKNOWN + } + } + } +} \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayTariffPlanTransitionsRepository.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayTariffPlanTransitionsRepository.kt new file mode 100644 index 0000000000..34b1e82e7f --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayTariffPlanTransitionsRepository.kt @@ -0,0 +1,11 @@ +package com.tangem.domain.pay.repository + +import arrow.core.Either +import com.tangem.domain.models.account.TangemPayTariffPlanTransition +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.visa.error.VisaApiError + +interface TangemPayTariffPlanTransitionsRepository { + + suspend fun getTransitions(userWalletId: UserWalletId): Either> +} \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/GetTangemPayTariffPlanTransitionsUseCase.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/GetTangemPayTariffPlanTransitionsUseCase.kt new file mode 100644 index 0000000000..ab2bbd0fd6 --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/GetTangemPayTariffPlanTransitionsUseCase.kt @@ -0,0 +1,15 @@ +package com.tangem.domain.pay.usecase + +import arrow.core.Either +import com.tangem.domain.models.account.TangemPayTariffPlanTransition +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.repository.TangemPayTariffPlanTransitionsRepository +import com.tangem.domain.visa.error.VisaApiError + +class GetTangemPayTariffPlanTransitionsUseCase( + private val repository: TangemPayTariffPlanTransitionsRepository, +) { + suspend operator fun invoke( + userWalletId: UserWalletId, + ): Either> = repository.getTransitions(userWalletId) +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt index 60eaea3cf3..f4df1c4318 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt @@ -18,6 +18,7 @@ import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.features.promobanners.api.PromoBannersBlockComponent import com.tangem.features.tangempay.navigation.TangemPayAccountDetailsInnerRoute import com.tangem.features.tangempay.tiers.current.TangemPayCurrentPlanComponent +import com.tangem.features.tangempay.tiers.select.TangemPaySelectPlanComponent import com.tangem.features.tangempay.utils.userWalletId import com.tangem.features.tokendetails.ExpressTransactionsComponent import com.tangem.features.tokenreceive.TokenReceiveComponent @@ -94,6 +95,12 @@ internal class DefaultTangemPayDetailsContainerComponent @AssistedInject constru tariffPlan = config.tariffPlan, ), ) + TangemPayAccountDetailsInnerRoute.SelectPlan -> TangemPaySelectPlanComponent( + appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), + params = TangemPaySelectPlanComponent.Params( + userWalletId = params.initialStatus.userWalletId, + ), + ) } private fun onChildBack() { diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayModelModule.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayModelModule.kt index 23e1df1634..e7216b1b93 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayModelModule.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayModelModule.kt @@ -6,6 +6,7 @@ import com.tangem.features.tangempay.closure.TangemPayCloseCardModel import com.tangem.features.tangempay.limit.setup.TangemPayCardLimitSetupModel import com.tangem.features.tangempay.model.* import com.tangem.features.tangempay.tiers.current.TangemPayCurrentPlanModel +import com.tangem.features.tangempay.tiers.select.TangemPaySelectPlanModel import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -90,4 +91,9 @@ internal interface TangemPayModelModule { @IntoMap @ClassKey(TangemPayCurrentPlanModel::class) fun bindTangemPayCurrentPlanModel(model: TangemPayCurrentPlanModel): Model + + @Binds + @IntoMap + @ClassKey(TangemPaySelectPlanModel::class) + fun bindTangemPaySelectPlanModel(model: TangemPaySelectPlanModel): Model } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayAccountDetailsInnerRoute.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayAccountDetailsInnerRoute.kt index c49b26761c..a88bd6099b 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayAccountDetailsInnerRoute.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayAccountDetailsInnerRoute.kt @@ -20,4 +20,7 @@ internal sealed class TangemPayAccountDetailsInnerRoute : Route { data class CurrentPlan( val tariffPlan: TangemPayCustomerTariffPlan, ) : TangemPayAccountDetailsInnerRoute() + + @Serializable + data object SelectPlan : TangemPayAccountDetailsInnerRoute() } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/tiers/current/TangemPayCurrentPlanModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/tiers/current/TangemPayCurrentPlanModel.kt index e3bdc1e072..5cba793686 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/tiers/current/TangemPayCurrentPlanModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/tiers/current/TangemPayCurrentPlanModel.kt @@ -9,6 +9,7 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.models.account.TangemPayTariffPlan import com.tangem.features.tangempay.details.impl.R +import com.tangem.features.tangempay.navigation.TangemPayAccountDetailsInnerRoute import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -34,7 +35,7 @@ internal class TangemPayCurrentPlanModel @Inject constructor( notification = null, sections = buildSections(plan), onBackClick = router::pop, - onChangePlanClick = {}, + onChangePlanClick = { router.push(TangemPayAccountDetailsInnerRoute.SelectPlan) }, ) private fun buildSections(plan: TangemPayTariffPlan) = persistentListOf( diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/tiers/select/TangemPaySelectPlanCompareContent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/tiers/select/TangemPaySelectPlanCompareContent.kt new file mode 100644 index 0000000000..d0dbb2ecb1 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/tiers/select/TangemPaySelectPlanCompareContent.kt @@ -0,0 +1,255 @@ +package com.tangem.features.tangempay.tiers.select + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEachIndexed +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetType +import com.tangem.core.ui.ds2.button.Close +import com.tangem.core.ui.ds2.button.TangemButton +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.features.tangempay.details.impl.R +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.launch + +@Composable +internal fun ComparePlansBottomSheet(compare: TangemPaySelectPlanUM.ComparePlans?) { + TangemBottomSheet( + config = TangemBottomSheetConfig( + isShown = compare != null, + onDismissRequest = compare?.onDismiss ?: {}, + content = TangemBottomSheetConfigContent.Empty, + ), + type = TangemBottomSheetType.Modal, + containerColor = TangemTheme.colors3.bg.secondary, + title = { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + ) { + Text( + modifier = Modifier.align(Alignment.Center), + text = resourceReference(R.string.tangempay_select_plan_compare).resolveReference(), + style = TangemTheme.typography3.body.medium, + color = TangemTheme.colors3.text.primary, + ) + TangemButton.Close( + modifier = Modifier.align(Alignment.CenterEnd), + onClick = compare?.onDismiss ?: {}, + ) + } + }, + content = { + if (compare != null) { + ComparePlansContent(compare) + } + }, + ) +} + +@Composable +private fun ComparePlansContent(compare: TangemPaySelectPlanUM.ComparePlans) { + BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { + val cardWidth = maxWidth - CARD_PEEK + val cardWidthPx = with(LocalDensity.current) { cardWidth.toPx() } + val scope = rememberCoroutineScope() + + val rowStates = remember(compare.plans.size) { List(compare.plans.size) { LazyListState() } } + val tabState = rememberLazyListState() + + rowStates.forEach { source -> + LaunchedEffect(source) { + snapshotFlow { source.firstVisibleItemIndex to source.firstVisibleItemScrollOffset } + .collect { (index, offset) -> + if (source.isScrollInProgress) { + rowStates.forEach { target -> if (target !== source) target.scrollToItem(index, offset) } + } + } + } + } + + val selectedIndex by remember(rowStates, cardWidthPx) { + derivedStateOf { + val primary = rowStates.firstOrNull() ?: return@derivedStateOf 0 + val extra = if (primary.firstVisibleItemScrollOffset > cardWidthPx / 2f) 1 else 0 + (primary.firstVisibleItemIndex + extra).coerceIn(0, compare.attributes.lastIndex.coerceAtLeast(0)) + } + } + + LaunchedEffect(selectedIndex) { tabState.animateScrollToItem(selectedIndex) } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + compare.plans.fastForEachIndexed { index, plan -> + PlanValuesSection(plan = plan, listState = rowStates[index], cardWidth = cardWidth) + } + AttributeTabs( + attributes = compare.attributes, + selectedIndex = selectedIndex, + listState = tabState, + onTabClick = { index -> scope.launch { rowStates.firstOrNull()?.animateScrollToItem(index) } }, + ) + } + } +} + +@Composable +private fun PlanValuesSection(plan: TangemPaySelectPlanUM.ComparePlans.Plan, listState: LazyListState, cardWidth: Dp) { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + modifier = Modifier.padding(horizontal = 32.dp, vertical = 12.dp), + text = plan.name.resolveReference(), + style = TangemTheme.typography3.subheading.medium, + color = TangemTheme.colors3.text.secondary, + ) + LazyRow( + state = listState, + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + flingBehavior = rememberSnapFlingBehavior(lazyListState = listState), + ) { + items(count = plan.values.size) { index -> + ValueCard(value = plan.values[index], width = cardWidth) + } + } + } +} + +@Composable +private fun ValueCard(value: TextReference, width: Dp) { + Box( + modifier = Modifier + .width(width) + .heightIn(112.dp) + .clip(RoundedCornerShape(24.dp)) + .background(TangemTheme.colors3.bg.tertiary) + .padding(16.dp), + ) { + Text( + text = value.resolveReference(), + style = TangemTheme.typography3.body.medium, + color = TangemTheme.colors3.text.primary, + ) + } +} + +@Composable +private fun AttributeTabs( + attributes: ImmutableList, + selectedIndex: Int, + listState: LazyListState, + onTabClick: (Int) -> Unit, +) { + LazyRow( + state = listState, + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + itemsIndexed(attributes) { index, title -> + AttributeTab(title = title, selected = index == selectedIndex, onClick = { onTabClick(index) }) + } + } +} + +@Composable +private fun AttributeTab(title: TextReference, selected: Boolean, onClick: () -> Unit) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(100.dp)) + .background(if (selected) TangemTheme.colors3.bg.tertiary else TangemTheme.colors3.bg.secondary) + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + ) { + Text( + text = title.resolveReference(), + style = TangemTheme.typography3.body.medium, + color = if (selected) TangemTheme.colors3.text.primary else TangemTheme.colors3.text.secondary, + maxLines = 1, + ) + } +} + +private val CARD_PEEK = 72.dp + +@Preview(showBackground = true, widthDp = 402) +@Preview(showBackground = true, widthDp = 402, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ComparePlansContentPreview() { + TangemThemePreviewRedesign { + Column( + modifier = Modifier + .fillMaxWidth() + .background(TangemTheme.colors3.bg.secondary), + ) { + ComparePlansContent(compare = previewCompare()) + } + } +} + +private fun previewCompare() = TangemPaySelectPlanUM.ComparePlans( + attributes = persistentListOf( + stringReference("Visa Programme"), + stringReference("Plan fee"), + stringReference("FX fee"), + stringReference("Daily spending limit"), + stringReference("Max cards issued"), + stringReference("Additional benefits"), + ), + plans = persistentListOf( + TangemPaySelectPlanUM.ComparePlans.Plan( + name = stringReference("Basic"), + values = persistentListOf( + stringReference("Platinum"), + stringReference("$0.00"), + stringReference("1%"), + stringReference("$10.000"), + stringReference("3"), + stringReference("No"), + ), + ), + TangemPaySelectPlanUM.ComparePlans.Plan( + name = stringReference("Plus"), + values = persistentListOf( + stringReference("Signature"), + stringReference("$29.99/month"), + stringReference("1%"), + stringReference("$50.000"), + stringReference("5"), + stringReference("Benefit 1, Benefit 2, Benefit 3"), + ), + ), + ), + onDismiss = {}, +) \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/tiers/select/TangemPaySelectPlanComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/tiers/select/TangemPaySelectPlanComponent.kt new file mode 100644 index 0000000000..68b4da1190 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/tiers/select/TangemPaySelectPlanComponent.kt @@ -0,0 +1,26 @@ +package com.tangem.features.tangempay.tiers.select + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.models.wallet.UserWalletId + +internal class TangemPaySelectPlanComponent( + appComponentContext: AppComponentContext, + params: Params, +) : ComposableContentComponent, AppComponentContext by appComponentContext { + + private val model: TangemPaySelectPlanModel = getOrCreateModel(params = params) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.state.collectAsStateWithLifecycle() + TangemPaySelectPlanScreen(state = state, modifier = modifier) + } + + data class Params(val userWalletId: UserWalletId) +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/tiers/select/TangemPaySelectPlanModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/tiers/select/TangemPaySelectPlanModel.kt new file mode 100644 index 0000000000..f5951f688e --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/tiers/select/TangemPaySelectPlanModel.kt @@ -0,0 +1,193 @@ +package com.tangem.features.tangempay.tiers.select + +import androidx.compose.runtime.Stable +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.navigation.Router +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.models.account.TangemPayTariffPlan +import com.tangem.domain.models.account.TangemPayTariffPlanTransition +import com.tangem.domain.pay.usecase.GetTangemPayTariffPlanTransitionsUseCase +import com.tangem.features.tangempay.details.impl.R +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Stable +@ModelScoped +internal class TangemPaySelectPlanModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val router: Router, + private val getTransitions: GetTangemPayTariffPlanTransitionsUseCase, +) : Model() { + + private val params = paramsContainer.require() + + private var transitions: List = emptyList() + private var selectedIndex: Int = 0 + private var isConfirm: Boolean = false + + val state: StateFlow + field = MutableStateFlow(buildState()) + + init { + loadTransitions() + } + + private fun loadTransitions() { + modelScope.launch { + getTransitions(params.userWalletId).onRight { result -> + transitions = result.filter { it.type in ALLOWED_TYPES } + state.update { buildState() } + } + } + } + + private fun onPlanSelected(index: Int) { + if (index == selectedIndex) return + selectedIndex = index + state.update { buildState() } + } + + private fun onSelectClick() { + if (transitions.isEmpty()) return + isConfirm = true + state.update { buildState() } + } + + private fun onComparePlansClick() { + if (transitions.isEmpty()) return + state.update { buildState(showPlanCompare = true) } + } + + private fun onCompareDismiss() { + state.update { buildState(showPlanCompare = false) } + } + + private fun onBackClick() { + if (isConfirm) { + isConfirm = false + state.update { buildState() } + } else { + router.pop() + } + } + + private fun buildState(showPlanCompare: Boolean = false): TangemPaySelectPlanUM = TangemPaySelectPlanUM( + topBarTitle = if (isConfirm) { + resourceReference(R.string.tangempay_select_plan_confirm_title) + } else { + resourceReference(R.string.tangempay_select_plan_title) + }, + plans = transitions.map { it.plan.toPlanUM() }.toImmutableList(), + selectedIndex = selectedIndex, + onPlanSelected = ::onPlanSelected, + onBackClick = ::onBackClick, + onCloseClick = router::pop, + content = if (isConfirm) buildConfirmContent() else buildSelectContent(), + compare = if (showPlanCompare) buildCompare() else null, + ) + + private fun buildSelectContent() = TangemPaySelectPlanUM.Content.Select( + onComparePlansClick = ::onComparePlansClick, + onSelectClick = ::onSelectClick, + ) + + private fun buildCompare(): TangemPaySelectPlanUM.ComparePlans { + val plans = transitions.map { it.plan } + val orderedTitles = plans + .flatMap { it.descriptionItems } + .sortedWith(compareBy({ it.section.ordinal }, { it.order })) + .map { it.title } + .distinct() + return TangemPaySelectPlanUM.ComparePlans( + attributes = orderedTitles.map(::stringReference).toImmutableList(), + plans = plans.map { plan -> + val valueByTitle = plan.descriptionItems.associate { it.title to it.body } + TangemPaySelectPlanUM.ComparePlans.Plan( + name = stringReference(plan.name), + values = orderedTitles + .map { title -> stringReference(valueByTitle[title].orEmpty()) } + .toImmutableList(), + ) + }.toImmutableList(), + onDismiss = ::onCompareDismiss, + ) + } + + private fun buildConfirmContent(): TangemPaySelectPlanUM.Content.Confirm { + val transition = transitions[selectedIndex] + val planName = transition.plan.name + val isUpgrade = transition.type == TangemPayTariffPlanTransition.Type.UPGRADE + return TangemPaySelectPlanUM.Content.Confirm( + // TODO v_rodionov: strings hardcoded for now - wait for documentation update + title = stringReference("We will issue Visa $planName for you"), + points = buildConfirmPoints(transition, planName, isUpgrade), + confirmButtonText = resourceReference( + if (isUpgrade) { + R.string.tangempay_select_plan_btn_upgrade + } else { + R.string.tangempay_select_plan_btn_downgrade + }, + ), + onCancelClick = ::onBackClick, + onConfirmClick = {}, + ) + } + + // TODO v_rodionov: strings hardcoded for now - wait for documentation update + private fun buildConfirmPoints( + transition: TangemPayTariffPlanTransition, + planName: String, + isUpgrade: Boolean, + ): ImmutableList = if (isUpgrade) { + val feeText = transition.plan.descriptionItems + .firstOrNull { it.section == TangemPayTariffPlan.Section.PLAN_RELATED } + ?.title + listOf( + stringReference("You will get your virtual Visa $planName in minutes"), + if (feeText != null) { + stringReference("$feeText monthly fee will be taken from your account") + } else { + stringReference("No fee applied") + }, + ) + } else { + listOf( + stringReference("Your current Visa cards will be closed"), + stringReference("No fee applied"), + ) + } + .map { TangemPaySelectPlanUM.PointUM(title = it, body = null) } + .toImmutableList() + + private fun TangemPayTariffPlan.toPlanUM() = TangemPaySelectPlanUM.PlanUM( + name = stringReference(name), + imageUrl = images.firstOrNull { it.type == TangemPayTariffPlan.Image.Type.MAIN }?.url, + points = descriptionItems + .sortedWith(compareBy({ it.section.ordinal }, { it.order })) + .map { item -> + TangemPaySelectPlanUM.PointUM( + title = stringReference(item.title), + body = item.body.takeIf(String::isNotBlank)?.let(::stringReference), + ) + } + .toImmutableList(), + ) + + companion object { + private val ALLOWED_TYPES = setOf( + TangemPayTariffPlanTransition.Type.UPGRADE, + TangemPayTariffPlanTransition.Type.DOWNGRADE, + TangemPayTariffPlanTransition.Type.ACTIVATION, // TODO v_rodionov: Only for test, must be removed in future + ) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/tiers/select/TangemPaySelectPlanScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/tiers/select/TangemPaySelectPlanScreen.kt new file mode 100644 index 0000000000..3b6894551a --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/tiers/select/TangemPaySelectPlanScreen.kt @@ -0,0 +1,356 @@ +package com.tangem.features.tangempay.tiers.select + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEach +import coil.compose.SubcomposeAsyncImage +import coil.request.ImageRequest +import com.tangem.core.ui.components.RectangleShimmer +import com.tangem.core.ui.ds.TangemPagerIndicator +import com.tangem.core.ui.ds.topbar.TangemTopBar +import com.tangem.core.ui.ds2.button.Back +import com.tangem.core.ui.ds2.button.Close +import com.tangem.core.ui.ds2.button.TangemButton +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.features.tangempay.details.impl.R +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.distinctUntilChanged +import com.tangem.core.ui.R as CoreUiR + +@Composable +internal fun TangemPaySelectPlanScreen(state: TangemPaySelectPlanUM, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .fillMaxSize() + .background(TangemTheme.colors3.bg.primary), + ) { + Column(modifier = Modifier.fillMaxSize()) { + SelectPlanTopBar(state = state) + + AnimatedContent( + targetState = state.content, + modifier = Modifier.weight(1f), + contentKey = { it::class }, + label = "SelectPlanContent", + ) { content -> + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) { + when (content) { + is TangemPaySelectPlanUM.Content.Select -> SelectContent(state = state) + is TangemPaySelectPlanUM.Content.Confirm -> ConfirmContent(state = state, content = content) + } + } + } + AnimatedContent( + targetState = state.content, + contentKey = { it::class }, + label = "SelectPlanFooter", + ) { content -> + when (content) { + is TangemPaySelectPlanUM.Content.Select -> SelectFooter(content = content) + is TangemPaySelectPlanUM.Content.Confirm -> ConfirmFooter(content = content) + } + } + } + + ComparePlansBottomSheet(compare = state.compare) + } +} + +@Composable +private fun SelectPlanTopBar(state: TangemPaySelectPlanUM) { + TangemTopBar( + modifier = Modifier.statusBarsPadding(), + title = state.topBarTitle, + startContent = { TangemButton.Back(onClick = state.onBackClick) }, + endContent = { TangemButton.Close(onClick = state.onCloseClick) }, + ) +} + +@Composable +private fun ColumnScope.SelectContent(state: TangemPaySelectPlanUM) { + val plans = state.plans.takeIf { it.isNotEmpty() } ?: return + + val pagerState = rememberPagerState( + initialPage = state.selectedIndex.coerceIn(0, plans.lastIndex), + pageCount = { plans.size }, + ) + + LaunchedEffect(pagerState) { + snapshotFlow { pagerState.settledPage } + .distinctUntilChanged() + .collect(state.onPlanSelected) + } + + HorizontalPager( + state = pagerState, + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp), + contentPadding = PaddingValues(horizontal = 24.dp), + pageSpacing = 8.dp, + beyondViewportPageCount = 1, + ) { page -> + PlanCard(imageUrl = plans[page].imageUrl) + } + + if (plans.size > 1) { + TangemPagerIndicator( + pagerState = pagerState, + modifier = Modifier + .align(Alignment.Start) + .padding(start = 24.dp, top = 16.dp), + ) + } + val plan = plans[state.selectedIndex.coerceIn(0, plans.lastIndex)] + PlanDetails( + title = plan.name, + points = plan.points, + ) +} + +@Composable +private fun ColumnScope.PlanDetails(title: TextReference, points: ImmutableList) { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(top = 24.dp, bottom = 12.dp), + text = title.resolveReference(), + style = TangemTheme.typography3.heading.medium, + color = TangemTheme.colors3.text.primary, + ) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(top = 12.dp, bottom = 12.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + points.fastForEach { point -> PlanPoint(point = point) } + } +} + +@Composable +private fun PlanPoint(point: TangemPaySelectPlanUM.PointUM, modifier: Modifier = Modifier) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.Top, + ) { + Icon( + modifier = Modifier.size(20.dp), + painter = painterResource(id = CoreUiR.drawable.ic_information_24), + contentDescription = null, + tint = TangemTheme.colors3.icon.secondary, + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = point.title.resolveReference(), + style = TangemTheme.typography3.subheading.medium, + color = TangemTheme.colors3.text.primary, + ) + point.body?.let { body -> + Text( + text = body.resolveReference(), + style = TangemTheme.typography3.subheading.medium, + color = TangemTheme.colors3.text.secondary, + ) + } + } + } +} + +@Composable +private fun ColumnScope.ConfirmContent(state: TangemPaySelectPlanUM, content: TangemPaySelectPlanUM.Content.Confirm) { + val selectedPlan = state.plans.getOrNull(state.selectedIndex) ?: return + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(top = 12.dp), + contentAlignment = Alignment.Center, + ) { + PlanCard(imageUrl = selectedPlan.imageUrl) + } + Spacer(modifier = Modifier.weight(1f)) + PlanDetails( + title = content.title, + points = content.points, + ) +} + +@Composable +private fun SelectFooter(content: TangemPaySelectPlanUM.Content.Select, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp) + .navigationBarsPadding(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + TangemButton( + modifier = Modifier.fillMaxWidth(), + variant = TangemButton.Variant.Secondary, + size = TangemButton.Size.X12, + text = resourceReference(R.string.tangempay_select_plan_compare), + onClick = content.onComparePlansClick, + ) + TangemButton( + modifier = Modifier.fillMaxWidth(), + variant = TangemButton.Variant.Primary, + size = TangemButton.Size.X12, + text = resourceReference(R.string.tangempay_select_plan_btn_select), + onClick = content.onSelectClick, + ) + } +} + +@Composable +private fun ConfirmFooter(content: TangemPaySelectPlanUM.Content.Confirm, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp) + .navigationBarsPadding(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + TangemButton( + modifier = Modifier.fillMaxWidth(), + variant = TangemButton.Variant.Secondary, + size = TangemButton.Size.X12, + text = resourceReference(R.string.tangempay_select_plan_btn_cancel), + onClick = content.onCancelClick, + ) + TangemButton( + modifier = Modifier.fillMaxWidth(), + variant = TangemButton.Variant.Primary, + size = TangemButton.Size.X12, + text = content.confirmButtonText, + onClick = content.onConfirmClick, + ) + } +} + +@Suppress("MagicNumber") +@Composable +private fun PlanCard(imageUrl: String?, modifier: Modifier = Modifier) { + SubcomposeAsyncImage( + modifier = modifier + .fillMaxWidth() + .aspectRatio(ratio = 266f / 172f) + .clip(RoundedCornerShape(12.dp)), + model = ImageRequest.Builder(LocalContext.current) + .data(imageUrl) + .crossfade(true) + .build(), + loading = { + RectangleShimmer( + modifier = Modifier.fillMaxSize(), + radius = 12.dp, + ) + }, + error = { + Box( + modifier = Modifier + .fillMaxSize() + .background(TangemTheme.colors3.bg.secondary), + ) + }, + contentScale = ContentScale.Crop, + contentDescription = null, + ) +} + +@Preview(showBackground = true, device = Devices.PIXEL_7_PRO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, device = Devices.PIXEL_7_PRO) +@Composable +private fun TangemPaySelectPlanScreenPreview() { + TangemThemePreviewRedesign { + TangemPaySelectPlanScreen(state = previewState(isConfirm = false)) + } +} + +@Preview(showBackground = true, device = Devices.PIXEL_7_PRO) +@Composable +private fun TangemPaySelectPlanConfirmPreview() { + TangemThemePreviewRedesign { + TangemPaySelectPlanScreen(state = previewState(isConfirm = true)) + } +} + +private fun previewState(isConfirm: Boolean) = TangemPaySelectPlanUM( + topBarTitle = stringReference(if (isConfirm) "Confirm selection" else "Select plan"), + plans = persistentListOf( + TangemPaySelectPlanUM.PlanUM( + name = stringReference("Plus"), + imageUrl = null, + points = persistentListOf( + TangemPaySelectPlanUM.PointUM( + title = stringReference("2 airport lounge pass a year"), + body = stringReference("Travel insurance and other benefits"), + ), + TangemPaySelectPlanUM.PointUM(stringReference("$50.000 daily spending limit"), null), + TangemPaySelectPlanUM.PointUM(stringReference("$29.99 / month"), null), + ), + ), + ), + selectedIndex = 0, + onPlanSelected = {}, + onBackClick = {}, + onCloseClick = {}, + content = if (isConfirm) { + TangemPaySelectPlanUM.Content.Confirm( + title = stringReference("We will issue Visa Plus for you"), + points = persistentListOf( + TangemPaySelectPlanUM.PointUM( + title = stringReference("You will get your virtual Visa Plus in minutes"), + body = null, + ), + TangemPaySelectPlanUM.PointUM( + title = stringReference("$29.99 / month will be charged from your account"), + body = null, + ), + ), + confirmButtonText = stringReference("Upgrade plan"), + onCancelClick = {}, + onConfirmClick = {}, + ) + } else { + TangemPaySelectPlanUM.Content.Select( + onComparePlansClick = {}, + onSelectClick = {}, + ) + }, +) \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/tiers/select/TangemPaySelectPlanUM.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/tiers/select/TangemPaySelectPlanUM.kt new file mode 100644 index 0000000000..d4ce6fd20b --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/tiers/select/TangemPaySelectPlanUM.kt @@ -0,0 +1,61 @@ +package com.tangem.features.tangempay.tiers.select + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.extensions.TextReference +import kotlinx.collections.immutable.ImmutableList + +@Immutable +internal data class TangemPaySelectPlanUM( + val topBarTitle: TextReference, + val plans: ImmutableList, + val selectedIndex: Int, + val onPlanSelected: (Int) -> Unit, + val onBackClick: () -> Unit, + val onCloseClick: () -> Unit, + val content: Content, + val compare: ComparePlans? = null, +) { + + @Immutable + data class PlanUM( + val name: TextReference, + val imageUrl: String?, + val points: ImmutableList, + ) + + @Immutable + data class ComparePlans( + val attributes: ImmutableList, + val plans: ImmutableList, + val onDismiss: () -> Unit, + ) { + @Immutable + data class Plan( + val name: TextReference, + val values: ImmutableList, + ) + } + + @Immutable + data class PointUM( + val title: TextReference, + val body: TextReference?, + ) + + @Immutable + sealed interface Content { + + data class Select( + val onComparePlansClick: () -> Unit, + val onSelectClick: () -> Unit, + ) : Content + + data class Confirm( + val title: TextReference, + val points: ImmutableList, + val confirmButtonText: TextReference, + val onCancelClick: () -> Unit, + val onConfirmClick: () -> Unit, + ) : Content + } +} \ No newline at end of file