Updated on 2026-08-14

This commit is contained in:
Tangem 2023-06-19 16:36:56 +08:00
parent 16f8451745
commit 00de15f8e0
15 changed files with 223 additions and 71 deletions

View file

@ -0,0 +1,37 @@
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.tap.features.shop.domain.ShopRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.runCatching
import timber.log.Timber
/**
* Default implementation of shop feature repository
*
* @property tangemTechApi TangemTech API
* @property dispatchers coroutine dispatchers provider
*
[REDACTED_AUTHOR]
*/
internal class DefaultShopRepository(
private val tangemTechApi: TangemTechApi,
private val dispatchers: CoroutineDispatcherProvider,
) : ShopRepository {
override suspend fun isShopifyOrderingAvailable(): Boolean {
return runCatching(dispatchers.io) { tangemTechApi.getShopInfo(name = SHOPIFY_NAME) }
.fold(
onSuccess = ShopResponse::isOrderingAvailable,
onFailure = {
Timber.e("Server error. isShopifyOrderingAvailable returns default value (false)")
false
},
)
}
private companion object {
const val SHOPIFY_NAME = "shopify"
}
}

View file

@ -0,0 +1,28 @@
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.ShopifyOrderingAvailabilityUseCase
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ViewModelComponent
import dagger.hilt.android.scopes.ViewModelScoped
@Module
@InstallIn(ViewModelComponent::class)
internal object ShopUseCaseModule {
@Provides
@ViewModelScoped
fun provideShopifyOrderingAvailabilityUseCase(
tangemTechApi: TangemTechApi,
dispatchers: CoroutineDispatcherProvider,
): ShopifyOrderingAvailabilityUseCase {
return DefaultShopifyOrderingAvailabilityUseCase(
shopRepository = DefaultShopRepository(tangemTechApi, dispatchers),
)
}
}

View file

@ -0,0 +1,15 @@
package com.tangem.tap.features.shop.domain
/**
* Default implementation of use case to define shopify ordering availability
*
* @property shopRepository shop feature repository
*
[REDACTED_AUTHOR]
*/
internal class DefaultShopifyOrderingAvailabilityUseCase(
private val shopRepository: ShopRepository,
) : ShopifyOrderingAvailabilityUseCase {
override suspend fun invoke() = shopRepository.isShopifyOrderingAvailable()
}

View file

@ -0,0 +1,12 @@
package com.tangem.tap.features.shop.domain
/**
* Shop feature repository
*
[REDACTED_AUTHOR]
*/
internal interface ShopRepository {
/** Get shopify ordering availability */
suspend fun isShopifyOrderingAvailable(): Boolean
}

View file

@ -0,0 +1,12 @@
package com.tangem.tap.features.shop.domain
/**
* Use case to define shopify ordering availability
*
[REDACTED_AUTHOR]
*/
internal interface ShopifyOrderingAvailabilityUseCase {
/** Get availability */
suspend operator fun invoke(): Boolean
}

View file

@ -0,0 +1,39 @@
package com.tangem.tap.features.shop.presentation
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.tangem.tap.features.shop.domain.ShopifyOrderingAvailabilityUseCase
import com.tangem.tap.features.shop.redux.ShopAction
import com.tangem.tap.proxy.AppStateHolder
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.runCatching
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Shop screen view model
*
* @property shopifyOrderingAvailabilityUseCase use case to define shopify ordering availability
* @property dispatchers coroutine dispatchers provider
* @property appStateHolder redux state holder
*
[REDACTED_AUTHOR]
*/
@HiltViewModel
internal class ShopViewModel @Inject constructor(
private val shopifyOrderingAvailabilityUseCase: ShopifyOrderingAvailabilityUseCase,
private val dispatchers: CoroutineDispatcherProvider,
private val appStateHolder: AppStateHolder,
) : ViewModel() {
/** Check ordering delay block visibility */
fun checkOrderingDelayBlockVisibility() {
viewModelScope.launch(dispatchers.main) {
val visibility = runCatching(dispatchers.io) { shopifyOrderingAvailabilityUseCase() }
.fold(onSuccess = { !it }, onFailure = { false })
appStateHolder.mainStore?.dispatch(action = ShopAction.SetOrderingDelayBlockVisibility(visibility))
}
}
}

