Updated on 2026-08-14

This commit is contained in:
Tangem 2023-07-19 18:22:26 +03:00
parent 6171aa8097
commit 3e1a45dbad
23 changed files with 360 additions and 40 deletions

View file

@ -3,7 +3,7 @@ package com.tangem.tap.common.analytics.converters
import com.shopify.buy3.Storefront
import com.tangem.common.Converter
import com.tangem.tap.common.analytics.events.Shop
import com.tangem.tap.common.shop.data.ProductType
import com.tangem.tap.features.shop.domain.models.ProductType
/**
[REDACTED_AUTHOR]

View file

@ -8,12 +8,13 @@ import com.tangem.core.analytics.Analytics
import com.tangem.datasource.config.models.ShopifyShop
import com.tangem.tap.common.analytics.converters.ShopOrderToEventConverter
import com.tangem.tap.common.extensions.filterNotNull
import com.tangem.tap.common.shop.data.ProductType
import com.tangem.tap.common.shop.data.TangemProduct
import com.tangem.tap.common.shop.data.TotalSum
import com.tangem.tap.common.shop.googlepay.GooglePayService
import com.tangem.tap.common.shop.shopify.ShopifyService
import com.tangem.tap.common.shop.shopify.data.CheckoutItem
import com.tangem.tap.features.shop.domain.models.ProductType
import com.tangem.tap.features.shop.domain.models.ProductType.Companion.SKUS_TO_DISPLAY
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
@ -206,12 +207,6 @@ class TangemShopService(application: Application, shopifyShop: ShopifyShop) {
}
}
}
companion object {
const val TANGEM_WALLET_2_CARDS_SKU = "TG115X2-S"
const val TANGEM_WALLET_3_CARDS_SKU = "TG115X3-S"
val SKUS_TO_DISPLAY = listOf(TANGEM_WALLET_2_CARDS_SKU, TANGEM_WALLET_3_CARDS_SKU)
}
}
private fun Storefront.MoneyV2.format(): String {

View file

@ -1,19 +0,0 @@
package com.tangem.tap.common.shop.data
import com.tangem.tap.common.shop.TangemShopService
enum class ProductType(val sku: String) {
WALLET_2_CARDS(TangemShopService.TANGEM_WALLET_2_CARDS_SKU),
WALLET_3_CARDS(TangemShopService.TANGEM_WALLET_3_CARDS_SKU),
;
companion object {
fun fromSku(sku: String): ProductType? {
return when (sku) {
WALLET_2_CARDS.sku -> WALLET_2_CARDS
WALLET_3_CARDS.sku -> WALLET_3_CARDS
else -> null
}
}
}
}

View file

@ -1,5 +1,7 @@
package com.tangem.tap.common.shop.data
import com.tangem.tap.features.shop.domain.models.ProductType
data class TangemProduct(
val type: ProductType,
val totalSum: TotalSum? = null,

View file

@ -2,10 +2,13 @@ package com.tangem.tap.features.shop.data
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.ShopResponse
import com.tangem.domain.common.extensions.withIOContext
import com.tangem.tap.features.shop.domain.ShopRepository
import com.tangem.tap.features.shop.domain.models.SalesProduct
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.runCatching
import timber.log.Timber
import java.util.Locale
/**
* Default implementation of shop feature repository
@ -20,6 +23,8 @@ internal class DefaultShopRepository(
private val dispatchers: CoroutineDispatcherProvider,
) : ShopRepository {
private val salesProductConverter = SalesProductConverter()
override suspend fun isShopifyOrderingAvailable(): Boolean {
return runCatching(dispatchers.io) { tangemTechApi.getShopInfo(name = SHOPIFY_NAME) }
.fold(
@ -31,7 +36,24 @@ internal class DefaultShopRepository(
)
}
override suspend fun getSalesProductInfo(): List<SalesProduct> {
return withIOContext {
val salesInfo = tangemTechApi.getSalesInfo(locale = getLocaleName(), shops = SHOPIFY_NAME)
salesProductConverter.convert(salesInfo)
}
}
private fun getLocaleName(): String {
return if (Locale.getDefault().language == "ru") {
RU_LOCALE
} else {
EN_LOCALE
}
}
private companion object {
const val SHOPIFY_NAME = "shopify"
private const val SHOPIFY_NAME = "shopify"
private const val RU_LOCALE = "ru"
private const val EN_LOCALE = "en"
}
}

View file

@ -0,0 +1,40 @@
package com.tangem.tap.features.shop.data
import com.tangem.datasource.api.tangemTech.models.SalesResponse
import com.tangem.tap.features.shop.domain.models.Notification
import com.tangem.tap.features.shop.domain.models.ProductState
import com.tangem.tap.features.shop.domain.models.ProductType
import com.tangem.tap.features.shop.domain.models.SalesProduct
import com.tangem.utils.converter.Converter
internal class SalesProductConverter : Converter<SalesResponse, List<SalesProduct>> {
override fun convert(value: SalesResponse): List<SalesProduct> {
return value.sales.map { sales ->
val productState = when (sales.state) {
"order" -> ProductState.ORDER
"pre-order" -> ProductState.PRE_ORDER
"sold-out" -> ProductState.SOLD_OUT
else -> ProductState.SOLD_OUT
}
val productType = when (sales.product.code) {
"pack2" -> ProductType.WALLET_2_CARDS
"pack3" -> ProductType.WALLET_3_CARDS
else -> ProductType.WALLET_3_CARDS
}
SalesProduct(
id = sales.id,
productType = productType,
state = productState,
name = sales.product.name,
notification = sales.notification?.let { notification ->
Notification(
type = notification.type,
title = notification.title,
description = notification.description,
)
},
)
}
}
}

View file

@ -3,6 +3,8 @@ package com.tangem.tap.features.shop.di
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.tap.features.shop.data.DefaultShopRepository
import com.tangem.tap.features.shop.domain.DefaultShopifyOrderingAvailabilityUseCase
import com.tangem.tap.features.shop.domain.GetShopifySalesProductsUseCase
import com.tangem.tap.features.shop.domain.ShopRepository
import com.tangem.tap.features.shop.domain.ShopifyOrderingAvailabilityUseCase
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
@ -17,12 +19,26 @@ internal object ShopUseCaseModule {
@Provides
@ViewModelScoped
fun provideShopifyOrderingAvailabilityUseCase(
tangemTechApi: TangemTechApi,
dispatchers: CoroutineDispatcherProvider,
): ShopifyOrderingAvailabilityUseCase {
fun provideShopifyOrderingAvailabilityUseCase(shopRepository: ShopRepository): ShopifyOrderingAvailabilityUseCase {
return DefaultShopifyOrderingAvailabilityUseCase(
shopRepository = DefaultShopRepository(tangemTechApi, dispatchers),
shopRepository = shopRepository,
)
}
@Provides
@ViewModelScoped
fun provideGetShopifySalesProductsUseCase(shopRepository: ShopRepository): GetShopifySalesProductsUseCase {
return GetShopifySalesProductsUseCase(
shopRepository = shopRepository,
)
}
@Provides
@ViewModelScoped
fun provideDefaultShopRepository(
tangemTechApi: TangemTechApi,
dispatchers: CoroutineDispatcherProvider,
): ShopRepository {
return DefaultShopRepository(tangemTechApi = tangemTechApi, dispatchers = dispatchers)
}
}

View file

@ -0,0 +1,23 @@
package com.tangem.tap.features.shop.di
import com.tangem.core.featuretoggle.manager.FeatureTogglesManager
import com.tangem.tap.features.shop.toggles.DefaultShopifyFeatureToggleManager
import com.tangem.tap.features.shop.toggles.ShopifyFeatureToggleManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object ShopifyTogglesModule {
@Provides
@Singleton
fun provideDefaultShopifyFeatureToggleManager(
featureToggleManager: FeatureTogglesManager,
): ShopifyFeatureToggleManager {
return DefaultShopifyFeatureToggleManager(featureToggleManager)
}
}

View file

@ -0,0 +1,25 @@
package com.tangem.tap.features.shop.domain
import arrow.core.Either
import arrow.core.left
import arrow.core.right
import com.tangem.tap.features.shop.domain.models.SalesError
import com.tangem.tap.features.shop.domain.models.SalesProduct
/**
* Use case to get shopify available products
*
* @property shopRepository shop feature repository
*/
class GetShopifySalesProductsUseCase(
private val shopRepository: ShopRepository,
) {
suspend operator fun invoke(): Either<SalesError, List<SalesProduct>> {
return try {
shopRepository.getSalesProductInfo().right()
} catch (e: Exception) {
SalesError.DataError(e).left()
}
}
}

