Updated on 2026-08-14

This commit is contained in:
Tangem 2022-02-23 10:35:32 +03:00
commit f5837320c6
71 changed files with 2537 additions and 17 deletions

View file

@ -18,8 +18,12 @@ import com.tangem.tap.common.redux.global.AndroidResources
import com.tangem.tap.common.redux.global.GlobalAction
import com.tangem.tap.common.redux.navigation.AppScreen
import com.tangem.tap.common.redux.navigation.NavigationAction
import com.tangem.tap.common.shop.GooglePayService
import com.tangem.tap.common.shop.GooglePayService.Companion.LOAD_PAYMENT_DATA_REQUEST_CODE
import com.tangem.tap.common.shop.googlepay.GooglePayUtil.createPaymentsClient
import com.tangem.tap.domain.TangemSdkManager
import com.tangem.tap.features.details.redux.walletconnect.WalletConnectAction
import com.tangem.tap.features.shop.redux.ShopAction
import com.tangem.wallet.R
import kotlinx.android.synthetic.main.activity_main.*
import kotlinx.coroutines.CoroutineExceptionHandler
@ -73,8 +77,14 @@ class MainActivity : AppCompatActivity(), SnackbarHandler {
store.dispatch(GlobalAction.SetResources(getAndroidResources()))
store.dispatch(WalletConnectAction.RestoreSessions)
store.dispatch(
ShopAction.CheckIfGooglePayAvailable(
GooglePayService(createPaymentsClient(this), this)
)
)
}
private fun getAndroidResources(): AndroidResources {
return AndroidResources(
AndroidResources.RString(
@ -97,7 +107,8 @@ class MainActivity : AppCompatActivity(), SnackbarHandler {
val backStackIsEmpty = supportFragmentManager.backStackEntryCount == 0
val isScannedBefore = store.state.globalState.scanResponse != null
val isOnboardingServiceActive = store.state.globalState.onboardingState.onboardingStarted
if (backStackIsEmpty || (!isOnboardingServiceActive && !isScannedBefore)) {
val shopOpened = store.state.shopState.total != null
if (backStackIsEmpty || (!isOnboardingServiceActive && !isScannedBefore && !shopOpened)) {
store.dispatch(NavigationAction.NavigateTo(AppScreen.Home))
}
intentHandler.handleIntent(intent)
@ -140,4 +151,15 @@ class MainActivity : AppCompatActivity(), SnackbarHandler {
snackbar?.dismiss()
snackbar = null
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
when (requestCode) {
LOAD_PAYMENT_DATA_REQUEST_CODE -> {
store.dispatch(
ShopAction.BuyWithGooglePay.HandleGooglePayResponse(resultCode, data)
)
}
}
}
}

View file

@ -12,6 +12,7 @@ import com.tangem.tap.common.images.PicassoHelper
import com.tangem.tap.common.redux.AppState
import com.tangem.tap.common.redux.appReducer
import com.tangem.tap.common.redux.global.GlobalAction
import com.tangem.tap.common.shop.TangemShopService
import com.tangem.tap.domain.configurable.config.ConfigManager
import com.tangem.tap.domain.configurable.config.FeaturesLocalLoader
import com.tangem.tap.domain.configurable.config.FeaturesRemoteLoader
@ -37,6 +38,7 @@ val store = Store(
lateinit var preferencesStorage: PreferencesStorage
lateinit var currenciesRepository: CurrenciesRepository
lateinit var walletConnectRepository: WalletConnectRepository
lateinit var shopService: TangemShopService
class TapApplication : Application() {
override fun onCreate() {
@ -72,7 +74,10 @@ class TapApplication : Application() {
val localLoader = FeaturesLocalLoader(this, moshi)
val remoteLoader = FeaturesRemoteLoader(moshi)
val configManager = ConfigManager(localLoader, remoteLoader)
configManager.load { store.dispatch(GlobalAction.SetConfigManager(configManager)) }
configManager.load {
store.dispatch(GlobalAction.SetConfigManager(configManager))
shopService = TangemShopService(this, configManager.config.shopify!!)
}
val warningsManager = WarningMessagesManager(RemoteWarningLoader(moshi))
warningsManager.load { store.dispatch(GlobalAction.SetWarningManager(warningsManager)) }
}

View file

@ -19,6 +19,7 @@ import com.tangem.tap.features.onboarding.products.otherCards.OnboardingOtherCar
import com.tangem.tap.features.onboarding.products.twins.ui.TwinsCardsFragment
import com.tangem.tap.features.onboarding.products.wallet.ui.OnboardingWalletFragment
import com.tangem.tap.features.send.ui.SendFragment
import com.tangem.tap.features.shop.ui.ShopFragment
import com.tangem.tap.features.tokens.ui.AddTokensFragment
import com.tangem.tap.features.wallet.ui.WalletDetailsFragment
import com.tangem.tap.features.wallet.ui.WalletFragment
@ -69,6 +70,7 @@ fun FragmentActivity.addOnBackPressedDispatcher(
private fun fragmentFactory(screen: AppScreen): Fragment {
return when (screen) {
AppScreen.Home -> HomeFragment()
AppScreen.Shop -> ShopFragment()
AppScreen.OnboardingNote -> OnboardingNoteFragment()
AppScreen.OnboardingWallet -> OnboardingWalletFragment()
AppScreen.OnboardingTwins -> TwinsCardsFragment()

View file

@ -11,6 +11,7 @@ import com.tangem.tap.features.onboarding.products.otherCards.redux.OnboardingOt
import com.tangem.tap.features.onboarding.products.twins.redux.TwinCardsReducer
import com.tangem.tap.features.onboarding.products.wallet.redux.OnboardingWalletReducer
import com.tangem.tap.features.send.redux.reducers.SendScreenReducer
import com.tangem.tap.features.shop.redux.ShopReducer
import com.tangem.tap.features.tokens.redux.TokensReducer
import com.tangem.tap.features.wallet.redux.reducers.WalletReducer
import org.rekotlin.Action
@ -32,7 +33,8 @@ fun appReducer(action: Action, state: AppState?): AppState {
detailsState = DetailsReducer.reduce(action, state),
disclaimerState = DisclaimerReducer.reduce(action, state),
tokensState = TokensReducer.reduce(action, state),
walletConnectState = WalletConnectReducer.reduce(action, state.walletConnectState)
walletConnectState = WalletConnectReducer.reduce(action, state.walletConnectState),
shopState = ShopReducer.reduce(action, state.shopState),
)
}

View file

@ -23,6 +23,8 @@ import com.tangem.tap.features.onboarding.products.wallet.redux.OnboardingWallet
import com.tangem.tap.features.onboarding.products.wallet.redux.OnboardingWalletState
import com.tangem.tap.features.send.redux.middlewares.SendMiddleware
import com.tangem.tap.features.send.redux.states.SendState
import com.tangem.tap.features.shop.redux.ShopMiddleware
import com.tangem.tap.features.shop.redux.ShopState
import com.tangem.tap.features.tokens.redux.TokensMiddleware
import com.tangem.tap.features.tokens.redux.TokensState
import com.tangem.tap.features.wallet.redux.WalletState
@ -44,6 +46,7 @@ data class AppState(
val disclaimerState: DisclaimerState = DisclaimerState(),
val tokensState: TokensState = TokensState(),
val walletConnectState: WalletConnectState = WalletConnectState(),
val shopState: ShopState = ShopState(),
) : StateType {
companion object {
@ -62,7 +65,8 @@ data class AppState(
DisclaimerMiddleware().disclaimerMiddleware,
TokensMiddleware().tokensMiddleware,
WalletConnectMiddleware().walletConnectMiddleware,
BackupMiddleware().backupMiddleware
BackupMiddleware().backupMiddleware,
ShopMiddleware().shopMiddleware,
)
}
}

View file

@ -11,6 +11,7 @@ data class NavigationState(
enum class AppScreen {
Home,
Shop,
Disclaimer,
OnboardingNote, OnboardingWallet, OnboardingTwins, OnboardingOther,
Wallet, WalletDetails,

View file

@ -0,0 +1,216 @@
package com.tangem.tap.common.shop
import android.app.Application
import android.content.Intent
import com.google.android.gms.wallet.PaymentData
import com.shopify.buy3.Storefront
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.shopify.ShopifyShop
import com.tangem.tap.common.shop.shopify.data.CheckoutItem
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import java.math.BigDecimal
import java.util.*
class TangemShopService(application: Application, shopifyShop: ShopifyShop) {
private val shopifyService = ShopifyService(application, shopifyShop)
private lateinit var product: Storefront.Product
private val checkouts = mutableMapOf<ProductType, Storefront.Checkout>()
private val variants = mutableMapOf<ProductType, Storefront.ProductVariant>()
private lateinit var googlePayService: GooglePayService
suspend fun getProducts(): Result<List<TangemProduct>> {
val result = shopifyService.getProducts()
result.onSuccess {
product = it.first {
val variantsSku = it.variants.edges.map { it.node.sku }
variantsSku.contains(ProductType.WALLET_2_CARDS.sku) && variantsSku.contains(
ProductType.WALLET_3_CARDS.sku
)
}
product.variants.edges.map { it.node }
.forEach { variant ->
if (variant.sku == ProductType.WALLET_2_CARDS.sku) {
variants[ProductType.WALLET_2_CARDS] = variant
} else if (variant.sku == ProductType.WALLET_3_CARDS.sku) {
variants[ProductType.WALLET_3_CARDS] = variant
}
}
val twoCardsProduct = TangemProduct(
type = ProductType.WALLET_2_CARDS,
totalSum = TotalSum(
finalValue = variants[ProductType.WALLET_2_CARDS]?.priceV2?.format(),
beforeDiscount = variants[ProductType.WALLET_2_CARDS]?.compareAtPriceV2?.format()
)
)
val threeCardsProduct = TangemProduct(
type = ProductType.WALLET_3_CARDS,
totalSum = TotalSum(
finalValue = variants[ProductType.WALLET_3_CARDS]?.priceV2?.format(),
beforeDiscount = variants[ProductType.WALLET_3_CARDS]?.compareAtPriceV2?.format()
)
)
createCheckouts()
return Result.success(listOf(twoCardsProduct, threeCardsProduct))
}
return Result.failure(result.exceptionOrNull()!!)
}
private suspend fun createCheckouts() {
variants.keys.map { coroutineScope { async { createCheckout(it) } } }.awaitAll()
}
private suspend fun createCheckout(productType: ProductType) {
val checkoutItem = CheckoutItem(variants[productType]!!.id, 1)
val result = shopifyService.createCheckout(listOf(checkoutItem))
result.onSuccess { checkout ->
checkouts[productType] = checkout
}
}
suspend fun checkIfGooglePayAvailable(googlePayService: GooglePayService): Result<Boolean> {
this.googlePayService = googlePayService
return googlePayService.checkIfGooglePayAvailable()
}
fun buyWithGooglePay(productType: ProductType) {
val totalPrice = checkouts[productType]!!.totalPriceV2.amount
googlePayService.payWithGooglePay(
totalPriceCents = totalPrice, currencyCode = checkouts[productType]!!.currencyCode.name,
merchantID = shopifyService.shop.merchantID
)
}
// fun subscribeToGooglePayResult(
// productType: ProductType,
// resultCallback: (Result<PaymentData>) -> Unit
// ) {
// googlePayService.responseCallback = { result ->
// result.onFailure { }
// result.onSuccess {
// completeTokenizedPayment(it, productType)
// }
// }
// }
suspend fun handleGooglePayResult(
resultCode: Int,
data: Intent?,
productType: ProductType
): Result<Unit> {
val result = googlePayService.handleResponseFromGooglePay(resultCode, data)
result.onSuccess {
val finalizePaymentResult = completeTokenizedPayment(it, productType)
finalizePaymentResult.onSuccess {
return Result.success(Unit)
}
return Result.failure(finalizePaymentResult.exceptionOrNull()!!)
}
return Result.failure(result.exceptionOrNull()!!)
}
private suspend fun completeTokenizedPayment(
paymentData: PaymentData,
productType: ProductType
): Result<Storefront.Checkout> {
val checkout = checkouts[productType]!!
val googlePayResponse =
googlePayService.parsePaymentData(paymentData)
?: return Result.failure(Exception("cannot parse GPay result"))
val amount =
Storefront.MoneyInput(checkout.totalPriceV2.amount, checkout.totalPriceV2.currencyCode)
val idempotencyKey = UUID.randomUUID().toString()
val addressGPay = googlePayResponse.billingAddress
val address = Storefront.MailingAddressInput().apply {
lastName = addressGPay.name
address1 = addressGPay.address1
address2 = addressGPay.address2 + addressGPay.address3
province = addressGPay.administrativeArea
zip = addressGPay.postalCode
phone = addressGPay.phoneNumber
}
val payment = Storefront.TokenizedPaymentInputV3(
amount,
idempotencyKey,
address,
paymentData.toJson(),
Storefront.PaymentTokenType.GOOGLE_PAY
)
.setTest(true)
return shopifyService.completeWithTokenizedPayment(
payment = payment,
checkoutID = checkout.id
)
}
suspend fun applyPromoCode(promoCode: String): Result<List<TangemProduct>> {
val products = variants.keys
.map { coroutineScope { async { applyPromoCode(promoCode, it) } } }
.awaitAll()
.map { result -> result.getOrElse { return Result.failure(it) } }
return Result.success(products)
}
suspend fun applyPromoCode(promoCode: String, productType: ProductType): Result<TangemProduct> {
val checkout = checkouts[productType] ?: return Result.failure(Exception("No checkout"))
val result = if (promoCode.isBlank()) {
shopifyService.removeDiscount(checkout.id)
} else {
shopifyService.applyDiscount(promoCode, checkout.id)
}
result.onSuccess {
checkouts[productType] = it
return Result.success(
TangemProduct(
productType,
TotalSum(
finalValue = it.totalPriceV2.format(),
beforeDiscount = variants[productType]!!.compareAtPriceV2.format()
),
appliedDiscount = it.getAppliedDiscount()
)
)
}
return Result.failure(result.exceptionOrNull()!!)
}
fun getCheckoutUrl(productType: ProductType): String {
return checkouts[productType]!!.webUrl
}
companion object {
const val TANGEM_WALLET_2_CARDS_SKU = "TG115x2"
const val TANGEM_WALLET_3_CARDS_SKU = "TG115x3"
}
}
private fun Storefront.MoneyV2.format(): String {
val currencySymbol = Currency.getInstance(currencyCode.name).symbol
val amountFormatted = BigDecimal(amount).setScale(2)
return currencySymbol + amountFormatted
}
private fun Storefront.Checkout.getAppliedDiscount(): String? {
val discountApplication =
discountApplications.edges.firstOrNull()?.node as? Storefront.DiscountCodeApplication
return discountApplication?.code
}

View file

@ -0,0 +1,8 @@
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)
}

View file

@ -0,0 +1,7 @@
package com.tangem.tap.common.shop.data
data class TangemProduct(
val type: ProductType,
val totalSum: TotalSum? = null,
val appliedDiscount: String? = null
)

View file

@ -0,0 +1,6 @@
package com.tangem.tap.common.shop.data
data class TotalSum(
val finalValue: String? = null,
val beforeDiscount: String? = null,
)

View file

@ -0,0 +1,148 @@
package com.tangem.tap.common.shop
import android.app.Activity
import android.app.Activity.RESULT_CANCELED
import android.app.Activity.RESULT_OK
import android.content.Intent
import android.util.Log
import com.google.android.gms.common.api.ApiException
import com.google.android.gms.wallet.*
import com.tangem.common.core.TangemSdkError
import com.tangem.tap.common.shop.googlepay.GooglePayUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONException
import org.json.JSONObject
import timber.log.Timber
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
class GooglePayService(private val paymentsClient: PaymentsClient, private val activity: Activity) {
// var responseCallback: ((Result<PaymentData>) -> Unit)? = null
suspend fun checkIfGooglePayAvailable(): Result<Boolean> {
val isReadyToPayJson = GooglePayUtil.isReadyToPayRequest() ?: return Result.success(false)
val request = IsReadyToPayRequest.fromJson(isReadyToPayJson.toString())
val task = paymentsClient.isReadyToPay(request)
return withContext(Dispatchers.IO) {
suspendCoroutine { continuation ->
task.addOnCompleteListener { completedTask ->
try {
val result = completedTask.getResult(ApiException::class.java)
continuation.resume(Result.success(true))
} catch (exception: ApiException) {
// Process error
Timber.w("isReadyToPay failed: $exception")
continuation.resume(Result.failure(exception))
}
}
}
}
}
fun payWithGooglePay(totalPriceCents: String, currencyCode: String, merchantID: String) {
val paymentDataRequestJson = GooglePayUtil.getPaymentDataRequest(
totalPriceCents,
currencyCode = currencyCode,
countryCode = "RU",
merchantID = merchantID
)
if (paymentDataRequestJson == null) {
Timber.e("RequestPayment: can't fetch payment data request")
return
}
val request = PaymentDataRequest.fromJson(paymentDataRequestJson.toString())
AutoResolveHelper.resolveTask(
paymentsClient.loadPaymentData(request), activity, LOAD_PAYMENT_DATA_REQUEST_CODE
)
}
fun handleResponseFromGooglePay(resultCode: Int, data: Intent?): Result<PaymentData> {
val result = when (resultCode) {
RESULT_OK -> {
val paymentData = data?.let { intent -> PaymentData.getFromIntent(intent) }
if (paymentData == null) {
Result.failure(Exception("No payment data"))
} else {
Result.success(paymentData)
}
}
RESULT_CANCELED -> {
Result.failure(TangemSdkError.UserCancelled())
}
AutoResolveHelper.RESULT_ERROR -> {
val statusCode = AutoResolveHelper.getStatusFromIntent(data)?.statusCode
if (statusCode == null) {
Result.failure(Exception("Unknown Status"))
} else {
Result.failure(Exception("$statusCode"))
}
}
else -> Result.failure(Exception("Unknown Status"))
}
// responseCallback?.invoke(result)
return result
}
fun parsePaymentData(paymentData: PaymentData): GooglePayResponse? {
val paymentInformation = paymentData.toJson()
try {
// Token will be null if PaymentDataRequest was not constructed using fromJson(String).
val paymentMethodData =
JSONObject(paymentInformation).getJSONObject("paymentMethodData")
val addressJson = paymentMethodData.getJSONObject("info")
.getJSONObject("billingAddress")
val address = Address(
name = addressJson.getString("name"),
postalCode = addressJson.getString("postalCode"),
countryCode = addressJson.getString("countryCode"),
phoneNumber = addressJson.getString("phoneNumber"),
address1 = addressJson.getString("address1"),
address2 = addressJson.getString("address2"),
address3 = addressJson.getString("address3"),
locality = addressJson.getString("locality"),
administrativeArea = addressJson.getString("administrativeArea"),
sortingCode = addressJson.getString("sortingCode"),
)
val token = paymentMethodData
.getJSONObject("tokenizationData")
.getString("token")
return GooglePayResponse(address, token)
} catch (e: JSONException) {
Log.e("handlePaymentSuccess", "Error: " + e.toString())
}
return null
}
companion object {
const val LOAD_PAYMENT_DATA_REQUEST_CODE = 315
}
}
data class GooglePayResponse(
val billingAddress: Address,
val token: String,
)
data class Address(
val name: String,
val postalCode: String,
val countryCode: String,
val phoneNumber: String,
val address1: String,
val address2: String,
val address3: String,
val locality: String,
val administrativeArea: String,
val sortingCode: String
)

View file

@ -0,0 +1,137 @@
package com.tangem.tap.common.shop.googlepay
import android.app.Activity
import com.google.android.gms.wallet.PaymentsClient
import com.google.android.gms.wallet.Wallet
import com.google.android.gms.wallet.WalletConstants
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
object GooglePayUtil {
private val baseRequest = JSONObject().apply {
put("apiVersion", 2)
put("apiVersionMinor", 0)
}
private fun gatewayTokenizationSpecification(merchantID: String): JSONObject {
return JSONObject().apply {
put("type", "PAYMENT_GATEWAY")
put(
"parameters", JSONObject(
mapOf(
"gateway" to "shopify",
"gatewayMerchantId" to merchantID
)
)
)
}
}
private val allowedCardNetworks = JSONArray(
listOf(
"AMEX",
"DISCOVER",
"INTERAC",
"JCB",
"MASTERCARD",
"VISA"
)
)
private val allowedCardAuthMethods = JSONArray(
listOf(
"PAN_ONLY",
"CRYPTOGRAM_3DS"
)
)
private fun baseCardPaymentMethod(): JSONObject {
return JSONObject().apply {
val parameters = JSONObject().apply {
put("allowedAuthMethods", allowedCardAuthMethods)
put("allowedCardNetworks", allowedCardNetworks)
put("billingAddressRequired", true)
put("billingAddressParameters", JSONObject().apply {
put("format", "FULL")
})
}
put("type", "CARD")
put("parameters", parameters)
}
}
private fun cardPaymentMethod(merchantID: String): JSONObject {
val cardPaymentMethod = baseCardPaymentMethod()
cardPaymentMethod.put("tokenizationSpecification", gatewayTokenizationSpecification(merchantID))
return cardPaymentMethod
}
fun createPaymentsClient(activity: Activity): PaymentsClient {
val walletOptions = Wallet.WalletOptions.Builder()
.setEnvironment(PAYMENTS_ENVIRONMENT)
.build()
return Wallet.getPaymentsClient(activity, walletOptions)
}
fun isReadyToPayRequest(): JSONObject? {
return try {
baseRequest.apply {
put("allowedPaymentMethods", JSONArray().put(baseCardPaymentMethod()))
}
} catch (e: JSONException) {
null
}
}
private fun getTransactionInfo(
price: String,
countryCode: String,
currencyCode: String
): JSONObject {
return JSONObject().apply {
put("totalPrice", price)
put("totalPriceStatus", "FINAL")
put("countryCode", countryCode)
put("currencyCode", currencyCode)
}
}
private val merchantInfo: JSONObject =
JSONObject().put("merchantName", "Example Merchant")
fun getPaymentDataRequest(
price: String,
countryCode: String,
currencyCode: String,
merchantID: String
): JSONObject? {
try {
return baseRequest.apply {
put("allowedPaymentMethods", JSONArray().put(cardPaymentMethod(merchantID)))
put("transactionInfo", getTransactionInfo(price, countryCode, currencyCode))
put("merchantInfo", merchantInfo)
// An optional shipping address requirement is a top-level property of the
// PaymentDataRequest JSON object.
val shippingAddressParameters = JSONObject().apply {
put("phoneNumberRequired", false)
// put("allowedCountryCodes", JSONArray(listOf("US", "GB")))
}
put("shippingAddressParameters", shippingAddressParameters)
put("shippingAddressRequired", true)
}
} catch (e: JSONException) {
return null
}
}
}
const val PAYMENTS_ENVIRONMENT = WalletConstants.ENVIRONMENT_TEST

View file

@ -0,0 +1,384 @@
package com.tangem.tap.common.shop
import android.app.Application
import com.shopify.buy3.GraphCallResult
import com.shopify.buy3.GraphClient
import com.shopify.buy3.RetryHandler
import com.shopify.buy3.Storefront.*
import com.shopify.graphql.support.ID
import com.shopify.graphql.support.Input
import com.tangem.tap.common.shop.shopify.ShopifyShop
import com.tangem.tap.common.shop.shopify.data.CheckoutItem
import com.tangem.tap.common.shop.shopify.data.checkoutFieldsFragment
import com.tangem.tap.common.shop.shopify.data.collectionFieldsFragment
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.util.concurrent.TimeUnit
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
class ShopifyService(private val application: Application, val shop: ShopifyShop) {
val client: GraphClient by lazy { initClient() }
suspend fun getShopName(): Result<String> {
val query = query { rootQuery: QueryRootQuery ->
rootQuery
.shop { shopQuery: ShopQuery ->
shopQuery
.name()
}
}
return when (val result = queryAsync(query)) {
is GraphCallResult.Success -> {
val name = result.response.data!!.shop.name
Result.success(name)
}
is GraphCallResult.Failure -> {
Result.failure(result.error)
}
}
}
suspend fun getProducts(collectionTitleFilter: String? = null): Result<List<Product>> {
val filter = collectionTitleFilter?.let { "title:\"$it\"" }
val query = query { rootQuery: QueryRootQuery ->
rootQuery
.collections(
{ arg -> arg.first(250).query(filter) },
) { collectionConnectionQuery ->
collectionConnectionQuery.collectionFieldsFragment()
}
}
return when (val result = queryAsync(query)) {
is GraphCallResult.Success -> {
val products = result.response.data!!.collections.edges
.map { it.node.products }
.flatMap { it.edges }
.map { it.node }
Result.success(products)
}
is GraphCallResult.Failure -> {
Result.failure(result.error)
}
}
}
suspend fun checkout(pollUntilOrder: Boolean, checkoutID: ID): Result<Checkout> {
val query = query { rootQuery: QueryRootQuery ->
rootQuery
.node(checkoutID) { query ->
query.onCheckout { checkoutQuery ->
with(checkoutQuery) {
checkoutFieldsFragment()
}
}
}
}
val retryHandler = RetryHandler.build<QueryRoot>(
1, TimeUnit.SECONDS
) {
this.retryWhen { result ->
when (result) {
is GraphCallResult.Success -> {
val checkout = result.response.data?.node as? Checkout
checkout == null
}
is GraphCallResult.Failure -> false
}
}
}
val result = if (pollUntilOrder) queryAsync(query, retryHandler) else queryAsync(query)
return when (result) {
is GraphCallResult.Success -> {
val checkout = result.response.data!!.node as? Checkout
if (checkout != null) {
Result.success(checkout)
} else {
Result.failure(ShopifyError.Unknown)
}
}
is GraphCallResult.Failure -> {
Result.failure(result.error)
}
}
}
suspend fun createCheckout(
checkoutItems: List<CheckoutItem>,
checkoutID: ID? = null
): Result<Checkout> {
val storefrontLineItems: MutableList<CheckoutLineItemInput> = checkoutItems
.map { CheckoutLineItemInput(it.quantity, it.id) }.toMutableList()
val query = if (checkoutID != null) {
mutation { mutationQuery: MutationQuery ->
mutationQuery
.checkoutLineItemsReplace(
storefrontLineItems, checkoutID
) { payloadQuery: CheckoutLineItemsReplacePayloadQuery ->
payloadQuery
.checkout { checkoutQuery: CheckoutQuery ->
checkoutQuery.checkoutFieldsFragment()
}
.userErrors() { userErrorQuery: CheckoutUserErrorQuery ->
userErrorQuery
.field()
.message()
}
}
}
} else {
val input = CheckoutCreateInput()
.setLineItemsInput(
Input.value(storefrontLineItems)
)
mutation { mutationQuery: MutationQuery ->
mutationQuery
.checkoutCreate(
input
) { payloadQuery: CheckoutCreatePayloadQuery ->
payloadQuery
.checkout { checkoutQuery: CheckoutQuery ->
checkoutQuery.checkoutFieldsFragment()
}
.checkoutUserErrors() { userErrorQuery: CheckoutUserErrorQuery ->
userErrorQuery
.field()
.message()
}
}
}
}
return runCheckoutMutation(query)
}
suspend fun applyDiscount(discountCode: String, checkoutID: ID): Result<Checkout> {
val query = mutation { mutationQuery: MutationQuery ->
mutationQuery
.checkoutDiscountCodeApplyV2(
discountCode, checkoutID
) { payloadQuery: CheckoutDiscountCodeApplyV2PayloadQuery ->
payloadQuery
.checkout { checkoutQuery: CheckoutQuery ->
checkoutQuery.checkoutFieldsFragment()
}
.checkoutUserErrors() { userErrorQuery: CheckoutUserErrorQuery ->
userErrorQuery
.field()
.message()
}
}
}
return runCheckoutMutation(query)
}
suspend fun removeDiscount(checkoutID: ID): Result<Checkout> {
val query = mutation { mutationQuery: MutationQuery ->
mutationQuery
.checkoutDiscountCodeRemove(
checkoutID
) { payloadQuery: CheckoutDiscountCodeRemovePayloadQuery ->
payloadQuery
.checkout { checkoutQuery: CheckoutQuery ->
checkoutQuery.checkoutFieldsFragment()
}
.checkoutUserErrors() { userErrorQuery: CheckoutUserErrorQuery ->
userErrorQuery
.field()
.message()
}
}
}
return runCheckoutMutation(query)
}
suspend fun updateAddress(
address: MailingAddress,
checkoutID: ID,
waitForShippingRates: Boolean
): Result<Checkout> {
val input = MailingAddressInput()
.setAddress1(address.address1)
.setAddress2(address.address2)
.setCity(address.city)
.setCountry(address.country)
.setFirstName(address.firstName)
.setLastName(address.lastName)
.setPhone(address.phone)
.setProvince(address.province)
.setZip(address.zip)
val query = mutation { mutationQuery: MutationQuery ->
mutationQuery
.checkoutShippingAddressUpdateV2(
input, checkoutID
) { shippingAddressUpdatePayloadQuery: CheckoutShippingAddressUpdateV2PayloadQuery ->
shippingAddressUpdatePayloadQuery
.checkout { checkoutQuery: CheckoutQuery ->
checkoutQuery.checkoutFieldsFragment()
}
.checkoutUserErrors { userErrorQuery: CheckoutUserErrorQuery ->
userErrorQuery
.field()
.message()
}
}
}
return runCheckoutMutation(query)
}
suspend fun updateEmail(email: String?, checkoutID: ID): Result<Checkout> {
val query = mutation { mutationQuery: MutationQuery ->
mutationQuery
.checkoutEmailUpdateV2(
checkoutID, email
) { emailUpdatePayloadQuery: CheckoutEmailUpdateV2PayloadQuery ->
emailUpdatePayloadQuery
.checkout { checkoutQuery: CheckoutQuery ->
checkoutQuery.checkoutFieldsFragment()
}
.checkoutUserErrors() { userErrorQuery: CheckoutUserErrorQuery ->
userErrorQuery
.field()
.message()
}
}
}
return runCheckoutMutation(query)
}
suspend fun updateShippingRate(handle: String?, checkoutID: ID): Result<Checkout> {
val query = mutation { mutationQuery: MutationQuery ->
mutationQuery
.checkoutShippingLineUpdate(
checkoutID, handle
) { shippingLineUpdatePayloadQuery: CheckoutShippingLineUpdatePayloadQuery ->
shippingLineUpdatePayloadQuery
.checkout { checkoutQuery: CheckoutQuery ->
checkoutQuery.checkoutFieldsFragment()
}
.checkoutUserErrors() { userErrorQuery: CheckoutUserErrorQuery ->
userErrorQuery
.field()
.message()
}
}
}
return runCheckoutMutation(query)
}
suspend fun completeWithTokenizedPayment(
payment: TokenizedPaymentInputV3,
checkoutID: ID
): Result<Checkout> {
val query = mutation { mutationQuery: MutationQuery ->
mutationQuery
.checkoutCompleteWithTokenizedPaymentV3(
checkoutID, payment
) { payloadQuery: CheckoutCompleteWithTokenizedPaymentV3PayloadQuery ->
payloadQuery
.payment { paymentQuery: PaymentQuery ->
paymentQuery
.ready()
.errorMessage()
}
.checkout { checkoutQuery: CheckoutQuery ->
checkoutQuery
.ready()
}
.checkoutUserErrors() { userErrorQuery: CheckoutUserErrorQuery ->
userErrorQuery
.field()
.message()
}
}
}
return runCheckoutMutation(query)
}
fun startGooglePaySession() {
// PaySession()
}
private suspend fun runCheckoutMutation(mutation: MutationQuery): Result<Checkout> {
return when (val result = mutationQueryAsync(mutation)) {
is GraphCallResult.Success -> {
val checkout = result.response.data!!.checkoutCreate?.checkout
?: result.response.data!!.checkoutDiscountCodeApplyV2?.checkout
?: result.response.data!!.checkoutDiscountCodeRemove?.checkout
?: result.response.data!!.checkoutShippingAddressUpdateV2?.checkout
?: result.response.data!!.checkoutEmailUpdateV2?.checkout
?: result.response.data!!.checkoutShippingLineUpdate?.checkout
?: result.response.data!!.checkoutCompleteWithTokenizedPaymentV3.checkout
Result.success(checkout)
}
is GraphCallResult.Failure -> Result.failure(result.error)
}
}
private suspend fun queryAsync(
query: QueryRootQuery,
retryHandler: RetryHandler<QueryRoot>
): GraphCallResult<QueryRoot> =
withContext(Dispatchers.IO) {
suspendCoroutine { continuation ->
client.queryGraph(query).enqueue(retryHandler = retryHandler) { result ->
continuation.resume(result)
}
}
}
private suspend fun queryAsync(
query: QueryRootQuery,
): GraphCallResult<QueryRoot> =
withContext(Dispatchers.IO) {
suspendCoroutine { continuation ->
client.queryGraph(query).enqueue { result ->
continuation.resume(result)
}
}
}
private suspend fun mutationQueryAsync(query: MutationQuery): GraphCallResult<Mutation> =
withContext(Dispatchers.IO) {
suspendCoroutine { continuation ->
client.mutateGraph(query).enqueue { result ->
continuation.resume(result)
}
}
}
private fun initClient(): GraphClient {
return GraphClient.build(
application,
shop.domain,
shop.storefrontApiKey
) {
// httpCache(application.filesDir) {
// cacheMaxSizeBytes = (1024 * 1024 * 10)
// defaultCachePolicy =
// HttpCachePolicy.Default.CACHE_FIRST.expireAfter(20, TimeUnit.MINUTES)
// }
}
}
}
sealed class ShopifyError : Throwable() {
object Unknown : ShopifyError()
object GooglePayFailed : ShopifyError()
class UserError(val errorMessage: String)
}

View file

@ -0,0 +1,10 @@
package com.tangem.tap.common.shop.shopify
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class ShopifyShop(
val domain: String,
val storefrontApiKey: String,
val merchantID: String,
)

View file

@ -0,0 +1,134 @@
package com.tangem.tap.common.shop.shopify.data
import com.shopify.buy3.Storefront
fun Storefront.CheckoutQuery.checkoutFieldsFragment() {
// id()
ready()
webUrl()
currencyCode()
lineItemsSubtotalPrice { it.amount() }
totalPriceV2 {
it.currencyCode()
it.amount() }
lineItems({ arg -> arg.first(250) }) {
it.edges {
it.node {
// it.id()
it.title()
it.quantity()
it.variant() {
it.priceV2() { it.amount() }
}
}
}
}
shippingLine {
it.handle()
it.title()
it.priceV2 { it.amount() }
}
availableShippingRates {
it.ready()
it.shippingRates {
it.handle()
it.title()
it.priceV2 { it.amount() }
}
}
shippingAddress {
it.address1()
it.address2()
it.city()
// .company()
it.country()
// .countryCodeV2()
it.firstName()
// .formatted()
// .formattedArea()
// .id()
it.lastName()
// .latitude()
// .longitude()
// .name()
it.phone()
it.province()
// .provinceCode()
it.zip()
}
discountApplications({ arg -> arg.first(250) }) {
it.edges {
it.node {
it.onDiscountCodeApplication {
it.code()
// .applicable()
// .allocationMethod()
// .targetSelection()
// .targetType()
it.value {
it.onMoneyV2 {
it.amount()
}
it.onPricingPercentageValue {
it.percentage()
}
}
}
}
}
}
order {
it.cancelReason()
it.canceledAt()
it.currencyCode()
// .currentSubtotalPrice()
// .currentTotalDuties()
// .currentTotalPrice()
// .currentTotalTax()
it.customerLocale()
it.customerUrl()
// .discountApplications()
it.edited()
it.email()
it.financialStatus()
it.fulfillmentStatus()
// .id()
// .lineItems()
// .metafield()
// .metafields()
it.name()
it.orderNumber()
// .originalTotalDuties()
// .originalTotalPrice()
it.phone()
it.processedAt()
it.shippingAddress {
it.address1()
it.address2()
it.city()
it.company()
it.country()
it.countryCodeV2()
it.firstName()
it.formatted()
it.formattedArea()
// .id()
it.lastName()
it.latitude()
it.longitude()
it.name()
it.phone()
it.province()
it.provinceCode()
it.zip()
}
// .shippingDiscountAllocations()
it.statusUrl()
// .subtotalPriceV2()
// .successfulFulfillments()
// .totalPriceV2()
// .totalRefundedV2()
// .totalShippingPriceV2()
// .totalTaxV2()
}
}

View file

@ -0,0 +1,8 @@
package com.tangem.tap.common.shop.shopify.data
import com.shopify.graphql.support.ID
data class CheckoutItem(
val id: ID,
val quantity: Int
)

View file

@ -0,0 +1,50 @@
package com.tangem.tap.common.shop.shopify.data
import com.shopify.buy3.Storefront
fun Storefront.CollectionConnectionQuery.collectionFieldsFragment() {
edges { collectionEdgeQuery ->
collectionEdgeQuery
.node { collectionQuery ->
collectionQuery
.title()
.products({ arg -> arg.first(250) }
) { productConnectionQuery ->
productConnectionQuery
.edges { productEdgeQuery ->
productEdgeQuery
.node { productQuery ->
productQuery.title()
.productType()
.description()
.variants({ arg -> arg.first(10) }) { variantConnectionQuery ->
variantConnectionQuery.edges { variantQuery ->
variantQuery.node {
it.title()
it.sku()
it.currentlyNotInStock()
it.priceV2 {
it.amount()
it.currencyCode()
}
it.compareAtPriceV2 {
it.amount()
it.currencyCode()
}
it.compareAtPriceV2 {
it.amount()
}
it.product {
it.title()
.productType()
.description()
}
}
}
}
}
}
}
}
}
}

View file

@ -2,6 +2,7 @@ package com.tangem.tap.domain.configurable.config
import com.tangem.blockchain.common.BlockchainSdkConfig
import com.tangem.common.extensions.VoidCallback
import com.tangem.tap.common.shop.shopify.ShopifyShop
import com.tangem.tap.domain.configurable.Loader
/**
@ -16,7 +17,8 @@ data class Config(
val isSendingToPayIdEnabled: Boolean = true,
val isTopUpEnabled: Boolean = false,
@Deprecated("Not relevant since version 3.23")
val isCreatingTwinCardsAllowed: Boolean = false
val isCreatingTwinCardsAllowed: Boolean = false,
val shopify: ShopifyShop? = null
)
class ConfigManager(
@ -52,10 +54,10 @@ class ConfigManager(
fun resetToDefault(name: String) {
when (name) {
isSendingToPayIdEnabled -> config =
config.copy(isSendingToPayIdEnabled = defaultConfig.isSendingToPayIdEnabled)
config.copy(isSendingToPayIdEnabled = defaultConfig.isSendingToPayIdEnabled)
isTopUpEnabled -> config = config.copy(isTopUpEnabled = defaultConfig.isTopUpEnabled)
isCreatingTwinCardsAllowed -> config =
config.copy(isCreatingTwinCardsAllowed = defaultConfig.isCreatingTwinCardsAllowed)
config.copy(isCreatingTwinCardsAllowed = defaultConfig.isCreatingTwinCardsAllowed)
}
}
@ -87,6 +89,7 @@ class ConfigManager(
infuraProjectId = values.infuraProjectId
),
appsFlyerDevKey = values.appsFlyerDevKey,
shopify = values.shopifyShop,
)
defaultConfig = defaultConfig.copy(
coinMarketCapKey = values.coinMarketCapKey,
@ -99,6 +102,7 @@ class ConfigManager(
infuraProjectId = values.infuraProjectId
),
appsFlyerDevKey = values.appsFlyerDevKey,
shopify = values.shopifyShop,
)
}

View file

@ -1,5 +1,7 @@
package com.tangem.tap.domain.configurable.config
import com.tangem.tap.common.shop.shopify.ShopifyShop
/**
[REDACTED_AUTHOR]
*/
@ -20,6 +22,7 @@ class ConfigValueModel(
val blockcypherTokens: Set<String>?,
val infuraProjectId: String?,
val appsFlyerDevKey: String,
val shopifyShop: ShopifyShop?
)
class ConfigModel(val features: FeatureModel?, val configValues: ConfigValueModel?) {

View file

@ -4,7 +4,6 @@ import com.tangem.tap.common.analytics.AnalyticsEvent
import com.tangem.tap.common.analytics.AnalyticsParam
import com.tangem.tap.common.analytics.GetCardSourceParams
import com.tangem.tap.common.entities.IndeterminateProgressButton
import com.tangem.tap.common.extensions.dispatchOpenUrl
import com.tangem.tap.common.extensions.onCardScanned
import com.tangem.tap.common.extensions.withMainContext
import com.tangem.tap.common.post
@ -50,7 +49,7 @@ private val homeMiddleware: Middleware<AppState> = { dispatch, state ->
}
is HomeAction.ReadCard -> handleReadCard()
is HomeAction.GoToShop -> {
store.dispatchOpenUrl(HomeMiddleware.CARD_SHOP_URI)
store.dispatch(NavigationAction.NavigateTo(AppScreen.Shop))
store.state.globalState.analyticsHandlers?.triggerEvent(
event = AnalyticsEvent.GET_CARD,
params = mapOf(AnalyticsParam.SOURCE.param to GetCardSourceParams.WELCOME.param)

View file

@ -32,7 +32,7 @@ class OnboardingWalletFragment : Fragment(R.layout.fragment_onboarding_wallet),
StoreSubscriber<OnboardingWalletState>, FragmentOnBackPressedHandler {
private var accessCodeDialog: AccessCodeDialog? = null
private lateinit var cardsWidget: BackupCardsWidget
private lateinit var cardsWidget: WalletCardsWidget
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -48,7 +48,7 @@ class OnboardingWalletFragment : Fragment(R.layout.fragment_onboarding_wallet),
super.onViewCreated(view, savedInstanceState)
val leapfrog = LeapfrogWidget(fl_cards_container)
cardsWidget = BackupCardsWidget(leapfrog) { 200f }
cardsWidget = WalletCardsWidget(leapfrog) { 200f }
startPostponedEnterTransition()
view_pager_backup_info.adapter = BackupInfoAdapter()

View file

@ -10,7 +10,7 @@ import com.tangem.tangem_sdk_new.ui.widget.leapfrogWidget.LeapView
import com.tangem.tangem_sdk_new.ui.widget.leapfrogWidget.LeapViewState
import com.tangem.tangem_sdk_new.ui.widget.leapfrogWidget.LeapfrogWidget
class BackupCardsWidget(
class WalletCardsWidget(
val leapfrogWidget: LeapfrogWidget,
val getTopOfAnchorViewForActivateState: () -> Float,
) {

View file

@ -0,0 +1,38 @@
package com.tangem.tap.features.shop.redux
import android.content.Intent
import com.tangem.tap.common.shop.GooglePayService
import com.tangem.tap.common.shop.data.ProductType
import com.tangem.tap.common.shop.data.TangemProduct
import org.rekotlin.Action
sealed class ShopAction : Action {
object LoadProducts : ShopAction() {
data class Success(val products: List<TangemProduct>) : 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()
data class Failure(val exception: Throwable) : ShopAction()
object Success : ShopAction()
}
object StartWebCheckout : ShopAction()
data class CheckIfGooglePayAvailable(val googlePayService: GooglePayService) : ShopAction() {
object Success : ShopAction()
object Failure : ShopAction()
}
data class SelectProduct(val productType: ProductType) : ShopAction()
object ResetState : ShopAction()
}

View file

@ -0,0 +1,116 @@
package com.tangem.tap.features.shop.redux
import com.tangem.tap.common.extensions.dispatchOnMain
import com.tangem.tap.common.extensions.dispatchOpenUrl
import com.tangem.tap.common.redux.AppState
import com.tangem.tap.common.redux.navigation.AppScreen
import com.tangem.tap.common.redux.navigation.NavigationAction
import com.tangem.tap.scope
import com.tangem.tap.shopService
import com.tangem.tap.store
import kotlinx.coroutines.launch
import org.rekotlin.Action
import org.rekotlin.Middleware
class ShopMiddleware {
val shopMiddleware: Middleware<AppState> = { dispatch, state ->
{ next ->
{ action ->
handle(action)
next(action)
}
}
}
}
private fun handle(action: Action) {
val shopState = store.state.shopState
if (action is NavigationAction.NavigateTo && action.screen == AppScreen.Shop) {
store.dispatch(ShopAction.LoadProducts)
}
if (action !is ShopAction) return
when (action) {
is ShopAction.ApplyPromoCode -> {
scope.launch {
if (action.promoCode.isBlank() && shopState.promoCode == null) {
store.dispatchOnMain(ShopAction.ApplyPromoCode.InvalidPromoCode)
return@launch
}
val result = shopService.applyPromoCode(action.promoCode)
result.onSuccess { products ->
store.dispatchOnMain(
ShopAction.ApplyPromoCode.Success(
promoCode = products.first { it.type == shopState.selectedProduct }.appliedDiscount,
products = products
)
)
}
result.onFailure { store.dispatchOnMain(ShopAction.ApplyPromoCode.InvalidPromoCode) }
}
}
ShopAction.BuyWithGooglePay -> {
shopService.buyWithGooglePay(shopState.selectedProduct)
// shopService.subscribeToGooglePayResult(productType = shopState.selectedProduct) { result ->
// result.onSuccess {
// store.dispatch(ShopAction.BuyWithGooglePay.Success)
// }
// result.onFailure { error ->
// if (error is TangemSdkError.UserCancelled) {
// store.dispatch(ShopAction.BuyWithGooglePay.UserCancelled)
// } else {
// store.dispatch(ShopAction.BuyWithGooglePay.Failure(error))
// }
// }
// }
}
is ShopAction.BuyWithGooglePay.HandleGooglePayResponse -> {
scope.launch {
val result = shopService.handleGooglePayResult(
action.resultCode,
action.data,
shopState.selectedProduct
)
result.onSuccess {
store.dispatchOnMain(ShopAction.BuyWithGooglePay.Success)
}
result.onFailure {
store.dispatchOnMain(ShopAction.BuyWithGooglePay.Failure(it))
}
}
}
ShopAction.LoadProducts -> {
scope.launch {
val result = shopService.getProducts()
result.onSuccess {
store.dispatchOnMain(ShopAction.LoadProducts.Success(it))
}
}
}
is ShopAction.CheckIfGooglePayAvailable -> {
scope.launch {
val isAvailable =
shopService.checkIfGooglePayAvailable(action.googlePayService).getOrNull()
?: false
val newAction = if (isAvailable) {
ShopAction.CheckIfGooglePayAvailable.Success
} else {
ShopAction.CheckIfGooglePayAvailable.Failure
}
store.dispatchOnMain(newAction)
}
}
ShopAction.StartWebCheckout -> {
store.dispatchOpenUrl(shopService.getCheckoutUrl(shopState.selectedProduct))
}
}
}

View file

@ -0,0 +1,68 @@
package com.tangem.tap.features.shop.redux
import org.rekotlin.Action
class ShopReducer {
companion object {
fun reduce(action: Action, state: ShopState): ShopState = internalReduce(action, state)
}
}
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.Success -> {
state.copy(
promoCode = action.promoCode,
availableProducts = action.products,
promoCodeLoading = false
)
}
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.ResetState -> ShopState()
}
}

View file

@ -0,0 +1,25 @@
package com.tangem.tap.features.shop.redux
import com.tangem.tap.common.shop.data.ProductType
import com.tangem.tap.common.shop.data.TangemProduct
import org.rekotlin.StateType
data class ShopState(
val availableProducts: List<TangemProduct> = emptyList(),
val selectedProduct: ProductType = ProductType.WALLET_3_CARDS,
val promoCode: String? = null,
val promoCodeLoading: Boolean = false,
val isGooglePayAvailable: Boolean = false, // TODO: change when we add support for GPay
) : StateType {
val total: String?
get() = availableProducts.firstOrNull { it.type == selectedProduct }?.totalSum?.finalValue
val priceBeforeDiscount: String?
get() {
val totalSum = availableProducts.firstOrNull { it.type == selectedProduct }?.totalSum
if (totalSum?.finalValue != totalSum?.beforeDiscount) {
return totalSum?.beforeDiscount
}
return null
}
}

View file

@ -0,0 +1,167 @@
package com.tangem.tap.features.shop.ui
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.content.Context
import android.os.Bundle
import android.view.View
import android.view.View.OnFocusChangeListener
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import androidx.activity.OnBackPressedCallback
import com.tangem.tap.common.KeyboardObserver
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.redux.ShopAction
import com.tangem.tap.features.shop.redux.ShopState
import com.tangem.tap.store
import com.tangem.wallet.R
import kotlinx.android.synthetic.main.fragment_shop.*
import org.rekotlin.StoreSubscriber
class ShopFragment : BaseStoreFragment(R.layout.fragment_shop), StoreSubscriber<ShopState> {
override fun subscribeToStore() {
store.subscribe(this) { state ->
state.skipRepeats { oldState, newState ->
oldState.shopState == newState.shopState
}.select { it.shopState }
}
storeSubscribersList.add(this)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
activity?.onBackPressedDispatcher?.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
store.dispatch(NavigationAction.PopBackTo())
store.dispatch(ShopAction.ResetState)
}
})
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupCardsImages()
setupProductSelection()
setupPromoCodeEditText()
toolbar.setNavigationOnClickListener {
requireActivity().onBackPressed()
}
val keyboardObserver = KeyboardObserver(requireActivity())
keyboardObserver.registerListener { isVisible ->
fl_cards.show(!isVisible)
}
}
private fun setupCardsImages() {
imv_second.animate()
.translationY(70f)
.scaleX(0.9f)
.scaleY(0.9f)
.start()
imv_third.animate()
.translationY(140f)
.scaleX(0.8f)
.scaleY(0.8f)
.start()
}
private fun setupProductSelection() {
chip_product_1.setOnCheckedChangeListener { _, isChecked ->
if (isChecked) store.dispatch(ShopAction.SelectProduct(ProductType.WALLET_3_CARDS))
}
chip_product_2.setOnCheckedChangeListener { _, isChecked ->
if (isChecked) store.dispatch(ShopAction.SelectProduct(ProductType.WALLET_2_CARDS))
}
}
private fun setupPromoCodeEditText() {
et_promo_code.setOnEditorActionListener { view, actionId, event ->
if (actionId == EditorInfo.IME_ACTION_DONE) {
val imm: InputMethodManager =
requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(view.windowToken, 0)
view.clearFocus()
return@setOnEditorActionListener true
}
return@setOnEditorActionListener false
}
et_promo_code.onFocusChangeListener = OnFocusChangeListener { _, hasFocus ->
if (!hasFocus) {
store.dispatch(ShopAction.ApplyPromoCode(et_promo_code.text.toString()))
}
}
}
override fun newState(state: ShopState) {
if (activity == null) return
animateProductSelection(state.selectedProduct)
handlePriceState(state)
handlePromoCodeState(state)
handleButtonsState(state)
}
private fun animateProductSelection(selectedProduct: ProductType) {
val show = when (selectedProduct) {
ProductType.WALLET_2_CARDS -> false
ProductType.WALLET_3_CARDS -> true
}
showOrHideThirdCardWithAnimation(show)
}
private fun showOrHideThirdCardWithAnimation(show: Boolean) {
val translationY = if (show) 140f else 0f
if (show) imv_third.show()
imv_third.animate()
.translationY(translationY)
.setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
super.onAnimationEnd(animation)
imv_third?.show(show)
}
})
}
private fun handlePriceState(state: ShopState) {
tv_total.text = state.total
tv_total_before_discount.text = state.priceBeforeDiscount
pb_price.show(state.total == null)
}
private fun handlePromoCodeState(state: ShopState) {
if (state.promoCode == null && !et_promo_code.hasFocus()) {
et_promo_code.setText("")
}
pb_promo_code.show(state.promoCodeLoading)
}
private fun handleButtonsState(state: ShopState) {
btn_pay_google_pay.show(state.isGooglePayAvailable)
btn_alternative_payment.show(state.isGooglePayAvailable)
btn_main_action.show(!state.isGooglePayAvailable)
if (state.total != null) {
btn_alternative_payment.setOnClickListener { store.dispatch(ShopAction.StartWebCheckout) }
btn_main_action.setOnClickListener { store.dispatch(ShopAction.StartWebCheckout) }
btn_pay_google_pay.setOnClickListener { store.dispatch(ShopAction.BuyWithGooglePay) }
}
}
override fun handleOnBackPressed() {
store.dispatch(ShopAction.ResetState)
super.handleOnBackPressed()
}
}