View file

@ -8,38 +8,40 @@ import com.tangem.tap.common.shop.googlepay.GooglePayService
import com.tangem.wallet.R
import org.rekotlin.Action
sealed class ShopAction : Action {
sealed interface ShopAction : Action {
object LoadProducts : ShopAction() {
data class Success(val products: List<TangemProduct>) : ShopAction()
object Failure : ShopAction(), NotificationAction {
object LoadProducts : ShopAction {
data class Success(val products: List<TangemProduct>) : ShopAction
object Failure : ShopAction, NotificationAction {
override val messageResource = R.string.common_server_unavailable
}
}
data class ApplyPromoCode(val promoCode: String) : ShopAction() {
data class Success(val promoCode: String?, val products: List<TangemProduct>) : ShopAction()
object InvalidPromoCode : ShopAction()
data class ApplyPromoCode(val promoCode: String) : ShopAction {
data class Success(val promoCode: String?, val products: List<TangemProduct>) : ShopAction
object InvalidPromoCode : ShopAction
}
object BuyWithGooglePay : ShopAction() {
object UserCancelled : ShopAction()
data class HandleGooglePayResponse(val resultCode: Int, val data: Intent?) : ShopAction()
object BuyWithGooglePay : ShopAction {
object UserCancelled : ShopAction
data class HandleGooglePayResponse(val resultCode: Int, val data: Intent?) : ShopAction
data class Failure(val exception: Throwable) : ShopAction()
object Success : ShopAction()
data class Failure(val exception: Throwable) : ShopAction
object Success : ShopAction
}
object StartWebCheckout : ShopAction()
object StartWebCheckout : ShopAction
data class CheckIfGooglePayAvailable(val googlePayService: GooglePayService) : ShopAction() {
object Success : ShopAction()
object Failure : ShopAction()
data class CheckIfGooglePayAvailable(val googlePayService: GooglePayService) : ShopAction {
object Success : ShopAction
object Failure : ShopAction
}
data class SelectProduct(val productType: ProductType) : ShopAction()
data class SelectProduct(val productType: ProductType) : ShopAction
object FinishSuccessfulOrder : ShopAction()
object FinishSuccessfulOrder : ShopAction
object ResetState : ShopAction()
object ResetState : ShopAction
data class SetOrderingDelayBlockVisibility(val visibility: Boolean) : ShopAction
}

View file

@ -6,63 +6,38 @@ object ShopReducer {
fun reduce(action: Action, state: ShopState): ShopState = internalReduce(action, state)
}
@Suppress("ComplexMethod")
private fun internalReduce(action: Action, state: ShopState): ShopState {
if (action !is ShopAction) return state
return when (action) {
is ShopAction.ApplyPromoCode -> state.copy(
promoCode = action.promoCode,
promoCodeLoading = true,
)
ShopAction.BuyWithGooglePay -> state
ShopAction.LoadProducts -> state
is ShopAction.LoadProducts.Success -> {
state.copy(
availableProducts = action.products,
)
}
ShopAction.StartWebCheckout -> state
ShopAction.ApplyPromoCode.InvalidPromoCode -> state.copy(
promoCode = null,
promoCodeLoading = false,
)
is ShopAction.ApplyPromoCode -> state.copy(promoCode = action.promoCode, promoCodeLoading = true)
is ShopAction.LoadProducts.Success -> state.copy(availableProducts = action.products)
is ShopAction.ApplyPromoCode.InvalidPromoCode -> state.copy(promoCode = null, promoCodeLoading = false)
is ShopAction.ApplyPromoCode.Success -> {
state.copy(
promoCode = action.promoCode,
availableProducts = action.products,
promoCodeLoading = false,
)
}
is ShopAction.SelectProduct -> state.copy(selectedProduct = action.productType)
)
}
is ShopAction.SelectProduct -> {
state.copy(
selectedProduct = action.productType,
)
}
is ShopAction.CheckIfGooglePayAvailable -> {
state
}
ShopAction.CheckIfGooglePayAvailable.Failure -> {
state.copy(isGooglePayAvailable = false)
}
ShopAction.CheckIfGooglePayAvailable.Success -> {
state.copy(isGooglePayAvailable = false) // TODO: change when we add support for GPay
}
is ShopAction.BuyWithGooglePay.Failure -> {
state
}
is ShopAction.BuyWithGooglePay.HandleGooglePayResponse -> {
state
}
ShopAction.BuyWithGooglePay.Success -> {
state
}
ShopAction.BuyWithGooglePay.UserCancelled -> {
state
}
ShopAction.FinishSuccessfulOrder -> state
ShopAction.ResetState -> ShopState()
ShopAction.LoadProducts.Failure -> state
// TODO: change when we add support for GPay
is ShopAction.CheckIfGooglePayAvailable.Failure -> state.copy(isGooglePayAvailable = false)
is ShopAction.CheckIfGooglePayAvailable.Success -> state.copy(isGooglePayAvailable = false)
is ShopAction.ResetState -> ShopState()
is ShopAction.SetOrderingDelayBlockVisibility -> state.copy(isOrderingDelayBlockVisible = action.visibility)
is ShopAction.BuyWithGooglePay,
is ShopAction.LoadProducts,
is ShopAction.StartWebCheckout,
is ShopAction.CheckIfGooglePayAvailable,
is ShopAction.BuyWithGooglePay.Failure,
is ShopAction.BuyWithGooglePay.HandleGooglePayResponse,
is ShopAction.BuyWithGooglePay.Success,
is ShopAction.BuyWithGooglePay.UserCancelled,
is ShopAction.FinishSuccessfulOrder,
is ShopAction.LoadProducts.Failure,
-> state
}
}

View file

@ -10,6 +10,7 @@ data class ShopState(
val promoCode: String? = null,
val promoCodeLoading: Boolean = false,
val isGooglePayAvailable: Boolean = false, // TODO: change when we add support for GPay
val isOrderingDelayBlockVisible: Boolean = false,
) : StateType {
val total: String?
get() = availableProducts.firstOrNull { it.type == selectedProduct }?.totalSum?.finalValue

View file

@ -9,28 +9,35 @@ import android.view.View.OnFocusChangeListener
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import androidx.activity.OnBackPressedCallback
import androidx.fragment.app.viewModels
import by.kirich1409.viewbindingdelegate.viewBinding
import com.tangem.tap.common.GlobalLayoutStateHandler
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.redux.navigation.NavigationAction
import com.tangem.tap.common.shop.data.ProductType
import com.tangem.tap.features.BaseStoreFragment
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.store
import com.tangem.wallet.R
import com.tangem.wallet.databinding.FragmentShopBinding
import dagger.hilt.android.AndroidEntryPoint
import org.rekotlin.StoreSubscriber
class ShopFragment : BaseStoreFragment(R.layout.fragment_shop), StoreSubscriber<ShopState> {
@AndroidEntryPoint
internal class ShopFragment : BaseStoreFragment(R.layout.fragment_shop), StoreSubscriber<ShopState> {
private val binding: FragmentShopBinding by viewBinding(FragmentShopBinding::bind)
private var cardTranslationY = 70f
private lateinit var keyboardObserver: KeyboardObserver
private val viewModel by viewModels<ShopViewModel>()
override fun subscribeToStore() {
store.subscribe(this) { state ->
state.skipRepeats { oldState, newState ->
@ -43,6 +50,8 @@ class ShopFragment : BaseStoreFragment(R.layout.fragment_shop), StoreSubscriber<
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel.checkOrderingDelayBlockVisibility()
activity?.onBackPressedDispatcher?.addCallback(
this,
object : OnBackPressedCallback(true) {
@ -133,6 +142,7 @@ class ShopFragment : BaseStoreFragment(R.layout.fragment_shop), StoreSubscriber<
animateProductSelection(state.selectedProduct)
handlePriceState(state)
handlePromoCodeState(state)
handleOrderingDelayBlock(isVisible = state.isOrderingDelayBlockVisible)
handleButtonsState(state)
}
@ -173,6 +183,10 @@ class ShopFragment : BaseStoreFragment(R.layout.fragment_shop), StoreSubscriber<
pbPromoCode.show(state.promoCodeLoading)
}
private fun handleOrderingDelayBlock(isVisible: Boolean) {
if (isVisible) binding.tvSoldOutDesc.show() else binding.tvSoldOutDesc.hide()
}
private fun handleButtonsState(state: ShopState) = with(binding) {
btnPayGooglePay.root.show(state.isGooglePayAvailable)
btnAlternativePayment.show(state.isGooglePayAvailable)

View file

@ -271,7 +271,7 @@
android:layout_marginTop="14dp"
android:background="@drawable/shape_rectangle_rounded_4"
android:padding="16dp"
android:text="@string/shop_sold_out_description_prefix"
android:text="@string/shop_sold_out_description"
android:textColor="@color/text_tertiary"
android:textSize="16sp" />
</LinearLayout>

View file

@ -51,4 +51,7 @@ interface TangemTechApi {
@Header("card_id") cardId: String,
@Body startReferralBody: StartReferralBody,
): ReferralResponse
@GET("shops")
suspend fun getShopInfo(@Query(value = "name") name: String): ShopResponse
}

View file

@ -0,0 +1,14 @@
package com.tangem.datasource.api.tangemTech.models
import com.squareup.moshi.Json
/**
* Shop response
*
* @property isOrderingAvailable ordering availability
*
[REDACTED_AUTHOR]
*/
data class ShopResponse(
@Json(name = "canOrder") val isOrderingAvailable: Boolean,
)

View file

@ -336,7 +336,7 @@
<string name="shop_i_have_a_promo_code">У меня есть промо-код…</string>
<string name="shop_one_wallet">Tangem Wallet</string>
<string name="shop_other_payment_methods">Другие способы оплаты</string>
<string name="shop_sold_out_description_prefix">Из-за большого количества заказов, которые мы получаем, cроки доставки могут быть увеличены.</string>
<string name="shop_sold_out_description">Из-за высокого количества заказов, которые мы получаем, доставка может быть задержана на срок до 5 недель в зависимости от вашего местоположения</string>
<string name="shop_total">Итого</string>
<string name="solana_rent_warning">Сеть Solana взимает арендную плату в размере %1$s каждые 2 дня. Аккаунты, которые не могут позволить себе арендную плату, удаляются из сети. Пополните свой счет более чем на %2$s, чтобы не платить арендную плату.</string>
<string name="story_awe_description">Держите свои криптосбережения в безопасности. Приватные ключи надежно хранятся на карте.</string>

View file

@ -332,7 +332,7 @@
<string name="shop_i_have_a_promo_code">I have a promo code…</string>
<string name="shop_one_wallet">Tangem Wallet</string>
<string name="shop_other_payment_methods">Other payment methods</string>
<string name="shop_sold_out_description_prefix">Due to the high volume of orders we are receiving, Shipping and Local Delivery orders may be delayed.</string>
<string name="shop_sold_out_description">Due to the high volume of orders we are receiving shipping may be delayed up to 5 weeks depending on your location</string>
<string name="shop_total">Total</string>
<string name="solana_rent_warning">Solana network charges a rent of %1$s every 2 days. Accounts that can\'t afford the rent are purged from the network. Deposit your account with more than %2$s to use it for free.</string>
<string name="story_awe_description">Store your crypto assets secure while keeping private keys contained in your card</string>