View file

@ -1,12 +1,17 @@
package com.tangem.tap.features.shop.domain
import com.tangem.tap.features.shop.domain.models.SalesProduct
/**
* Shop feature repository
*
[REDACTED_AUTHOR]
*/
internal interface ShopRepository {
interface ShopRepository {
/** Get shopify ordering availability */
suspend fun isShopifyOrderingAvailable(): Boolean
/** Get actual sales product info */
suspend fun getSalesProductInfo(): List<SalesProduct>
}

View file

@ -0,0 +1,22 @@
package com.tangem.tap.features.shop.domain.models
private const val TANGEM_WALLET_2_CARDS_SKU = "TG115X2-S"
private const val TANGEM_WALLET_3_CARDS_SKU = "TG115X3-S"
enum class ProductType(val sku: String) {
WALLET_2_CARDS(TANGEM_WALLET_2_CARDS_SKU),
WALLET_3_CARDS(TANGEM_WALLET_3_CARDS_SKU),
;
companion object {
val SKUS_TO_DISPLAY = listOf(TANGEM_WALLET_2_CARDS_SKU, TANGEM_WALLET_3_CARDS_SKU)
fun fromSku(sku: String): ProductType? {
return when (sku) {
WALLET_2_CARDS.sku -> WALLET_2_CARDS
WALLET_3_CARDS.sku -> WALLET_3_CARDS
else -> null
}
}
}
}

View file

@ -0,0 +1,5 @@
package com.tangem.tap.features.shop.domain.models
sealed class SalesError {
data class DataError(val cause: Throwable) : SalesError()
}

View file

@ -0,0 +1,30 @@
package com.tangem.tap.features.shop.domain.models
/**
* Sales product
*
* @property id product id
* @property productType shows TW2 cards or 3 cards
* @property state state as order available etc
* @property name product name
* @property notification optional notification
*/
data class SalesProduct(
val id: String,
val productType: ProductType,
val state: ProductState,
val name: String,
val notification: Notification?,
)
data class Notification(
val type: String,
val title: String,
val description: String,
)
enum class ProductState {
ORDER,
SOLD_OUT,
PRE_ORDER,
}

View file

@ -2,6 +2,7 @@ package com.tangem.tap.features.shop.presentation
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.tangem.tap.features.shop.domain.GetShopifySalesProductsUseCase
import com.tangem.tap.features.shop.domain.ShopifyOrderingAvailabilityUseCase
import com.tangem.tap.features.shop.redux.ShopAction
import com.tangem.tap.proxy.AppStateHolder
@ -15,6 +16,7 @@ import javax.inject.Inject
* Shop screen view model
*
* @property shopifyOrderingAvailabilityUseCase use case to define shopify ordering availability
* @property getShopifySalesProductsUseCase use case to get actual sales info
* @property dispatchers coroutine dispatchers provider
* @property appStateHolder redux state holder
*
@ -23,6 +25,7 @@ import javax.inject.Inject
@HiltViewModel
internal class ShopViewModel @Inject constructor(
private val shopifyOrderingAvailabilityUseCase: ShopifyOrderingAvailabilityUseCase,
private val getShopifySalesProductsUseCase: GetShopifySalesProductsUseCase,
private val dispatchers: CoroutineDispatcherProvider,
private val appStateHolder: AppStateHolder,
) : ViewModel() {
@ -36,4 +39,22 @@ internal class ShopViewModel @Inject constructor(
appStateHolder.mainStore?.dispatch(action = ShopAction.SetOrderingDelayBlockVisibility(visibility))
}
}
/**
* Get actual sales products info
* to configure view dynamically
*/
fun getActualSalesInfo() {
viewModelScope.launch(dispatchers.main) {
val action = getShopifySalesProductsUseCase().fold(
ifLeft = {
ShopAction.SalesProductsError
},
ifRight = {
ShopAction.SalesProductsLoaded(it)
},
)
appStateHolder.mainStore?.dispatch(action)
}
}
}

View file

@ -2,9 +2,10 @@ package com.tangem.tap.features.shop.redux
import android.content.Intent
import com.tangem.tap.common.redux.NotificationAction
import com.tangem.tap.common.shop.data.ProductType
import com.tangem.tap.features.shop.domain.models.ProductType
import com.tangem.tap.common.shop.data.TangemProduct
import com.tangem.tap.common.shop.googlepay.GooglePayService
import com.tangem.tap.features.shop.domain.models.SalesProduct
import com.tangem.wallet.R
import org.rekotlin.Action
@ -44,4 +45,8 @@ sealed interface ShopAction : Action {
object ResetState : ShopAction
data class SetOrderingDelayBlockVisibility(val visibility: Boolean) : ShopAction
data class SalesProductsLoaded(val salesProducts: List<SalesProduct>) : ShopAction
object SalesProductsError : ShopAction
}

View file

@ -39,5 +39,11 @@ private fun internalReduce(action: Action, state: ShopState): ShopState {
is ShopAction.FinishSuccessfulOrder,
is ShopAction.LoadProducts.Failure,
-> state
is ShopAction.SalesProductsLoaded -> state.copy(
salesProducts = action.salesProducts,
)
is ShopAction.SalesProductsError -> state.copy(
salesProducts = emptyList(),
)
}
}

View file

@ -1,12 +1,14 @@
package com.tangem.tap.features.shop.redux
import com.tangem.tap.common.shop.data.ProductType
import com.tangem.tap.features.shop.domain.models.ProductType
import com.tangem.tap.common.shop.data.TangemProduct
import com.tangem.tap.features.shop.domain.models.SalesProduct
import org.rekotlin.StateType
data class ShopState(
val availableProducts: List<TangemProduct> = emptyList(),
val selectedProduct: ProductType = ProductType.WALLET_3_CARDS,
val salesProducts: List<SalesProduct> = emptyList(),
val promoCode: String? = null,
val promoCodeLoading: Boolean = false,
val isGooglePayAvailable: Boolean = false, // TODO: change when we add support for GPay

View file

@ -0,0 +1,11 @@
package com.tangem.tap.features.shop.toggles
import com.tangem.core.featuretoggle.manager.FeatureTogglesManager
internal class DefaultShopifyFeatureToggleManager(
private val featureTogglesManager: FeatureTogglesManager,
) : ShopifyFeatureToggleManager {
override val isDynamicSalesProductsEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled("SHOPIFY_DYNAMIC_ENABLED")
}

View file

@ -0,0 +1,10 @@
package com.tangem.tap.features.shop.toggles
/**
* Shopify feature toggle manager that provides info about shopify toggle availability
*
*/
interface ShopifyFeatureToggleManager {
val isDynamicSalesProductsEnabled: Boolean
}

View file

@ -17,20 +17,27 @@ import com.tangem.tap.common.KeyboardObserver
import com.tangem.tap.common.extensions.getQuantityString
import com.tangem.tap.common.extensions.hide
import com.tangem.tap.common.extensions.show
import com.tangem.tap.common.shop.data.ProductType
import com.tangem.tap.features.BaseStoreFragment
import com.tangem.tap.features.shop.domain.models.ProductState
import com.tangem.tap.features.shop.domain.models.ProductType
import com.tangem.tap.features.shop.domain.models.SalesProduct
import com.tangem.tap.features.shop.presentation.ShopViewModel
import com.tangem.tap.features.shop.redux.ShopAction
import com.tangem.tap.features.shop.redux.ShopState
import com.tangem.tap.features.shop.toggles.ShopifyFeatureToggleManager
import com.tangem.tap.store
import com.tangem.wallet.R
import com.tangem.wallet.databinding.FragmentShopBinding
import dagger.hilt.android.AndroidEntryPoint
import org.rekotlin.StoreSubscriber
import javax.inject.Inject
@AndroidEntryPoint
internal class ShopFragment : BaseStoreFragment(R.layout.fragment_shop), StoreSubscriber<ShopState> {
@Inject
lateinit var shopifyFeatureToggleManager: ShopifyFeatureToggleManager
private val binding: FragmentShopBinding by viewBinding(FragmentShopBinding::bind)
private var cardTranslationY = 70f
@ -50,7 +57,11 @@ internal class ShopFragment : BaseStoreFragment(R.layout.fragment_shop), StoreSu
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel.checkOrderingDelayBlockVisibility()
if (shopifyFeatureToggleManager.isDynamicSalesProductsEnabled) {
viewModel.getActualSalesInfo()
} else {
viewModel.checkOrderingDelayBlockVisibility()
}
activity?.onBackPressedDispatcher?.addCallback(
this,
@ -142,7 +153,11 @@ internal class ShopFragment : BaseStoreFragment(R.layout.fragment_shop), StoreSu
animateProductSelection(state.selectedProduct)
handlePriceState(state)
handlePromoCodeState(state)
handleOrderingDelayBlock(isVisible = state.isOrderingDelayBlockVisible)
if (shopifyFeatureToggleManager.isDynamicSalesProductsEnabled) {
handleNotificationBlock(state)
} else {
handleOrderingDelayBlock(isVisible = state.isOrderingDelayBlockVisible)
}
handleButtonsState(state)
}
@ -187,6 +202,17 @@ internal class ShopFragment : BaseStoreFragment(R.layout.fragment_shop), StoreSu
if (isVisible) binding.tvSoldOutDesc.show() else binding.tvSoldOutDesc.hide()
}
private fun handleNotificationBlock(state: ShopState) {
if (isVisible) {
binding.tvSoldOutDesc.show()
getSelectedSalesProduct(state)?.notification?.let { notification ->
binding.tvSoldOutDesc.text = notification.description
}
} else {
binding.tvSoldOutDesc.hide()
}
}
private fun handleButtonsState(state: ShopState) = with(binding) {
btnPayGooglePay.root.show(state.isGooglePayAvailable)
btnAlternativePayment.show(state.isGooglePayAvailable)
@ -194,8 +220,15 @@ internal class ShopFragment : BaseStoreFragment(R.layout.fragment_shop), StoreSu
if (state.total != null) {
btnAlternativePayment.setOnClickListener { store.dispatch(ShopAction.StartWebCheckout) }
btnMainAction.setOnClickListener { store.dispatch(ShopAction.StartWebCheckout) }
btnPayGooglePay.root.setOnClickListener { store.dispatch(ShopAction.BuyWithGooglePay) }
btnMainAction.setOnClickListener { store.dispatch(ShopAction.StartWebCheckout) }
if (state.salesProducts.isNotEmpty()) {
getSelectedSalesProduct(state)?.let { selectedProduct ->
btnMainAction.text = getMainBtnTextByProductState(
productState = selectedProduct.state,
)
}
}
}
}
@ -203,4 +236,16 @@ internal class ShopFragment : BaseStoreFragment(R.layout.fragment_shop), StoreSu
store.dispatch(ShopAction.ResetState)
super.handleOnBackPressed()
}
private fun getSelectedSalesProduct(state: ShopState): SalesProduct? {
return state.salesProducts.find {
it.productType == state.selectedProduct
}
}
private fun getMainBtnTextByProductState(productState: ProductState): String = when (productState) {
ProductState.ORDER -> getString(R.string.shop_buy_now)
ProductState.SOLD_OUT -> "Sold out" // getString(R.string.sold_out) // todo finalize in next PR
ProductState.PRE_ORDER -> "Pre order" // getString(R.string.pre_order) // todo finalize in next PR
}
}

View file

@ -54,4 +54,10 @@ interface TangemTechApi {
@GET("shops")
suspend fun getShopInfo(@Query(value = "name") name: String): ShopResponse
@GET("sales")
suspend fun getSalesInfo(
@Query(value = "locale") locale: String,
@Query(value = "shops") shops: String,
): SalesResponse
}

View file

@ -0,0 +1,44 @@
package com.tangem.datasource.api.tangemTech.models
import com.squareup.moshi.Json
/**
* Sales response
*/
data class SalesResponse(
@Json(name = "sales") val sales: List<Sales>,
)
/**
* Sales info
*
* @property id sales id
* @property state state as order, sold-out, pre-order
* @property product product that is sales
* @property notification optional notification for product
*/
data class Sales(
@Json(name = "id") val id: String,
@Json(name = "state") val state: String,
@Json(name = "product") val product: Product,
@Json(name = "notification") val notification: Notification?,
)
/**
* Product
*
* @property id product id
* @property code code that shows what product it is
* @property name product name
*/
data class Product(
@Json(name = "id") val id: String,
@Json(name = "code") val code: String,
@Json(name = "name") val name: String,
)
data class Notification(
@Json(name = "type") val type: String,
@Json(name = "title") val title: String,
@Json(name = "description") val description: String,
)

View file

@ -22,5 +22,9 @@
{
"name": "REDESIGNED_TOKEN_DETAIL_SCREEN_ENABLED",
"version": "undefined"
},
{
"name": "SHOPIFY_DYNAMIC_ENABLED",
"version": "undefined"
}
]