Updated on 2026-08-14

This commit is contained in:
Tangem 2022-10-25 16:38:52 +05:00
commit 707f04c6a5
232 changed files with 7456 additions and 1908 deletions

View file

@ -7,7 +7,10 @@ import android.view.View
import android.view.ViewGroup
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.widget.Toolbar
import androidx.core.view.MenuHost
import androidx.core.view.MenuProvider
import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle
import androidx.transition.TransitionInflater
import com.google.android.material.snackbar.Snackbar
import com.tangem.common.extensions.VoidCallback
@ -42,6 +45,17 @@ abstract class BaseFragment(layoutId: Int) : Fragment(layoutId), FragmentOnBackP
store.dispatch(NavigationAction.PopBackTo())
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
loadToolbarMenu()?.let {
val menuHost = requireActivity() as? MenuHost ?: return
menuHost.addMenuProvider(it, viewLifecycleOwner, Lifecycle.State.RESUMED)
}
}
protected open fun loadToolbarMenu():MenuProvider? = null
fun showRetrySnackbar(message: String, action: VoidCallback) {
val snackbar = Snackbar.make(mainView, message, Snackbar.LENGTH_INDEFINITE)
snackbar.setAction(getString(R.string.common_retry)) {

View file

@ -448,6 +448,33 @@ class DemoConfig {
"AB02000000049533",
"AB02000000049541",
"AB02000000049830",
// === more cids ===
"AC03000000091418",
"AC03000000091400",
"AC03000000099007",
"AC03000000098991",
"AC03000000098942",
"AC03000000091715",
"AC03000000091301",
"AC03000000091343",
"AB01000000055705",
"AB01000000052918",
"AB01000000047710",
"AB01000000052306",
"AB01000000047645",
"AB01000000048957",
"AB01000000052900",
"AB01000000050391",
"AB01000000047363",
"AB02000000053998",
"AB02000000019809",
"AB02000000020872",
"AB02000000022027",
"AB02000000058955",
"AB02000000053253",
"AB02000000048063",
"AB02000000023736",
"AB02000000058187",
)
private val testDemoCardIds = listOf(

View file

@ -2,7 +2,7 @@ package com.tangem.tap.features.details.redux
import com.tangem.common.CompletionResult
import com.tangem.common.core.TangemSdkError
import com.tangem.domain.common.isTangemTwins
import com.tangem.domain.common.TapWorkarounds.isTangemTwins
import com.tangem.tap.common.analytics.Analytics
import com.tangem.tap.common.analytics.AnalyticsParam
import com.tangem.tap.common.extensions.dispatchNotification
@ -69,13 +69,8 @@ class DetailsMiddleware {
}
is DetailsAction.CreateBackup -> {
store.state.detailsState.scanResponse?.let {
store.dispatch(GlobalAction.Onboarding.Start(it, canSkipBackup = false))
store.dispatch(NavigationAction.NavigateTo(AppScreen.OnboardingWallet))
store.dispatch(
GlobalAction.Onboarding.Start(
it,
fromHomeScreen = false,
),
)
}
}
DetailsAction.ScanCard -> {
@ -98,7 +93,7 @@ class DetailsMiddleware {
when (action) {
is DetailsAction.ResetToFactory.Start -> {
val card = store.state.detailsState.cardSettingsState?.card ?: return
if (card.isTangemTwins()) {
if (card.isTangemTwins) {
store.dispatch(DetailsAction.ReCreateTwinsWallet)
return
} else {

View file

@ -79,7 +79,7 @@ private fun prepareSecurityOptions(card: Card): ManageSecurityState {
}
}
val allowedSecurityOptions = when {
card.isStart2Coin || card.isTangemNote() -> {
card.isStart2Coin || card.isTangemNote || card.isSaltPay -> {
EnumSet.of(SecurityOption.LongTap)
}
card.settings.isBackupAllowed -> {
@ -97,7 +97,7 @@ private fun prepareSecurityOptions(card: Card): ManageSecurityState {
private fun isResetToFactoryAllowedByCard(card: Card): Boolean {
val notAllowedByAnyWallet = card.wallets.any { it.settings.isPermanent }
val notAllowedByCard = notAllowedByAnyWallet ||
(card.isWalletDataSupported && (!card.isTangemNote() && !card.settings.isBackupAllowed)) ||
(card.isWalletDataSupported && (!card.isTangemNote && !card.settings.isBackupAllowed)) ||
card.isSaltPay
return !notAllowedByCard
}

View file

@ -3,6 +3,7 @@ package com.tangem.tap.features.details.redux.walletconnect
import com.tangem.blockchain.common.Blockchain
import com.tangem.domain.common.ScanResponse
import com.tangem.tap.common.redux.NotificationAction
import com.tangem.tap.domain.TapError
import com.tangem.wallet.R
import com.trustwallet.walletconnect.models.binance.WCBinanceTradeOrder
import com.trustwallet.walletconnect.models.binance.WCBinanceTransferOrder
@ -52,7 +53,7 @@ sealed class WalletConnectAction : Action {
val updatedSession: WalletConnectSession,
) : WalletConnectAction()
data class FailureEstablishingSession(val session: WCSession?) : WalletConnectAction()
data class FailureEstablishingSession(val session: WCSession?, val error: TapError? = null) : WalletConnectAction()
data class SetSessionsRestored(val sessions: List<WalletConnectSession>) :
WalletConnectAction()
@ -103,6 +104,6 @@ sealed class WalletConnectAction : Action {
data class Sign(
val id: Long, val data: ByteArray, val sessionData: WCSession,
) : WalletConnectAction()
) : WalletConnectAction()
}
}

View file

@ -9,6 +9,7 @@ import com.tangem.domain.common.ScanResponse
import com.tangem.domain.common.TapWorkarounds.derivationStyle
import com.tangem.domain.common.extensions.withMainContext
import com.tangem.tap.common.extensions.dispatchOnMain
import com.tangem.tap.common.redux.AppDialog
import com.tangem.tap.common.redux.AppState
import com.tangem.tap.common.redux.global.GlobalAction
import com.tangem.tap.common.redux.navigation.AppScreen
@ -22,6 +23,7 @@ import com.tangem.tap.features.demo.DemoHelper
import com.tangem.tap.features.wallet.redux.WalletState
import com.tangem.tap.scope
import com.tangem.tap.store
import com.tangem.wallet.R
import kotlinx.coroutines.launch
import org.rekotlin.Action
import org.rekotlin.Middleware
@ -96,6 +98,16 @@ class WalletConnectMiddleware {
store.dispatchOnMain(GlobalAction.ShowDialog(WalletConnectDialog.SessionTimeout))
}
is WalletConnectAction.FailureEstablishingSession -> {
if (action.error != null) {
store.dispatch(
GlobalAction.ShowDialog(
AppDialog.SimpleOkDialogRes(
headerId = R.string.common_warning,
messageId = action.error.messageResource,
),
),
)
}
if (action.session != null) {
walletConnectManager.disconnect(action.session)
}
@ -306,9 +318,11 @@ class WalletConnectMiddleware {
} else {
blockchain
}
val derivation = blockchainToMake.derivationPath(store.state.globalState.scanResponse?.card?.derivationStyle)
?.rawPath
val blockchainNetwork = BlockchainNetwork(
blockchain = blockchainToMake,
derivationPath = wallet.derivationPath?.rawPath,
derivationPath = derivation,
tokens = emptyList(),
)
return walletState.getWalletManager(blockchainNetwork)

View file

@ -1,7 +1,7 @@
package com.tangem.tap.features.details.ui.cardsettings
import com.tangem.domain.common.TapWorkarounds.isTangemTwins
import com.tangem.domain.common.getTwinCardIdForUser
import com.tangem.domain.common.isTangemTwins
import com.tangem.tap.common.redux.AppState
import com.tangem.tap.features.details.redux.CardSettingsState
import com.tangem.tap.features.details.redux.DetailsAction
@ -20,7 +20,7 @@ class CardSettingsViewModel(private val store: Store<AppState>) {
},
)
} else {
val cardId = if (state.card.isTangemTwins()) {
val cardId = if (state.card.isTangemTwins) {
state.card.getTwinCardIdForUser()
} else {
state.cardInfo.cardId
@ -30,7 +30,7 @@ class CardSettingsViewModel(private val store: Store<AppState>) {
CardInfo.Issuer(state.cardInfo.issuer),
)
if (!state.card.isTangemTwins()) {
if (!state.card.isTangemTwins) {
cardDetails.add(CardInfo.SignedHashes(state.cardInfo.signedHashes.toString()))
}
cardDetails.add(

View file

@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@ -86,7 +87,7 @@ fun WalletConnectDetailsItem(
) {
Row(
modifier = modifier
.height(84.dp)
.defaultMinSize(minHeight = 84.dp)
.fillMaxWidth()
.clickable { onItemsClick(SettingsElement.WalletConnect) },
horizontalArrangement = Arrangement.Start,
@ -99,7 +100,7 @@ fun WalletConnectDetailsItem(
tint = colorResource(id = R.color.all_colors_azure),
)
Column(
modifier = modifier.height(56.dp),
modifier = modifier.defaultMinSize(minHeight = 56.dp),
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Center,
) {

View file

@ -1,5 +1,6 @@
package com.tangem.tap.features.details.ui.details
import com.tangem.domain.common.TapWorkarounds.isSaltPay
import com.tangem.domain.common.TapWorkarounds.isStart2Coin
import com.tangem.tap.common.feedback.FeedbackEmail
import com.tangem.tap.common.feedback.SupportInfo
@ -25,7 +26,9 @@ class DetailsViewModel(private val store: Store<AppState>) {
if (state.scanResponse?.card?.isMultiwalletAllowed == true) it else null
}
SettingsElement.LinkMoreCards -> {
if (state.createBackupAllowed) it else null
// if (state.createBackupAllowed) it else null
// TODO: SaltPay: temporary excluding backup process for Visa cards
if (state.createBackupAllowed && state.scanResponse?.card?.isSaltPay != true) it else null
}
SettingsElement.PrivacyPolicy -> {
if (state.privacyPolicyUrl != null) it else null
@ -75,8 +78,7 @@ class DetailsViewModel(private val store: Store<AppState>) {
store.dispatch(DetailsAction.CreateBackup)
}
SettingsElement.TermsOfService -> {
store.dispatch(DisclaimerAction.ShowAcceptedDisclaimer)
store.dispatch(NavigationAction.NavigateTo(AppScreen.Disclaimer))
store.dispatch(DisclaimerAction.Show())
}
SettingsElement.TermsOfUse -> {
store.dispatch(DetailsAction.ShowDisclaimer)

View file

@ -1,6 +1,8 @@
package com.tangem.tap.features.details.ui.resetcard
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -89,11 +91,14 @@ fun ResetCardView(
color = colorResource(id = R.color.text_secondary),
)
Spacer(modifier = modifier.size(44.dp))
Spacer(modifier = modifier.size(28.dp))
Row(
modifier = modifier
.fillMaxWidth()
.padding(end = 20.dp),
.clickable(
onClick = { state.onAcceptWarningToggleClick(!state.accepted) },
)
.padding(top = 16.dp, bottom = 16.dp),
) {
IconToggleButton(
checked = state.accepted,
@ -120,13 +125,15 @@ fun ResetCardView(
text = stringResource(id = R.string.reset_card_to_factory_warning_message),
style = TangemTypography.body2,
color = colorResource(id = R.color.text_secondary),
modifier = modifier
.padding(end = 20.dp),
)
}
Spacer(modifier = modifier.size(32.dp))
Spacer(modifier = modifier.size(16.dp))
Box(
modifier = modifier
.padding(start = 16.dp, end = 16.dp),
.padding(start = 16.dp, end = 16.dp, bottom = 32.dp),
) {
DetailsMainButton(
title = stringResource(id = R.string.reset_card_to_factory_button_title),

View file

@ -140,13 +140,12 @@ private fun WalletConnectSessions(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = modifier) {
Text(
text = session.description,
style = TangemTypography.subtitle1,
color = colorResource(id = R.color.text_primary_1),
)
}
Text(
text = session.description,
style = TangemTypography.subtitle1,
color = colorResource(id = R.color.text_primary_1),
modifier = modifier.weight(1f),
)
IconButton(
onClick = { state.onRemoveSession(session.sessionId) },
) {

View file

@ -1,8 +1,17 @@
package com.tangem.tap.features.disclaimer.redux
import com.tangem.common.extensions.VoidCallback
import org.rekotlin.Action
sealed class DisclaimerAction: Action {
object AcceptDisclaimer : DisclaimerAction()
object ShowAcceptedDisclaimer : DisclaimerAction()
sealed class DisclaimerAction : Action {
data class SetDisclaimerType(
val type: DisclaimerType,
) : DisclaimerAction()
data class Show(val onAcceptCallback: VoidCallback? = null) : DisclaimerAction()
data class AcceptDisclaimer(val type: DisclaimerType) : DisclaimerAction()
internal data class UpdateState(val type: DisclaimerType, val accepted: Boolean) : DisclaimerAction()
internal data class SetOnAcceptCallback(val onAcceptCallback: VoidCallback? = null) : DisclaimerAction()
}

View file

@ -1,28 +1,65 @@
package com.tangem.tap.features.disclaimer.redux
import com.tangem.tap.common.post
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.features.home.redux.HomeAction
import com.tangem.tap.preferencesStorage
import com.tangem.tap.store
import org.rekotlin.Action
import org.rekotlin.Middleware
class DisclaimerMiddleware {
val disclaimerMiddleware: Middleware<AppState> = { dispatch, state ->
{ next ->
{ action ->
when (action) {
is DisclaimerAction.AcceptDisclaimer -> {
preferencesStorage.saveDisclaimerAccepted()
store.dispatch(NavigationAction.PopBackTo())
//delayed sending used for better animation
post(550) { store.dispatch(HomeAction.ReadCard) }
}
}
state()?.let { handleDisclaimerMiddleware(action, it) }
next(action)
}
}
}
}
}
private fun handleDisclaimerMiddleware(action: Action, appState: AppState) {
val state = appState.disclaimerState
when (action) {
is DisclaimerAction.SetDisclaimerType -> {
handleUpdateState = action.type.createUpdateState()
}
is DisclaimerAction.Show -> {
handleUpdateState = state.type.createUpdateState()
store.dispatch(DisclaimerAction.SetOnAcceptCallback(action.onAcceptCallback))
store.dispatch(NavigationAction.NavigateTo(AppScreen.Disclaimer))
}
is DisclaimerAction.AcceptDisclaimer -> {
action.type.accept()
handleUpdateState = action.type.createUpdateState()
store.dispatch(NavigationAction.PopBackTo())
state.onAcceptCallback?.invoke()
store.dispatch(DisclaimerAction.SetOnAcceptCallback(null))
}
}
}
private fun DisclaimerType.accept() = when (this) {
DisclaimerType.SaltPay -> preferencesStorage.disclaimerPrefStorage.hasSaltPayTosAccepted = true
DisclaimerType.Tangem -> preferencesStorage.disclaimerPrefStorage.hasTangemTosAccepted = true
}
fun DisclaimerType.isAccepted(): Boolean = when (this) {
DisclaimerType.SaltPay -> preferencesStorage.disclaimerPrefStorage.hasSaltPayTosAccepted
DisclaimerType.Tangem -> preferencesStorage.disclaimerPrefStorage.hasTangemTosAccepted
}
private fun DisclaimerType.createUpdateState(): DisclaimerAction.UpdateState {
return DisclaimerAction.UpdateState(this, this.isAccepted())
}
private var handleUpdateState: DisclaimerAction.UpdateState = DisclaimerAction.UpdateState(
type = DisclaimerType.Tangem,
accepted = false,
)
set(value) {
field = value
store.dispatch(value)
}

View file

@ -13,9 +13,15 @@ private fun internalReduce(action: Action, state: AppState): DisclaimerState {
if (action !is DisclaimerAction) return state.disclaimerState
val disclaimerState = state.disclaimerState
return when (action) {
is DisclaimerAction.AcceptDisclaimer, DisclaimerAction.ShowAcceptedDisclaimer -> {
disclaimerState.copy(accepted = true)
}
is DisclaimerAction.UpdateState -> disclaimerState.copy(
type = action.type,
accepted = action.accepted,
)
is DisclaimerAction.SetOnAcceptCallback -> disclaimerState.copy(
onAcceptCallback = action.onAcceptCallback,
)
else -> disclaimerState
}
}

View file

@ -1,7 +1,30 @@
package com.tangem.tap.features.disclaimer.redux
import android.net.Uri
import com.tangem.common.card.Card
import com.tangem.common.extensions.VoidCallback
import com.tangem.domain.common.ScanResponse
import com.tangem.domain.common.TapWorkarounds.isSaltPay
import org.rekotlin.StateType
data class DisclaimerState(
val accepted: Boolean = false
val accepted: Boolean = false,
val type: DisclaimerType = DisclaimerType.Tangem,
val onAcceptCallback: VoidCallback? = null,
) : StateType
sealed class DisclaimerType(
val uri: Uri,
) {
object Tangem : DisclaimerType(Uri.parse("https://tangem.com/tangem_tos.html"))
object SaltPay : DisclaimerType(Uri.parse("https://tangem.com/soltpay_tos.html"))
companion object {
fun get(scanResponse: ScanResponse): DisclaimerType = get(scanResponse.card)
fun get(card: Card): DisclaimerType = when {
card.isSaltPay -> SaltPay
else -> Tangem
}
}
}

View file

@ -6,6 +6,7 @@ import androidx.activity.OnBackPressedCallback
import androidx.fragment.app.Fragment
import androidx.transition.TransitionInflater
import by.kirich1409.viewbindingdelegate.viewBinding
import com.tangem.tap.common.extensions.configureSettings
import com.tangem.tap.common.extensions.hide
import com.tangem.tap.common.redux.navigation.NavigationAction
import com.tangem.tap.features.disclaimer.redux.DisclaimerAction
@ -16,17 +17,20 @@ import com.tangem.wallet.databinding.FragmentDisclaimerBinding
import org.rekotlin.StoreSubscriber
class DisclaimerFragment : Fragment(R.layout.fragment_disclaimer),
StoreSubscriber<DisclaimerState> {
StoreSubscriber<DisclaimerState> {
private val binding: FragmentDisclaimerBinding by viewBinding(FragmentDisclaimerBinding::bind)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
activity?.onBackPressedDispatcher?.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
store.dispatch(NavigationAction.PopBackTo())
}
})
activity?.onBackPressedDispatcher?.addCallback(
this,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
store.dispatch(NavigationAction.PopBackTo())
}
},
)
val inflater = TransitionInflater.from(requireContext())
enterTransition = inflater.inflateTransition(android.R.transition.slide_bottom)
exitTransition = inflater.inflateTransition(android.R.transition.slide_top)
@ -51,19 +55,25 @@ class DisclaimerFragment : Fragment(R.layout.fragment_disclaimer),
binding.toolbar.setNavigationOnClickListener {
store.dispatch(NavigationAction.PopBackTo())
}
setOnClickListeners()
initViews()
}
private fun setOnClickListeners() {
binding.btnAccept.setOnClickListener { store.dispatch(DisclaimerAction.AcceptDisclaimer) }
private fun initViews() {
binding.webView.configureSettings()
}
override fun newState(state: DisclaimerState) {
override fun newState(state: DisclaimerState) = with(binding) {
if (activity == null || view == null) return
if (state.accepted) {
binding.halfTransparentOverlay.hide()
binding.btnAccept.hide()
halfTransparentOverlay.hide()
btnAccept.hide()
}
btnAccept.setOnClickListener {
store.dispatch(DisclaimerAction.AcceptDisclaimer(state.type))
}
webView.loadUrl(state.type.uri.toString())
}
}

View file

@ -81,16 +81,12 @@ class HomeFragment : Fragment(), StoreSubscriber<HomeState> {
StoriesScreen(
homeState,
onScanButtonClick = { store.dispatch(HomeAction.ReadCard) },
onShopButtonClick = {
store.dispatch(
HomeAction.GoToShop(store.state.globalState.userCountryCode)
)
},
onShopButtonClick = { store.dispatch(HomeAction.GoToShop(store.state.globalState.userCountryCode)) },
onSearchTokensClick = {
store.dispatch(NavigationAction.NavigateTo(AppScreen.AddTokens))
store.dispatch(TokensAction.AllowToAddTokens(false))
store.dispatch(TokensAction.LoadCurrencies())
}
},
)
}

View file

@ -49,7 +49,6 @@ import com.tangem.tap.features.home.compose.content.StoriesWeb3
import com.tangem.tap.features.home.compose.views.HomeButtons
import com.tangem.tap.features.home.compose.views.StoriesProgressBar
import com.tangem.tap.features.home.redux.HomeState
import com.tangem.tap.features.wallet.redux.ProgressState
import com.tangem.wallet.R
import kotlin.math.max
@ -74,7 +73,7 @@ fun StoriesScreen(
}
val isPressed = remember { mutableStateOf(false) }
val isPaused = isPressed.value || homeState.value.btnScanState.progressState == ProgressState.Loading
val isPaused = isPressed.value || homeState.value.scanInProgress
val hideContent = remember { mutableStateOf(true) }
@ -88,10 +87,10 @@ fun StoriesScreen(
Box(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFF090E13))
.background(Color(0xFF090E13)),
) {
Row(
modifier = Modifier.fillMaxSize()
modifier = Modifier.fillMaxSize(),
) {
Box(
Modifier
@ -109,7 +108,7 @@ fun StoriesScreen(
isPressed.value = false
},
)
}
},
)
Box(
Modifier
@ -127,7 +126,7 @@ fun StoriesScreen(
isPressed.value = false
},
)
}
},
)
}
if (!isDarkBackground) {
@ -135,7 +134,7 @@ fun StoriesScreen(
modifier = Modifier.fillMaxSize(),
painter = painterResource(id = R.drawable.ic_overlay),
contentDescription = null,
contentScale = ContentScale.FillBounds
contentScale = ContentScale.FillBounds,
)
}
@ -165,7 +164,7 @@ fun StoriesScreen(
.height(17.dp)
.alpha(if (hideContent.value) 0f else 1f)
.align(Alignment.Start),
colorFilter = if (isDarkBackground) null else ColorFilter.tint(Color.Black)
colorFilter = if (isDarkBackground) null else ColorFilter.tint(Color.Black),
)
when (currentStep.value) {
1 -> FirstStoriesContent(isPaused, currentStep.duration()) { hideContent.value = it }
@ -190,27 +189,28 @@ fun StoriesScreen(
.height(48.dp),
colors = ButtonDefaults.textButtonColors(
backgroundColor = Color.White,
contentColor = Color(0xFF080C10)
)
contentColor = Color(0xFF080C10),
),
) {
Image(
painter = painterResource(id = R.drawable.ic_search),
contentDescription = null
contentDescription = null,
)
Text(
text = stringResource(id = R.string.search_tokens_title),
fontWeight = FontWeight.Medium,
fontSize = 16.sp,
textAlign = TextAlign.Center
textAlign = TextAlign.Center,
)
}
HomeButtons(
isDarkBackground = isDarkBackground,
modifier = Modifier
.padding(start = 16.dp, top = 0.dp, end = 16.dp, bottom = 37.dp)
.fillMaxWidth(),
isDarkBackground = isDarkBackground,
btnScanStateInProgress = homeState.value.btnScanStateInProgress,
onScanButtonClick = onScanButtonClick,
onShopButtonClick = onShopButtonClick
onShopButtonClick = onShopButtonClick,
)
}
}

View file

@ -3,8 +3,10 @@ package com.tangem.tap.features.home.compose.views
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@ -19,53 +21,89 @@ import com.tangem.wallet.R
@Composable
fun HomeButtons(
isDarkBackground: Boolean, modifier: Modifier = Modifier,
modifier: Modifier = Modifier,
isDarkBackground: Boolean,
btnScanStateInProgress: Boolean,
onScanButtonClick: () -> Unit,
onShopButtonClick: () -> Unit
onShopButtonClick: () -> Unit,
) {
val darkColorBackground = Color(0xFF26292E)
val darkColorButton = Color(0xFF080C10)
Row(
horizontalArrangement = Arrangement.SpaceEvenly,
modifier = modifier
modifier = modifier,
) {
Button(
ProgressButton(
modifier = Modifier
.weight(1f)
.height(48.dp),
inProgress = btnScanStateInProgress,
backgroundColor = if (isDarkBackground) darkColorBackground else Color.White,
contentColor = if (isDarkBackground) Color.White else darkColorButton,
onClick = onScanButtonClick,
colors = ButtonDefaults.textButtonColors(
backgroundColor = if (isDarkBackground) darkColorBackground else Color.White,
contentColor = if (isDarkBackground) Color.White else darkColorButton
)
) {
Text(
text = stringResource(id = R.string.home_button_scan),
fontWeight = FontWeight.Medium,
fontSize = 16.sp,
textAlign = TextAlign.Center,
maxLines = 1
)
}
progressContent = {
CircularProgressIndicator(
modifier = Modifier.size(30.dp),
color = if (isDarkBackground) Color.White else darkColorBackground,
strokeWidth = 3.dp,
)
},
content = {
Text(
text = stringResource(id = R.string.home_button_scan),
fontWeight = FontWeight.Medium,
fontSize = 16.sp,
textAlign = TextAlign.Center,
maxLines = 1,
)
},
)
SpacerS8()
Button(
modifier = Modifier
.weight(1f)
.height(48.dp),
onClick = onShopButtonClick,
enabled = !btnScanStateInProgress,
colors = ButtonDefaults.textButtonColors(
backgroundColor = if (isDarkBackground) Color.White else darkColorBackground,
contentColor = if (isDarkBackground) darkColorButton else Color.White
)
contentColor = if (isDarkBackground) darkColorButton else Color.White,
),
) {
Text(
text = stringResource(id = R.string.home_button_order),
fontWeight = FontWeight.Medium,
fontSize = 16.sp,
textAlign = TextAlign.Center,
maxLines = 1
maxLines = 1,
)
}
}
}
@Composable
fun ProgressButton(
modifier: Modifier,
backgroundColor: Color,
contentColor: Color,
onClick: () -> Unit,
inProgress: Boolean,
progressContent: @Composable (() -> Unit)? = null,
content: @Composable () -> Unit,
) {
Button(
modifier = modifier,
onClick = onClick,
colors = ButtonDefaults.textButtonColors(
backgroundColor = backgroundColor,
contentColor = contentColor,
),
) {
if (progressContent != null && inProgress) {
progressContent()
} else {
content()
}
}
}

View file

@ -6,13 +6,12 @@ import org.rekotlin.Action
sealed class HomeAction : Action {
// from ui
object ReadCard : HomeAction()
data class ScanInProgress(val scanInProgress: Boolean) : HomeAction()
data class GoToShop(val userCountryCode: String?) : HomeAction()
// internal
data class ShouldScanCardOnResume(val shouldScanCard: Boolean) : HomeAction()
object Init : HomeAction()
data class SetTermsOfUseState(val isDisclaimerAccepted: Boolean) : HomeAction()
data class ChangeScanCardButtonState(val state: IndeterminateProgressButton) : HomeAction()
}

View file

@ -1,31 +1,47 @@
package com.tangem.tap.features.home.redux
import com.tangem.common.core.TangemError
import com.tangem.common.services.Result
import com.tangem.domain.common.ScanResponse
import com.tangem.domain.common.extensions.withIOContext
import com.tangem.domain.common.extensions.withMainContext
import com.tangem.tap.DELAY_SDK_DIALOG_CLOSE
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.dispatchDialogShow
import com.tangem.tap.common.extensions.dispatchOpenUrl
import com.tangem.tap.common.extensions.onCardScanned
import com.tangem.tap.common.post
import com.tangem.tap.common.postUi
import com.tangem.tap.common.postUiDelayBg
import com.tangem.tap.common.redux.AppDialog
import com.tangem.tap.common.redux.AppState
import com.tangem.tap.common.redux.global.GlobalAction
import com.tangem.tap.common.redux.navigation.AppScreen
import com.tangem.tap.common.redux.navigation.FragmentShareTransition
import com.tangem.tap.common.redux.navigation.NavigationAction
import com.tangem.tap.features.disclaimer.redux.DisclaimerAction
import com.tangem.tap.features.disclaimer.redux.DisclaimerType
import com.tangem.tap.features.disclaimer.redux.isAccepted
import com.tangem.tap.features.home.BELARUS_COUNTRY_CODE
import com.tangem.tap.features.home.RUSSIA_COUNTRY_CODE
import com.tangem.tap.features.home.redux.HomeMiddleware.Companion.BUY_WALLET_URL
import com.tangem.tap.features.onboarding.OnboardingHelper
import com.tangem.tap.features.onboarding.OnboardingSaltPayHelper
import com.tangem.tap.features.onboarding.products.twins.redux.TwinCardsAction
import com.tangem.tap.features.onboarding.products.twins.redux.TwinCardsStep
import com.tangem.tap.features.onboarding.products.wallet.saltPay.SaltPayExceptionHandler
import com.tangem.tap.features.onboarding.products.wallet.saltPay.redux.OnboardingSaltPayAction
import com.tangem.tap.features.onboarding.products.wallet.saltPay.redux.OnboardingSaltPayState
import com.tangem.tap.features.send.redux.states.ButtonState
import com.tangem.tap.preferencesStorage
import com.tangem.tap.scope
import com.tangem.tap.store
import com.tangem.wallet.R
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.rekotlin.Action
import org.rekotlin.DispatchFunction
import org.rekotlin.Middleware
class HomeMiddleware {
@ -36,87 +52,146 @@ class HomeMiddleware {
}
}
private val homeMiddleware: Middleware<AppState> = { _, _ ->
private val homeMiddleware: Middleware<AppState> = { dispatch, state ->
{ next ->
{ action ->
when (action) {
is HomeAction.Init -> {
store.dispatch(GlobalAction.RestoreAppCurrency)
store.dispatch(GlobalAction.ExchangeManager.Init)
store.dispatch(HomeAction.SetTermsOfUseState(preferencesStorage.wasDisclaimerAccepted()))
store.dispatch(GlobalAction.FetchUserCountry)
}
is HomeAction.ShouldScanCardOnResume -> {
if (action.shouldScanCard) {
store.dispatch(HomeAction.ShouldScanCardOnResume(false))
postUi(700) { store.dispatch(HomeAction.ReadCard) }
}
}
is HomeAction.ReadCard -> {
handleReadCard()
// store.dispatch(NavigationAction.NavigateTo(AppScreen.AddCustomTokens))
}
is HomeAction.GoToShop -> {
when (action.userCountryCode) {
RUSSIA_COUNTRY_CODE, BELARUS_COUNTRY_CODE ->
store.dispatchOpenUrl(BUY_WALLET_URL)
else -> store.dispatch(NavigationAction.NavigateTo(AppScreen.Shop))
}
store.state.globalState.analyticsHandler?.handleAnalyticsEvent(
event = AnalyticsEvent.GET_CARD,
params = mapOf(AnalyticsParam.SOURCE.param to GetCardSourceParams.WELCOME.param)
)
}
}
handleHomeAction(state, action, dispatch)
next(action)
}
}
}
private fun handleReadCard() {
if (!preferencesStorage.wasDisclaimerAccepted()) {
store.dispatch(NavigationAction.NavigateTo(AppScreen.Disclaimer))
} else {
changeButtonState(ButtonState.PROGRESS)
store.dispatch(
GlobalAction.ScanCard(
onSuccess = { scanResponse ->
store.state.globalState.tapWalletManager.updateConfigManager(scanResponse)
store.dispatch(TwinCardsAction.IfTwinsPrepareState(scanResponse))
private fun handleHomeAction(appState: () -> AppState?, action: Action, dispatch: DispatchFunction) {
when (action) {
is HomeAction.Init -> {
store.dispatch(GlobalAction.RestoreAppCurrency)
store.dispatch(GlobalAction.ExchangeManager.Init)
store.dispatch(GlobalAction.FetchUserCountry)
}
is HomeAction.ShouldScanCardOnResume -> {
if (action.shouldScanCard) {
store.dispatch(HomeAction.ShouldScanCardOnResume(false))
postUiDelayBg(700) { store.dispatch(HomeAction.ReadCard) }
}
}
is HomeAction.ReadCard -> {
changeButtonState(ButtonState.PROGRESS)
val scanCardAction = GlobalAction.ScanCard(
onSuccess = {
store.dispatch(HomeAction.ScanInProgress(false))
checkForDisclaimer(it, ::onScanSuccess)
},
onFailure = ::onScanFailure,
)
store.dispatch(HomeAction.ScanInProgress(true))
postUiDelayBg(300) { store.dispatch(scanCardAction) }
}
is HomeAction.GoToShop -> {
when (action.userCountryCode) {
RUSSIA_COUNTRY_CODE, BELARUS_COUNTRY_CODE -> store.dispatchOpenUrl(BUY_WALLET_URL)
else -> store.dispatch(NavigationAction.NavigateTo(AppScreen.Shop))
}
store.state.globalState.analyticsHandlers?.triggerEvent(
event = AnalyticsEvent.GET_CARD,
params = mapOf(AnalyticsParam.SOURCE.param to GetCardSourceParams.WELCOME.param),
)
}
}
}
if (OnboardingHelper.isOnboardingCase(scanResponse)) {
val navigateTo = OnboardingHelper.whereToNavigate(scanResponse)
store.dispatch(GlobalAction.Onboarding.Start(scanResponse))
navigateTo(navigateTo)
} else {
scope.launch {
withMainContext {
if (scanResponse.twinsIsTwinned() && !preferencesStorage.wasTwinsOnboardingShown()) {
store.dispatch(TwinCardsAction.SetStepOfScreen(TwinCardsStep.WelcomeOnly))
navigateTo(AppScreen.OnboardingTwins)
} else {
navigateTo(AppScreen.Wallet, null)
}
}
store.onCardScanned(scanResponse)
}
}
},
onFailure = {
changeButtonState(ButtonState.ENABLED)
},
),
private fun checkForDisclaimer(scanResponse: ScanResponse, onDisclaimerAccepted: (ScanResponse) -> Unit) {
val disclaimerType = DisclaimerType.get(scanResponse)
store.dispatch(DisclaimerAction.SetDisclaimerType(disclaimerType))
if (disclaimerType.isAccepted()) {
onDisclaimerAccepted((scanResponse))
} else {
changeButtonState(ButtonState.ENABLED)
store.dispatch(
DisclaimerAction.Show {
changeButtonState(ButtonState.PROGRESS)
onDisclaimerAccepted(scanResponse)
},
)
}
}
private fun onScanSuccess(scanResponse: ScanResponse) {
val globalState = store.state.globalState
val tapWalletManager = globalState.tapWalletManager
tapWalletManager.updateConfigManager(scanResponse)
store.dispatch(TwinCardsAction.IfTwinsPrepareState(scanResponse))
if (scanResponse.isSaltPay()) {
if (scanResponse.isSaltPayVisa()) {
scope.launch {
val (manager, config) = OnboardingSaltPayState.initDependency(scanResponse)
val result = OnboardingSaltPayHelper.isOnboardingCase(scanResponse, manager)
delay(500)
withMainContext {
when (result) {
is Result.Success -> {
val isOnboardingCase = result.data
if (isOnboardingCase) {
store.dispatch(GlobalAction.Onboarding.Start(scanResponse, canSkipBackup = false))
store.dispatch(OnboardingSaltPayAction.Init.SetDependencies(manager, config))
store.dispatch(OnboardingSaltPayAction.Update)
navigateTo(AppScreen.OnboardingWallet)
} else {
navigateTo(AppScreen.Wallet)
withIOContext { store.onCardScanned(scanResponse) }
}
}
is Result.Failure -> {
changeButtonState(ButtonState.ENABLED)
SaltPayExceptionHandler.handle(result.error)
}
}
}
}
} else {
if (scanResponse.card.backupStatus?.isActive == false) {
changeButtonState(ButtonState.ENABLED)
store.dispatchDialogShow(
AppDialog.SimpleOkDialogRes(
headerId = R.string.saltpay_error_empty_backup_title,
messageId = R.string.saltpay_error_empty_backup_message,
),
)
} else {
navigateTo(AppScreen.Wallet, null)
scope.launch { store.onCardScanned(scanResponse) }
}
}
} else {
if (OnboardingHelper.isOnboardingCase(scanResponse)) {
store.dispatch(GlobalAction.Onboarding.Start(scanResponse, canSkipBackup = true))
val appScreen = OnboardingHelper.whereToNavigate(scanResponse)
navigateTo(appScreen)
} else {
if (scanResponse.twinsIsTwinned() && !preferencesStorage.wasTwinsOnboardingShown()) {
store.dispatch(TwinCardsAction.SetStepOfScreen(TwinCardsStep.WelcomeOnly))
navigateTo(AppScreen.OnboardingTwins)
} else {
navigateTo(AppScreen.Wallet, null)
}
scope.launch { store.onCardScanned(scanResponse) }
}
}
}
private fun onScanFailure(error: TangemError) {
store.dispatch(HomeAction.ScanInProgress(false))
changeButtonState(ButtonState.ENABLED)
}
private fun changeButtonState(state: ButtonState) {
val btnState = IndeterminateProgressButton(state)
store.dispatch(HomeAction.ChangeScanCardButtonState(btnState))
store.dispatch(HomeAction.ChangeScanCardButtonState(IndeterminateProgressButton(state)))
}
private fun navigateTo(screen: AppScreen, transition: FragmentShareTransition? = null) {
post(DELAY_SDK_DIALOG_CLOSE) {
postUiDelayBg(DELAY_SDK_DIALOG_CLOSE) {
changeButtonState(ButtonState.ENABLED)
store.dispatch(NavigationAction.NavigateTo(screen, transition))
}

View file

@ -15,6 +15,9 @@ private fun internalReduce(action: Action, state: AppState): HomeState {
var state = state.homeState
when (action) {
is HomeAction.ScanInProgress -> {
state = state.copy(scanInProgress = action.scanInProgress)
}
is HomeAction.ShouldScanCardOnResume -> {
state = state.copy(shouldScanCardOnResume = action.shouldScanCard)
}

View file

@ -2,9 +2,15 @@ package com.tangem.tap.features.home.redux
import com.tangem.tap.common.entities.IndeterminateProgressButton
import com.tangem.tap.features.send.redux.states.ButtonState
import com.tangem.tap.features.wallet.redux.ProgressState
import org.rekotlin.StateType
data class HomeState(
val shouldScanCardOnResume: Boolean = false,
val scanInProgress: Boolean = false,
val btnScanState: IndeterminateProgressButton = IndeterminateProgressButton(ButtonState.ENABLED),
) : StateType
) : StateType {
val btnScanStateInProgress: Boolean
get() = btnScanState.progressState == ProgressState.Loading
}

View file

@ -15,16 +15,14 @@ class OnboardingHelper {
fun isOnboardingCase(response: ScanResponse): Boolean {
val cardInfoStorage = preferencesStorage.usedCardsPrefStorage
return when {
response.productType == ProductType.Twins -> {
response.isTangemTwins() -> {
if (!response.twinsIsTwinned()) {
true
} else {
cardInfoStorage.activationIsStarted(response.card.cardId)
}
}
response.card.hasWallets() -> {
cardInfoStorage.activationIsStarted(response.card.cardId)
}
response.card.hasWallets() -> cardInfoStorage.activationIsStarted(response.card.cardId)
else -> true
}
}
@ -38,6 +36,7 @@ class OnboardingHelper {
AppScreen.OnboardingOther
}
ProductType.Twins -> AppScreen.OnboardingTwins
ProductType.SaltPay -> AppScreen.OnboardingWallet
}
}
}

View file

@ -0,0 +1,27 @@
package com.tangem.tap.features.onboarding
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import androidx.core.view.MenuProvider
import com.tangem.tap.common.feedback.SupportInfo
import com.tangem.tap.common.redux.global.GlobalAction
import com.tangem.tap.store
import com.tangem.wallet.R
/**
[REDACTED_AUTHOR]
*/
class OnboardingMenuProvider : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.menu_onboarding, menu)
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
R.id.menu_item_chat_support -> {
store.dispatch(GlobalAction.OpenChat(SupportInfo()))
true
}
else -> false
}
}

View file

@ -0,0 +1,47 @@
package com.tangem.tap.features.onboarding
import com.tangem.common.services.Result
import com.tangem.domain.common.ScanResponse
import com.tangem.domain.common.extensions.successOr
import com.tangem.tap.features.onboarding.products.wallet.saltPay.SaltPayActivationManager
import com.tangem.tap.features.onboarding.products.wallet.saltPay.message.SaltPayActivationError
import com.tangem.tap.features.onboarding.products.wallet.saltPay.redux.SaltPayActivationStep
import com.tangem.tap.features.onboarding.products.wallet.saltPay.redux.updateActivationStatus
/**
[REDACTED_AUTHOR]
*/
class OnboardingSaltPayHelper {
companion object {
suspend fun isOnboardingCase(
scanResponse: ScanResponse,
manager: SaltPayActivationManager,
): Result<Boolean> {
return try {
val status = manager.updateActivationStatus(
amountToClaim = null,
step = SaltPayActivationStep.None,
).successOr { return it }
val isRegistrationCase = status.step != SaltPayActivationStep.Finished
val isBackupCase = scanResponse.card.backupStatus?.isActive == false
Result.Success(isRegistrationCase || isBackupCase)
} catch (ex: Exception) {
Result.Failure(ex)
} catch (error: SaltPayActivationError) {
Result.Failure(error)
}
}
suspend fun testProceedToOnboarding(
scanResponse: ScanResponse,
manager: SaltPayActivationManager,
): Result<Boolean> = Result.Success(true)
suspend fun testProceedToMainScreen(
scanResponse: ScanResponse,
manager: SaltPayActivationManager,
): Result<Boolean> = Result.Success(false)
}
}

View file

@ -1,9 +1,14 @@
package com.tangem.tap.features.onboarding.products
import android.os.Bundle
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.MenuProvider
import by.kirich1409.viewbindingdelegate.viewBinding
import com.tangem.tap.common.extensions.show
import com.tangem.tap.common.transitions.HomeToOnboardingTransition
import com.tangem.tap.features.BaseStoreFragment
import com.tangem.tap.features.onboarding.OnboardingMenuProvider
import com.tangem.wallet.R
import com.tangem.wallet.databinding.FragmentOnboardingMainBinding
import com.tangem.wallet.databinding.ViewOnboardingProgressBinding
@ -22,6 +27,12 @@ abstract class BaseOnboardingFragment<T> : BaseStoreFragment(R.layout.fragment_o
exitTransition = HomeToOnboardingTransition()
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
(activity as? AppCompatActivity)?.setSupportActionBar(binding.toolbar)
}
override fun loadToolbarMenu(): MenuProvider? = OnboardingMenuProvider()
protected fun showConfetti(show: Boolean) = with(binding.vConfetti) {
lavConfetti.show(show)

View file

@ -1,19 +1,14 @@
package com.tangem.tap.features.onboarding.products.note
import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable
import android.os.Bundle
import android.view.View
import android.view.animation.OvershootInterpolator
import android.widget.ImageView
import androidx.annotation.LayoutRes
import androidx.constraintlayout.widget.ConstraintSet
import androidx.core.view.isVisible
import androidx.transition.TransitionManager
import coil.load
import com.tangem.blockchain.common.Blockchain
import com.tangem.tangem_sdk_new.extensions.fadeIn
import com.tangem.tangem_sdk_new.extensions.fadeOut
import com.tangem.tap.common.extensions.getDrawableCompat
import com.tangem.tap.common.extensions.stripZeroPlainString
import com.tangem.tap.common.redux.navigation.ShareElement
@ -33,23 +28,18 @@ import com.tangem.wallet.R
class OnboardingNoteFragment : BaseOnboardingFragment<OnboardingNoteState>() {
private var previousStep: OnboardingNoteStep = OnboardingNoteStep.None
private val mainBinding by lazy { binding.vMain }
private lateinit var btnRefreshBalanceWidget: RefreshBalanceWidget
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
postponeEnterTransition()
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
addBackPressHandler(this)
binding.onboardingTopContainer.imvFrontCard.transitionName = ShareElement.imvFrontCard
startPostponedEnterTransition()
mainBinding.onboardingTopContainer.imvFrontCard.transitionName = ShareElement.imvFrontCard
binding.toolbar.setTitle(R.string.onboarding_title)
btnRefreshBalanceWidget = RefreshBalanceWidget(binding.onboardingTopContainer.onboardingMainContainer)
btnRefreshBalanceWidget = RefreshBalanceWidget(mainBinding.onboardingTopContainer.onboardingWalletContainer)
store.dispatch(OnboardingNoteAction.LoadCardArtwork)
store.dispatch(OnboardingNoteAction.DetermineStepOfScreen)
@ -67,7 +57,7 @@ class OnboardingNoteFragment : BaseOnboardingFragment<OnboardingNoteState>() {
override fun newState(state: OnboardingNoteState) {
if (activity == null || view == null) return
binding.onboardingTopContainer.imvFrontCard.load(state.cardArtworkUrl) {
mainBinding.onboardingTopContainer.imvFrontCard.load(state.cardArtworkUrl) {
placeholder(R.drawable.card_placeholder_black)
error(R.drawable.card_placeholder_black)
fallback(R.drawable.card_placeholder_black)
@ -90,7 +80,7 @@ class OnboardingNoteFragment : BaseOnboardingFragment<OnboardingNoteState>() {
private fun setBalance(state: OnboardingNoteState) {
if (state.walletBalance.currency.blockchain == Blockchain.Unknown) return
with (binding.onboardingTopContainer.onboardingTvBalance) {
with(mainBinding.onboardingTopContainer.onboardingTvBalance) {
if (state.balanceCriticalError == null) {
val balanceValue = state.walletBalance.value.stripZeroPlainString()
tvBalanceValue.text = balanceValue
@ -102,7 +92,7 @@ class OnboardingNoteFragment : BaseOnboardingFragment<OnboardingNoteState>() {
}
}
private fun setupNoneState() = with(binding.onboardingActionContainer) {
private fun setupNoneState() = with(mainBinding.onboardingActionContainer) {
btnMainAction.isVisible = false
btnAlternativeAction.isVisible = false
btnRefreshBalanceWidget.show(false)
@ -116,7 +106,7 @@ class OnboardingNoteFragment : BaseOnboardingFragment<OnboardingNoteState>() {
}
private fun setupCreateWalletState(state: OnboardingNoteState) =
with(binding.onboardingActionContainer) {
with(mainBinding.onboardingActionContainer) {
btnMainAction.setText(R.string.onboarding_create_wallet_button_create_wallet)
btnMainAction.setOnClickListener { store.dispatch(OnboardingNoteAction.CreateWallet) }
btnAlternativeAction.setText(R.string.onboarding_button_what_does_it_mean)
@ -127,15 +117,15 @@ class OnboardingNoteFragment : BaseOnboardingFragment<OnboardingNoteState>() {
tvHeader.setText(R.string.onboarding_create_wallet_header)
tvBody.setText(R.string.onboarding_create_wallet_body)
binding.onboardingTopContainer.imvCardBackground.setBackgroundDrawable(
requireContext().getDrawableCompat(R.drawable.shape_circle)
mainBinding.onboardingTopContainer.imvCardBackground.setBackgroundDrawable(
requireContext().getDrawableCompat(R.drawable.shape_circle),
)
updateConstraints(state.currentStep, R.layout.lp_onboarding_create_wallet)
btnAlternativeAction.isVisible = false // temporary
}
private fun setupTopUpWalletState(state: OnboardingNoteState) = with(binding.onboardingActionContainer) {
private fun setupTopUpWalletState(state: OnboardingNoteState) = with(mainBinding.onboardingActionContainer) {
if (state.isBuyAllowed) {
btnMainAction.setText(R.string.onboarding_top_up_button_but_crypto)
btnMainAction.setOnClickListener {
@ -163,7 +153,7 @@ class OnboardingNoteFragment : BaseOnboardingFragment<OnboardingNoteState>() {
state.walletBalance.amountToCreateAccount?.let { amount ->
val tvBodyMessage = getString(
R.string.onboarding_top_up_body_no_account_error,
amount, state.walletBalance.currency.currencySymbol
amount, state.walletBalance.currency.currencySymbol,
)
tvBody.text = tvBodyMessage
}
@ -175,12 +165,13 @@ class OnboardingNoteFragment : BaseOnboardingFragment<OnboardingNoteState>() {
store.dispatch(OnboardingNoteAction.Balance.Update)
}
}
binding.onboardingTopContainer.imvCardBackground
.setBackgroundDrawable(requireContext().getDrawableCompat(R.drawable.shape_rectangle_rounded_8))
val drawable = requireContext().getDrawableCompat(R.drawable.shape_rectangle_rounded_8)
mainBinding.onboardingTopContainer.imvCardBackground.setBackgroundDrawable(drawable)
updateConstraints(state.currentStep, R.layout.lp_onboarding_topup_wallet)
}
private fun setupDoneState(state: OnboardingNoteState) = with(binding.onboardingActionContainer) {
private fun setupDoneState(state: OnboardingNoteState) = with(mainBinding.onboardingActionContainer) {
btnMainAction.setText(R.string.onboarding_done_button_continue)
btnMainAction.setOnClickListener {
showConfetti(false)
@ -188,15 +179,15 @@ class OnboardingNoteFragment : BaseOnboardingFragment<OnboardingNoteState>() {
}
btnAlternativeAction.isVisible = false
btnAlternativeAction.setText("")
btnAlternativeAction.text = ""
btnAlternativeAction.setOnClickListener { }
btnRefreshBalanceWidget.mainView.setOnClickListener(null)
tvHeader.setText(R.string.onboarding_done_header)
tvBody.setText(R.string.onboarding_done_body)
binding.onboardingTopContainer.imvCardBackground.setBackgroundDrawable(
requireContext().getDrawableCompat(R.drawable.shape_rectangle_rounded_8)
mainBinding.onboardingTopContainer.imvCardBackground.setBackgroundDrawable(
requireContext().getDrawableCompat(R.drawable.shape_rectangle_rounded_8),
)
updateConstraints(state.currentStep, R.layout.lp_onboarding_done_activation)
}
@ -204,22 +195,13 @@ class OnboardingNoteFragment : BaseOnboardingFragment<OnboardingNoteState>() {
private fun updateConstraints(currentStep: OnboardingNoteStep, @LayoutRes layoutId: Int) {
if (this.previousStep == currentStep) return
with(binding.onboardingTopContainer) {
with(mainBinding.onboardingTopContainer) {
val constraintSet = ConstraintSet()
constraintSet.clone(requireContext(), layoutId)
constraintSet.applyTo(onboardingMainContainer)
constraintSet.applyTo(onboardingWalletContainer)
val transition = InternalNoteLayoutTransition()
transition.interpolator = OvershootInterpolator()
TransitionManager.beginDelayedTransition(onboardingMainContainer, transition)
TransitionManager.beginDelayedTransition(onboardingWalletContainer, transition)
}
}
}
fun ImageView.swapToBitmapDrawable(bitmap: Bitmap?, duration: Long = 200) {
if (drawable is BitmapDrawable && (drawable as BitmapDrawable).bitmap == bitmap) return
fadeOut(duration) {
setImageBitmap(bitmap)
fadeIn(duration)
}
}

View file

@ -4,7 +4,12 @@ import com.tangem.common.CompletionResult
import com.tangem.common.extensions.guard
import com.tangem.domain.common.extensions.withMainContext
import com.tangem.tap.DELAY_SDK_DIALOG_CLOSE
import com.tangem.tap.common.extensions.*
import com.tangem.tap.common.extensions.dispatchDialogShow
import com.tangem.tap.common.extensions.dispatchErrorNotification
import com.tangem.tap.common.extensions.dispatchOpenUrl
import com.tangem.tap.common.extensions.getAddressData
import com.tangem.tap.common.extensions.getToUpUrl
import com.tangem.tap.common.extensions.onCardScanned
import com.tangem.tap.common.postUi
import com.tangem.tap.common.redux.AppDialog
import com.tangem.tap.common.redux.AppState
@ -115,11 +120,11 @@ private fun handleNoteAction(appState: () -> AppState?, action: Action, dispatch
val balanceIsLoading = noteState.walletBalance.copy(
currency = Currency.Blockchain(
walletManager.wallet.blockchain,
walletManager.wallet.publicKey.derivationPath?.rawPath
walletManager.wallet.publicKey.derivationPath?.rawPath,
),
state = ProgressState.Loading,
error = null,
criticalError = null
criticalError = null,
)
store.dispatch(OnboardingNoteAction.Balance.Set(balanceIsLoading))

View file

@ -24,17 +24,13 @@ import com.tangem.wallet.R
*/
class OnboardingOtherCardsFragment : BaseOnboardingFragment<OnboardingOtherCardsState>() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
postponeEnterTransition()
}
private val mainBinding by lazy { binding.vMain }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
addBackPressHandler(this)
binding.onboardingTopContainer.imvFrontCard.transitionName = ShareElement.imvFrontCard
startPostponedEnterTransition()
mainBinding.onboardingTopContainer.imvFrontCard.transitionName = ShareElement.imvFrontCard
binding.toolbar.setTitle(R.string.onboarding_title)
store.dispatch(OnboardingOtherCardsAction.LoadCardArtwork)
@ -54,7 +50,7 @@ class OnboardingOtherCardsFragment : BaseOnboardingFragment<OnboardingOtherCards
if (activity == null || view == null) return
if (state.currentStep == OnboardingOtherCardsStep.None) return
binding.onboardingTopContainer.imvFrontCard.load(state.cardArtworkUrl) {
mainBinding.onboardingTopContainer.imvFrontCard.load(state.cardArtworkUrl) {
placeholder(R.drawable.card_placeholder_black)
error(R.drawable.card_placeholder_black)
fallback(R.drawable.card_placeholder_black)
@ -70,29 +66,25 @@ class OnboardingOtherCardsFragment : BaseOnboardingFragment<OnboardingOtherCards
showConfetti(state.showConfetti)
}
private fun setupCreateWalletState() = with(binding) {
private fun setupCreateWalletState() = with(mainBinding.onboardingActionContainer) {
btnMainAction.setText(R.string.onboarding_create_wallet_button_create_wallet)
btnMainAction.setOnClickListener { store.dispatch(OnboardingOtherCardsAction.CreateWallet) }
btnAlternativeAction.setText(R.string.onboarding_button_what_does_it_mean)
btnAlternativeAction.setOnClickListener { }
with(onboardingActionContainer) {
btnMainAction.setText(R.string.onboarding_create_wallet_button_create_wallet)
btnMainAction.setOnClickListener { store.dispatch(OnboardingOtherCardsAction.CreateWallet) }
btnAlternativeAction.setText(R.string.onboarding_button_what_does_it_mean)
btnAlternativeAction.setOnClickListener { }
tvHeader.setText(R.string.onboarding_create_wallet_header)
tvBody.setText(R.string.onboarding_create_wallet_body)
tvHeader.setText(R.string.onboarding_create_wallet_header)
tvBody.setText(R.string.onboarding_create_wallet_body)
btnAlternativeAction.isVisible = false // temporary
btnAlternativeAction.isVisible = false // temporary
}
binding.onboardingTopContainer.imvCardBackground.setBackgroundDrawable(
requireContext().getDrawableCompat(R.drawable.shape_circle)
mainBinding.onboardingTopContainer.imvCardBackground.setBackgroundDrawable(
requireContext().getDrawableCompat(R.drawable.shape_circle),
)
updateConstraints(R.layout.lp_onboarding_create_wallet)
}
private fun setupDoneState() = with(binding.onboardingActionContainer) {
private fun setupDoneState() = with(mainBinding.onboardingActionContainer) {
btnMainAction.setText(R.string.onboarding_done_button_continue)
btnMainAction.setOnClickListener {
showConfetti(false)
@ -106,20 +98,18 @@ class OnboardingOtherCardsFragment : BaseOnboardingFragment<OnboardingOtherCards
tvHeader.setText(R.string.onboarding_done_header)
tvBody.setText(R.string.onboarding_done_body)
binding.onboardingTopContainer.imvCardBackground?.setBackgroundDrawable(
requireContext().getDrawableCompat(R.drawable.shape_rectangle_rounded_8)
mainBinding.onboardingTopContainer.imvCardBackground?.setBackgroundDrawable(
requireContext().getDrawableCompat(R.drawable.shape_rectangle_rounded_8),
)
updateConstraints(R.layout.lp_onboarding_done)
}
private fun updateConstraints(@LayoutRes layoutId: Int) {
with(binding.onboardingTopContainer) {
val constraintSet = ConstraintSet()
constraintSet.clone(requireContext(), layoutId)
constraintSet.applyTo(onboardingMainContainer)
val transition = InternalNoteLayoutTransition()
transition.interpolator = OvershootInterpolator()
TransitionManager.beginDelayedTransition(onboardingMainContainer, transition)
}
private fun updateConstraints(@LayoutRes layoutId: Int) = with(mainBinding.onboardingTopContainer) {
val constraintSet = ConstraintSet()
constraintSet.clone(requireContext(), layoutId)
constraintSet.applyTo(onboardingWalletContainer)
val transition = InternalNoteLayoutTransition()
transition.interpolator = OvershootInterpolator()
TransitionManager.beginDelayedTransition(onboardingWalletContainer, transition)
}
}

View file

@ -79,17 +79,21 @@ private fun handleOtherCardsAction(action: Action, dispatch: DispatchFunction) {
val primaryBlockchain = updatedResponse.getBlockchain()
val blockchainNetworks = if (primaryBlockchain != Blockchain.Unknown) {
val primaryToken = updatedResponse.getPrimaryToken()
val blockchainNetwork = BlockchainNetwork(primaryBlockchain, updatedResponse.card).updateTokens(
listOfNotNull(primaryToken))
val blockchainNetwork =
BlockchainNetwork(primaryBlockchain, updatedResponse.card).updateTokens(
listOfNotNull(primaryToken),
)
listOf(blockchainNetwork)
} else {
listOf(
BlockchainNetwork(Blockchain.Bitcoin, updatedResponse.card),
BlockchainNetwork(Blockchain.Ethereum, updatedResponse.card)
BlockchainNetwork(Blockchain.Ethereum, updatedResponse.card),
)
}
store.dispatch(WalletAction.MultiWallet.SaveCurrencies(blockchainNetworks))
store.dispatch(
WalletAction.MultiWallet.SaveCurrencies(blockchainNetworks, updatedResponse.card),
)
delay(DELAY_SDK_DIALOG_CLOSE)
store.dispatch(OnboardingOtherCardsAction.SetStepOfScreen(OnboardingOtherCardsStep.Done))

View file

@ -40,6 +40,7 @@ import com.tangem.wallet.databinding.LayoutOnboardingContainerTopBinding
class TwinsCardsFragment : BaseOnboardingFragment<TwinCardsState>() {
private val mainBinding by lazy { binding.vMain }
private var previousStep = TwinCardsStep.None
private lateinit var twinsWidget: TwinsCardWidget
@ -47,7 +48,6 @@ class TwinsCardsFragment : BaseOnboardingFragment<TwinCardsState>() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
postponeEnterTransition()
store.dispatch(TwinCardsAction.Init)
}
@ -66,30 +66,30 @@ class TwinsCardsFragment : BaseOnboardingFragment<TwinCardsState>() {
super.onViewCreated(view, savedInstanceState)
addBackPressHandler(this)
reconfigureLayoutForTwins(binding.onboardingTopContainer)
reconfigureLayoutForTwins(mainBinding.onboardingTopContainer)
val typedValue = TypedValue()
resources.getValue(R.dimen.device_scale_factor_for_twins_welcome, typedValue, true)
val deviceScaleFactorForWelcomeState = typedValue.float
twinsWidget = TwinsCardWidget(
LeapfrogWidget(binding.onboardingTopContainer.cardsContainer),
deviceScaleFactorForWelcomeState
LeapfrogWidget(mainBinding.onboardingTopContainer.cardsContainer),
deviceScaleFactorForWelcomeState,
) {
285f * deviceScaleFactorForWelcomeState
}
btnRefreshBalanceWidget =
RefreshBalanceWidget(binding.onboardingTopContainer.onboardingMainContainer)
RefreshBalanceWidget(mainBinding.onboardingTopContainer.onboardingWalletContainer)
binding.toolbar.title = getText(R.string.twins_recreate_toolbar)
binding.onboardingTopContainer.imvTwinFrontCard.load(Artwork.TWIN_CARD_1) {
mainBinding.onboardingTopContainer.imvTwinFrontCard.load(Artwork.TWIN_CARD_1) {
placeholder(R.drawable.card_placeholder_black)
error(R.drawable.card_placeholder_black)
fallback(R.drawable.card_placeholder_black)
}
binding.onboardingTopContainer.imvTwinBackCard.load(Artwork.TWIN_CARD_2) {
mainBinding.onboardingTopContainer.imvTwinBackCard.load(Artwork.TWIN_CARD_2) {
placeholder(R.drawable.card_placeholder_white)
error(R.drawable.card_placeholder_white)
fallback(R.drawable.card_placeholder_white)
@ -143,7 +143,7 @@ class TwinsCardsFragment : BaseOnboardingFragment<TwinCardsState>() {
private fun setBalance(state: TwinCardsState) {
if (state.walletBalance.currency.blockchain == Blockchain.Unknown) return
with(binding.onboardingTopContainer.onboardingTvBalance) {
with(mainBinding.onboardingTopContainer.onboardingTvBalance) {
if (state.balanceCriticalError == null) {
val balanceValue = state.walletBalance.value.stripZeroPlainString()
tvBalanceValue.text = balanceValue
@ -167,26 +167,26 @@ class TwinsCardsFragment : BaseOnboardingFragment<TwinCardsState>() {
}
private fun setupWelcomeState(state: TwinCardsState, mainAction: VoidCallback) =
with(binding.onboardingActionContainer) {
twinsWidget.toWelcome(false) { startPostponedEnterTransition() }
with(mainBinding.onboardingActionContainer) {
twinsWidget.toWelcome(false)
binding.onboardingTopContainer.onboardingTwinsWelcomeBg.root.show()
mainBinding.onboardingTopContainer.onboardingTwinsWelcomeBg.root.show()
pbBinding.pbState.hide()
tvHeader.setText(R.string.twins_onboarding_subtitle)
tvBody.text = getString(
R.string.twins_onboarding_description_format,
state.cardNumber?.pairIndexNumber()
state.cardNumber?.pairIndexNumber(),
)
btnMainAction.setText(R.string.common_continue)
btnMainAction.setOnClickListener { mainAction() }
}
private fun setupWarningState(state: TwinCardsState) = with(binding.onboardingActionContainer) {
twinsWidget.toWelcome(false) { startPostponedEnterTransition() }
private fun setupWarningState(state: TwinCardsState) = with(mainBinding.onboardingActionContainer) {
twinsWidget.toWelcome(false)
binding.onboardingTopContainer.onboardingTwinsWelcomeBg.root.hide()
mainBinding.onboardingTopContainer.onboardingTwinsWelcomeBg.root.hide()
pbBinding.pbState.hide()
chbUnderstand.show()
@ -203,198 +203,185 @@ class TwinsCardsFragment : BaseOnboardingFragment<TwinCardsState>() {
}
}
private fun setupCreateFirstWalletState(state: TwinCardsState) =
with(binding.onboardingActionContainer) {
binding.onboardingTopContainer.onboardingTwinsWelcomeBg.root.hide()
binding.onboardingTopContainer.onboardingTwinsWelcomeBg.bgCircleLarge.hide()
binding.onboardingTopContainer.onboardingTwinsWelcomeBg.bgCircleMedium.hide()
binding.onboardingTopContainer.onboardingTwinsWelcomeBg.bgCircleMin.hide()
chbUnderstand.hide()
pbBinding.pbState.show()
private fun setupCreateFirstWalletState(state: TwinCardsState) = with(mainBinding.onboardingActionContainer) {
mainBinding.onboardingTopContainer.onboardingTwinsWelcomeBg.root.hide()
mainBinding.onboardingTopContainer.onboardingTwinsWelcomeBg.bgCircleLarge.hide()
mainBinding.onboardingTopContainer.onboardingTwinsWelcomeBg.bgCircleMedium.hide()
mainBinding.onboardingTopContainer.onboardingTwinsWelcomeBg.bgCircleMin.hide()
chbUnderstand.hide()
pbBinding.pbState.show()
binding.onboardingTopContainer.onboardingMainContainer.beginDelayedTransition()
mainBinding.onboardingTopContainer.onboardingWalletContainer.beginDelayedTransition()
when (previousStep) {
TwinCardsStep.None -> {
twinsWidget.toLeapfrog(false) {
when (state.cardNumber) {
TwinCardNumber.First -> startPostponedEnterTransition()
TwinCardNumber.Second -> {
switchToCard(
state.cardNumber,
false
) { startPostponedEnterTransition() }
}
}
}
}
TwinCardsStep.Welcome, TwinCardsStep.Warning -> {
twinsWidget.toLeapfrog(onEnd = { switchToCard(state.cardNumber) })
}
}
val twinIndexNumber = state.cardNumber?.indexNumber()
tvHeader.text = getString(R.string.twins_recreate_title_format, twinIndexNumber)
tvBody.setText(R.string.onboarding_twins_interrupt_warning)
btnMainAction.text = getString(R.string.twins_recreate_button_format, twinIndexNumber)
btnMainAction.setOnClickListener {
store.dispatch(
TwinCardsAction.Wallet.LaunchFirstStep(
Message(getString(R.string.twins_recreate_title_format, twinIndexNumber)),
AndroidAssetReader(requireContext()),
)
)
}
btnAlternativeAction.isVisible = false
btnAlternativeAction.isClickable = false
}
private fun setupCreateSecondWalletState(state: TwinCardsState) =
with(binding.onboardingActionContainer) {
switchToCard(state.cardNumber?.pairNumber())
val twinPairIndexNumber = state.cardNumber?.pairIndexNumber()
tvHeader.text = getString(R.string.twins_recreate_title_format, twinPairIndexNumber)
tvBody.setText(R.string.onboarding_twins_interrupt_warning)
btnMainAction.text =
getString(R.string.twins_recreate_button_format, twinPairIndexNumber)
btnMainAction.setOnClickListener {
store.dispatch(
TwinCardsAction.Wallet.LaunchSecondStep(
Message(
getString(
R.string.twins_recreate_title_format,
twinPairIndexNumber
)
),
Message(getString(R.string.twins_recreate_title_preparing)),
Message(getString(R.string.twins_recreate_title_creating_wallet)),
)
)
}
}
private fun setupCreateThirdWalletState(state: TwinCardsState) =
with(binding.onboardingActionContainer) {
switchToCard(state.cardNumber)
val twinIndexNumber = state.cardNumber?.indexNumber()
tvHeader.text = getString(R.string.twins_recreate_title_format, twinIndexNumber)
tvBody.setText(R.string.onboarding_twins_interrupt_warning)
btnMainAction.text = getString(R.string.twins_recreate_button_format, twinIndexNumber)
btnMainAction.setOnClickListener {
store.dispatch(
TwinCardsAction.Wallet.LaunchThirdStep(
Message(getString(R.string.twins_recreate_title_format, twinIndexNumber))
)
)
}
}
private fun setupTopUpWalletState(state: TwinCardsState) =
with(binding.onboardingActionContainer) {
when (previousStep) {
TwinCardsStep.None -> {
when (previousStep) {
TwinCardsStep.None -> {
twinsWidget.toLeapfrog(false) {
when (state.cardNumber) {
TwinCardNumber.First -> {
twinsWidget.leapfrogWidget.unfold(false) {
twinsWidget.toActivate(false) {
startPostponedEnterTransition()
}
}
}
TwinCardNumber.First -> {}
TwinCardNumber.Second -> {
switchToCard(state.cardNumber, false) {
twinsWidget.toActivate(false)
startPostponedEnterTransition()
}
switchToCard(
state.cardNumber,
false,
)
}
}
}
TwinCardsStep.CreateThirdWallet -> {
twinsWidget.toActivate()
}
}
if (state.isBuyAllowed) {
btnMainAction.setText(R.string.onboarding_top_up_button_but_crypto)
btnMainAction.setOnClickListener {
store.dispatch(TwinCardsAction.TopUp)
}
btnAlternativeAction.isVisible = true
btnAlternativeAction.setText(R.string.onboarding_top_up_button_show_wallet_address)
btnAlternativeAction.setOnClickListener {
store.dispatch(TwinCardsAction.ShowAddressInfoDialog)
}
} else {
btnMainAction.setText(R.string.onboarding_button_receive_crypto)
btnMainAction.setOnClickListener {
store.dispatch(TwinCardsAction.ShowAddressInfoDialog)
}
btnAlternativeAction.isVisible = false
TwinCardsStep.Welcome, TwinCardsStep.Warning -> {
twinsWidget.toLeapfrog(onEnd = { switchToCard(state.cardNumber) })
}
tvHeader.setText(R.string.onboarding_top_up_header)
tvBody.setText(R.string.onboarding_top_up_body)
btnRefreshBalanceWidget.changeState(state.walletBalance.state)
if (btnRefreshBalanceWidget.isShowing != true) {
btnRefreshBalanceWidget.mainView.setOnClickListener {
store.dispatch(TwinCardsAction.Balance.Update)
}
}
binding.onboardingTopContainer.imvCardBackground.setBackgroundDrawable(
requireContext().getDrawableCompat(R.drawable.shape_rectangle_rounded_8)
)
updateConstraints(state.currentStep, R.layout.lp_onboarding_topup_wallet_twins)
}
private fun setupDoneState(state: TwinCardsState) =
with(binding.onboardingActionContainer) {
btnMainAction.setText(R.string.onboarding_done_button_continue)
val twinIndexNumber = state.cardNumber?.indexNumber()
tvHeader.text = getString(R.string.twins_recreate_title_format, twinIndexNumber)
tvBody.setText(R.string.onboarding_twins_interrupt_warning)
btnMainAction.text = getString(R.string.twins_recreate_button_format, twinIndexNumber)
btnMainAction.setOnClickListener {
store.dispatch(
TwinCardsAction.Wallet.LaunchFirstStep(
Message(getString(R.string.twins_recreate_title_format, twinIndexNumber)),
AndroidAssetReader(requireContext()),
),
)
}
btnAlternativeAction.isVisible = false
btnAlternativeAction.isClickable = false
}
private fun setupCreateSecondWalletState(state: TwinCardsState) = with(mainBinding.onboardingActionContainer) {
switchToCard(state.cardNumber?.pairNumber())
val twinPairIndexNumber = state.cardNumber?.pairIndexNumber()
tvHeader.text = getString(R.string.twins_recreate_title_format, twinPairIndexNumber)
tvBody.setText(R.string.onboarding_twins_interrupt_warning)
btnMainAction.text =
getString(R.string.twins_recreate_button_format, twinPairIndexNumber)
btnMainAction.setOnClickListener {
store.dispatch(
TwinCardsAction.Wallet.LaunchSecondStep(
Message(
getString(
R.string.twins_recreate_title_format,
twinPairIndexNumber,
),
),
Message(getString(R.string.twins_recreate_title_preparing)),
Message(getString(R.string.twins_recreate_title_creating_wallet)),
),
)
}
}
private fun setupCreateThirdWalletState(state: TwinCardsState) = with(mainBinding.onboardingActionContainer) {
switchToCard(state.cardNumber)
val twinIndexNumber = state.cardNumber?.indexNumber()
tvHeader.text = getString(R.string.twins_recreate_title_format, twinIndexNumber)
tvBody.setText(R.string.onboarding_twins_interrupt_warning)
btnMainAction.text = getString(R.string.twins_recreate_button_format, twinIndexNumber)
btnMainAction.setOnClickListener {
store.dispatch(
TwinCardsAction.Wallet.LaunchThirdStep(
Message(getString(R.string.twins_recreate_title_format, twinIndexNumber)),
),
)
}
}
private fun setupTopUpWalletState(state: TwinCardsState) = with(mainBinding.onboardingActionContainer) {
when (previousStep) {
TwinCardsStep.None -> {
when (state.cardNumber) {
TwinCardNumber.First -> {
twinsWidget.leapfrogWidget.unfold(false) {
twinsWidget.toActivate(false)
}
}
TwinCardNumber.Second -> {
switchToCard(state.cardNumber, false) {
twinsWidget.toActivate(false)
}
}
}
}
TwinCardsStep.CreateThirdWallet -> {
twinsWidget.toActivate()
}
}
if (state.isBuyAllowed) {
btnMainAction.setText(R.string.onboarding_top_up_button_but_crypto)
btnMainAction.setOnClickListener {
store.dispatch(TwinCardsAction.Confetti.Hide)
store.dispatch(TwinCardsAction.Done)
store.dispatch(TwinCardsAction.TopUp)
}
btnAlternativeAction.isVisible = true
btnAlternativeAction.setText(R.string.onboarding_top_up_button_show_wallet_address)
btnAlternativeAction.setOnClickListener {
store.dispatch(TwinCardsAction.ShowAddressInfoDialog)
}
} else {
btnMainAction.setText(R.string.onboarding_button_receive_crypto)
btnMainAction.setOnClickListener {
store.dispatch(TwinCardsAction.ShowAddressInfoDialog)
}
btnAlternativeAction.isVisible = false
btnAlternativeAction.isClickable = false
tvHeader.setText(R.string.onboarding_done_header)
tvBody.setText(R.string.onboarding_done_body)
val layout = when (state.mode) {
CreateTwinWalletMode.CreateWallet -> R.layout.lp_onboarding_done_activation_twins
CreateTwinWalletMode.RecreateWallet -> R.layout.lp_onboarding_done
}
updateConstraints(state.currentStep, layout)
}
tvHeader.setText(R.string.onboarding_top_up_header)
tvBody.setText(R.string.onboarding_top_up_body)
btnRefreshBalanceWidget.changeState(state.walletBalance.state)
if (btnRefreshBalanceWidget.isShowing != true) {
btnRefreshBalanceWidget.mainView.setOnClickListener {
store.dispatch(TwinCardsAction.Balance.Update)
}
}
mainBinding.onboardingTopContainer.imvCardBackground.setBackgroundDrawable(
requireContext().getDrawableCompat(R.drawable.shape_rectangle_rounded_8),
)
updateConstraints(state.currentStep, R.layout.lp_onboarding_topup_wallet_twins)
}
private fun setupDoneState(state: TwinCardsState) = with(mainBinding.onboardingActionContainer) {
btnMainAction.setText(R.string.onboarding_done_button_continue)
btnMainAction.setOnClickListener {
store.dispatch(TwinCardsAction.Confetti.Hide)
store.dispatch(TwinCardsAction.Done)
}
btnAlternativeAction.isVisible = false
btnAlternativeAction.isClickable = false
tvHeader.setText(R.string.onboarding_done_header)
tvBody.setText(R.string.onboarding_done_body)
val layout = when (state.mode) {
CreateTwinWalletMode.CreateWallet -> R.layout.lp_onboarding_done_activation_twins
CreateTwinWalletMode.RecreateWallet -> R.layout.lp_onboarding_done
}
updateConstraints(state.currentStep, layout)
}
private fun updateConstraints(currentStep: TwinCardsStep, @LayoutRes layoutId: Int) {
if (this.previousStep == currentStep) return
with(binding.onboardingTopContainer) {
with(mainBinding.onboardingTopContainer) {
val constraintSet = ConstraintSet()
constraintSet.clone(requireContext(), layoutId)
constraintSet.applyTo(onboardingMainContainer)
constraintSet.applyTo(onboardingWalletContainer)
val transition = InternalNoteLayoutTransition()
transition.interpolator = OvershootInterpolator()
TransitionManager.beginDelayedTransition(onboardingMainContainer, transition)
TransitionManager.beginDelayedTransition(onboardingWalletContainer, transition)
}
}
private fun switchToCard(
cardNumber: TwinCardNumber?,
animate: Boolean = true,
onEnd: VoidCallback = {}
) {
private fun switchToCard(cardNumber: TwinCardNumber?, animate: Boolean = true, onEnd: VoidCallback = {}) {
val position = cardNumber?.number ?: return
if (twinsWidget.leapfrogWidget.getViewPositionByIndex(1) != position - 1) {
@ -405,12 +392,14 @@ class TwinsCardsFragment : BaseOnboardingFragment<TwinCardsState>() {
}
override fun handleOnBackPressed() {
store.dispatch(TwinCardsAction.Wallet.HandleOnBackPressed { should, popAction ->
store.dispatch(TwinCardsAction.Confetti.Hide)
showConfetti(false)
if (should) switchToCard(TwinCardNumber.First, true, popAction)
else popAction()
})
store.dispatch(
TwinCardsAction.Wallet.HandleOnBackPressed { should, popAction ->
store.dispatch(TwinCardsAction.Confetti.Hide)
showConfetti(false)
if (should) switchToCard(TwinCardNumber.First, true, popAction)
else popAction()
},
)
}
}

View file

@ -5,12 +5,14 @@ import org.rekotlin.Action
sealed class OnboardingWalletAction : Action {
object Init : OnboardingWalletAction()
object StartSaltPayCardActivation : OnboardingWalletAction()
object GetToCreateWalletStep : OnboardingWalletAction()
object GetToSaltPayStep : OnboardingWalletAction()
object CreateWallet : OnboardingWalletAction()
object Done : OnboardingWalletAction()
object FinishOnboarding : OnboardingWalletAction()
object ProceedBackup : OnboardingWalletAction()
object ResumeBackup : OnboardingWalletAction()
object LoadArtwork : OnboardingWalletAction()
class SetArtworkUrl(val artworkUrl: String?) : OnboardingWalletAction()
@ -28,6 +30,11 @@ sealed class BackupAction : Action {
object StartAddingPrimaryCard : BackupAction()
object ScanPrimaryCard : BackupAction()
/**
* Check for unfinished backup of standard Wallet cards.
* For SaltPay cards unfinished backup resumed after scanning the card on HomeScreen through Onboarding.Start.
* See more Onboarding.Start, CheckForUnfinishedBackup, StartForUnfinishedBackup
*/
object CheckForUnfinishedBackup : BackupAction()
object StartAddingBackupCards : BackupAction()
@ -53,15 +60,13 @@ sealed class BackupAction : Action {
data class SaveAccessCodeConfirmation(val accessCodeConfirmation: String) : BackupAction()
object OnAccessCodeDialogClosed : BackupAction()
data class PrepareToWriteBackupCard(val cardNumber: Int) : BackupAction()
data class WriteBackupCard(val cardNumber: Int) : BackupAction()
object PrepareToWritePrimaryCard : BackupAction()
object WritePrimaryCard : BackupAction()
data class PrepareToWriteBackupCard(val cardNumber: Int) : BackupAction()
data class WriteBackupCard(val cardNumber: Int) : BackupAction()
object FinishBackup : BackupAction()
object DiscardBackup : BackupAction()
object DiscardSavedBackup : BackupAction()
object ResumeBackup : BackupAction()
object ResumeFoundUnfinishedBackup : BackupAction()
}

View file

@ -3,10 +3,13 @@ package com.tangem.tap.features.onboarding.products.wallet.redux
import com.tangem.blockchain.common.Blockchain
import com.tangem.common.CompletionResult
import com.tangem.common.card.Card
import com.tangem.common.extensions.ifNotNull
import com.tangem.domain.common.SaltPayWorkaround
import com.tangem.domain.common.ScanResponse
import com.tangem.domain.common.extensions.withMainContext
import com.tangem.operations.backup.BackupService
import com.tangem.tap.*
import com.tangem.tap.backupService
import com.tangem.tap.common.extensions.dispatchDialogShow
import com.tangem.tap.common.extensions.dispatchOnMain
import com.tangem.tap.common.redux.AppState
import com.tangem.tap.common.redux.global.GlobalAction
@ -16,10 +19,19 @@ import com.tangem.tap.domain.extensions.hasWallets
import com.tangem.tap.domain.tokens.models.BlockchainNetwork
import com.tangem.tap.features.demo.DemoHelper
import com.tangem.tap.features.home.redux.HomeAction
import com.tangem.tap.features.onboarding.products.wallet.saltPay.dialog.SaltPayDialog
import com.tangem.tap.features.onboarding.products.wallet.saltPay.redux.OnboardingSaltPayAction
import com.tangem.tap.features.onboarding.products.wallet.saltPay.redux.OnboardingSaltPayState
import com.tangem.tap.features.wallet.redux.Artwork
import com.tangem.tap.features.wallet.redux.WalletAction
import com.tangem.tap.preferencesStorage
import com.tangem.tap.scope
import com.tangem.tap.store
import com.tangem.tap.tangemSdk
import com.tangem.tap.tangemSdkManager
import kotlinx.coroutines.launch
import org.rekotlin.Action
import org.rekotlin.DispatchFunction
import org.rekotlin.Middleware
class OnboardingWalletMiddleware {
@ -31,35 +43,59 @@ class OnboardingWalletMiddleware {
private val onboardingWalletMiddleware: Middleware<AppState> = { dispatch, state ->
{ next ->
{ action ->
handleWalletAction(action)
handleWalletAction(action, state, dispatch)
next(action)
}
}
}
private fun handleWalletAction(action: Action) {
private fun handleWalletAction(action: Action, state: () -> AppState?, dispatch: DispatchFunction) {
if (action !is OnboardingWalletAction) return
val globalState = store.state.globalState
val onboardingManager = globalState.onboardingState.onboardingManager
val scanResponse = onboardingManager?.scanResponse
val card = scanResponse?.card
val walletState = store.state.onboardingWalletState
val onboardingWalletState = store.state.onboardingWalletState
when (action) {
OnboardingWalletAction.Init -> {
val action = when {
when {
card == null -> {
OnboardingWalletAction.ProceedBackup
// it's possible when found unfinished backup for standard Wallet cards
store.dispatch(OnboardingWalletAction.ResumeBackup)
}
card.hasWallets() && card.backupStatus == Card.BackupStatus.NoBackup -> {
store.dispatch(OnboardingWalletAction.ResumeBackup)
}
card.hasWallets() && card.backupStatus?.isActive == true -> {
when {
// check for unfinished backup for saltPay cards. See more
scanResponse.isSaltPay() && backupService.hasIncompletedBackup -> {
store.dispatch(OnboardingWalletAction.ResumeBackup)
}
scanResponse.isSaltPay() -> {
store.dispatch(OnboardingWalletAction.GetToSaltPayStep)
store.dispatch(BackupAction.FinishBackup)
}
else -> store.dispatch(BackupAction.FinishBackup)
}
}
else -> {
store.dispatch(OnboardingWalletAction.GetToCreateWalletStep)
}
}
}
is OnboardingWalletAction.StartSaltPayCardActivation -> {
// we need to do this because the standard activation happens in the OnboardingWalletAction.CreateWallet
// step which is omitted for SaltPay cards
if (onboardingWalletState.isSaltPay) {
ifNotNull(onboardingManager, scanResponse?.card?.cardId) { manager, cardId ->
manager.activationStarted(cardId)
}
card.hasWallets() && card.backupStatus == Card.BackupStatus.NoBackup ->
OnboardingWalletAction.ProceedBackup
card.hasWallets() && card.backupStatus?.isActive == true ->
BackupAction.FinishBackup
else -> OnboardingWalletAction.GetToCreateWalletStep
}
store.dispatch(action)
}
is OnboardingWalletAction.LoadArtwork -> {
scope.launch {
@ -80,25 +116,24 @@ private fun handleWalletAction(action: Action) {
when (result) {
is CompletionResult.Success -> {
//here we must use updated scanResponse after createWallet & derivation
val updatedResponse =
globalState.onboardingState.onboardingManager.scanResponse.copy(
card = result.data.card,
derivedKeys = result.data.derivedKeys,
primaryCard = result.data.primaryCard
)
val updatedResponse = globalState.onboardingState.onboardingManager.scanResponse.copy(
card = result.data.card,
derivedKeys = result.data.derivedKeys,
primaryCard = result.data.primaryCard,
)
onboardingManager.scanResponse = updatedResponse
val blockchainNetworks = listOf(
BlockchainNetwork(Blockchain.Bitcoin, result.data.card),
BlockchainNetwork(Blockchain.Ethereum, result.data.card)
BlockchainNetwork(Blockchain.Ethereum, result.data.card),
)
store.dispatchOnMain(
store.dispatch(
WalletAction.MultiWallet.SaveCurrencies(
blockchainNetworks = blockchainNetworks,
card = result.data.card,
),
)
onboardingManager.activationStarted(updatedResponse.card.cardId)
store.dispatch(OnboardingWalletAction.ProceedBackup)
store.dispatch(OnboardingWalletAction.ResumeBackup)
}
is CompletionResult.Failure -> {
// do nothing
@ -110,6 +145,7 @@ private fun handleWalletAction(action: Action) {
OnboardingWalletAction.FinishOnboarding -> {
store.dispatch(GlobalAction.Onboarding.Stop)
//TODO: SaltPay: scanResponse
if (scanResponse == null) {
store.dispatch(NavigationAction.PopBackTo())
store.dispatch(HomeAction.ReadCard)
@ -120,15 +156,19 @@ private fun handleWalletAction(action: Action) {
store.dispatchOnMain(NavigationAction.NavigateTo(AppScreen.Wallet))
}
}
OnboardingWalletAction.ProceedBackup -> {
is OnboardingWalletAction.ResumeBackup -> {
val newAction = when (val backupState = backupService.currentState) {
BackupService.State.FinalizingPrimaryCard -> BackupAction.PrepareToWritePrimaryCard
is BackupService.State.FinalizingBackupCard -> BackupAction.PrepareToWriteBackupCard(backupState.index)
else -> {
if (walletState.backupState.backupStep == BackupStep.InitBackup ||
walletState.backupState.backupStep == BackupStep.Finished
if (onboardingWalletState.backupState.backupStep == BackupStep.InitBackup ||
onboardingWalletState.backupState.backupStep == BackupStep.Finished
) {
BackupAction.IntroduceBackup
if (onboardingWalletState.isSaltPay) {
BackupAction.StartBackup
} else {
BackupAction.IntroduceBackup
}
} else {
null
}
@ -137,16 +177,9 @@ private fun handleWalletAction(action: Action) {
newAction?.let { store.dispatch(it) }
}
OnboardingWalletAction.OnBackPressed -> {
when (walletState.backupState.backupStep) {
BackupStep.InitBackup, BackupStep.Finished -> store.dispatch(NavigationAction.PopBackTo())
BackupStep.ScanOriginCard, BackupStep.AddBackupCards, BackupStep.EnterAccessCode,
BackupStep.ReenterAccessCode, BackupStep.SetAccessCode, BackupStep.WritePrimaryCard,
-> {
store.dispatch(BackupAction.DiscardBackup)
store.dispatch(NavigationAction.PopBackTo())
}
is BackupStep.WriteBackupCard ->
store.dispatch(GlobalAction.ShowDialog(BackupDialog.BackupInProgress))
when {
onboardingWalletState.isSaltPay -> handleOnBackPressedSaltPay(onboardingWalletState)
else -> handleOnBackPressed(onboardingWalletState)
}
}
}
@ -159,7 +192,7 @@ private fun updateScanResponseAfterBackup(
val cardsCount = backupState.backupCardsNumber
scanResponse.card.copy(
backupStatus = Card.BackupStatus.Active(cardCount = cardsCount),
isAccessCodeSet = true
isAccessCodeSet = true,
)
} else {
scanResponse.card
@ -180,13 +213,11 @@ class BackupMiddleware {
private fun handleBackupAction(appState: () -> AppState?, action: BackupAction) {
if (DemoHelper.tryHandle(appState, action)) return
val globalState = appState()?.globalState ?: return
val onboardingWalletState = appState()?.onboardingWalletState ?: return
val backupState = store.state.onboardingWalletState.backupState
val globalState = store.state.globalState
val onboardingManager = globalState.onboardingState.onboardingManager
val scanResponse = onboardingManager?.scanResponse
val backupState = onboardingWalletState.backupState
val scanResponse = globalState.onboardingState.onboardingManager?.scanResponse
val card = scanResponse?.card
when (action) {
@ -212,32 +243,33 @@ private fun handleBackupAction(appState: () -> AppState?, action: BackupAction)
}
}
is BackupAction.AddBackupCard -> {
onboardingWalletState.backupCardIdFilter?.let {
backupService.skipCompatibilityChecks = true
tangemSdk.config.filter.cardIdFilter = it
}
backupService.addBackupCard { result ->
backupService.skipCompatibilityChecks = false
tangemSdk.config.filter.cardIdFilter = null
when (result) {
is CompletionResult.Success -> {
store.dispatchOnMain(BackupAction.AddBackupCard.Success)
}
is CompletionResult.Failure -> {
}
}
}
}
is BackupAction.FinishAddingBackupCards -> {
if (backupService.addedBackupCardsCount == 1) {
store.dispatchOnMain(GlobalAction.ShowDialog(BackupDialog.AddMoreBackupCards))
}
if (backupService.addedBackupCardsCount == 2) {
if (backupService.addedBackupCardsCount == backupState.maxBackupCards) {
store.dispatchOnMain(BackupAction.ShowAccessCodeInfoScreen)
} else {
store.dispatchOnMain(GlobalAction.ShowDialog(BackupDialog.AddMoreBackupCards))
}
}
is BackupAction.CheckAccessCode -> {
if (action.accessCode.length < 4) {
store.dispatch(
BackupAction.SetAccessCodeError(
AccessCodeError.CodeTooShort
)
)
store.dispatch(BackupAction.SetAccessCodeError(AccessCodeError.CodeTooShort))
} else {
store.dispatch(BackupAction.SetAccessCodeError(null))
store.dispatch(BackupAction.SaveFirstAccessCode(action.accessCode))
@ -247,22 +279,17 @@ private fun handleBackupAction(appState: () -> AppState?, action: BackupAction)
if (action.accessCodeConfirmation == backupState.accessCode) {
store.dispatch(BackupAction.SetAccessCodeError(null))
backupService.setAccessCode(action.accessCodeConfirmation)
store.dispatch(OnboardingSaltPayAction.SetAccessCode(action.accessCodeConfirmation))
store.dispatch(BackupAction.PrepareToWritePrimaryCard)
} else {
store.dispatch(
BackupAction.SetAccessCodeError(
AccessCodeError.CodesDoNotMatch
)
)
store.dispatch(BackupAction.SetAccessCodeError(AccessCodeError.CodesDoNotMatch))
}
}
is BackupAction.WritePrimaryCard -> {
backupService.proceedBackup { result ->
when (result) {
is CompletionResult.Success -> {
store.dispatchOnMain(
BackupAction.PrepareToWriteBackupCard(1)
)
store.dispatchOnMain(BackupAction.PrepareToWriteBackupCard(1))
}
is CompletionResult.Failure -> {
}
@ -274,15 +301,13 @@ private fun handleBackupAction(appState: () -> AppState?, action: BackupAction)
when (result) {
is CompletionResult.Success -> {
if (backupService.currentState == BackupService.State.Finished) {
initSaltPayOnBackupFinishedIfNeeded(scanResponse, onboardingWalletState)
store.dispatchOnMain(BackupAction.FinishBackup)
} else {
store.dispatchOnMain(
BackupAction.PrepareToWriteBackupCard(action.cardNumber + 1)
)
store.dispatchOnMain(BackupAction.PrepareToWriteBackupCard(action.cardNumber + 1))
}
}
is CompletionResult.Failure -> {
}
}
}
@ -304,17 +329,68 @@ private fun handleBackupAction(appState: () -> AppState?, action: BackupAction)
backupService.discardSavedBackup()
}
is BackupAction.CheckForUnfinishedBackup -> {
if (backupService.hasIncompletedBackup) {
if (backupService.hasIncompletedBackup && !backupService.primaryCardIsSaltPay()) {
store.dispatch(GlobalAction.ShowDialog(BackupDialog.UnfinishedBackupFound))
}
}
is BackupAction.ResumeBackup -> {
store.dispatch(GlobalAction.Onboarding.Start(null, true))
store.dispatch(OnboardingWalletAction.ProceedBackup)
is BackupAction.ResumeFoundUnfinishedBackup -> {
store.dispatch(GlobalAction.Onboarding.StartForUnfinishedBackup)
store.dispatch(NavigationAction.NavigateTo(AppScreen.OnboardingWallet))
}
is BackupAction.DismissBackup -> {
if (onboardingWalletState.isSaltPay) throw UnsupportedOperationException()
store.dispatch(BackupAction.FinishBackup)
}
}
}
private fun BackupService.primaryCardIsSaltPay(): Boolean {
return primaryCardId?.slice(0..3)?.let {
SaltPayWorkaround.isVisaBatchId(it)
} ?: false
}
/**
* SaltPay state maybe not initialized if backup resumed from ResumeFoundUnfinishedBackup action
*/
private fun initSaltPayOnBackupFinishedIfNeeded(
scanResponse: ScanResponse?,
onboardingWalletState: OnboardingWalletState,
) {
if (onboardingWalletState.isSaltPay && onboardingWalletState.onboardingSaltPayState == null) {
//TODO: SaltPay: scanResponse
if (scanResponse == null) throw IllegalArgumentException()
val (manager, config) = OnboardingSaltPayState.initDependency(scanResponse)
store.dispatchOnMain(OnboardingSaltPayAction.Init.SetDependencies(manager, config))
store.dispatchOnMain(OnboardingSaltPayAction.Update)
}
}
private fun handleOnBackPressedSaltPay(state: OnboardingWalletState) {
when (state.backupState.backupStep) {
BackupStep.Finished -> {
store.dispatchDialogShow(
SaltPayDialog.Activation.TryToInterrupt(
onOk = { store.dispatch(NavigationAction.PopBackTo()) },
onCancel = { /* do nothing */ },
),
)
}
else -> handleOnBackPressed(state)
}
}
private fun handleOnBackPressed(state: OnboardingWalletState) {
when (state.backupState.backupStep) {
BackupStep.InitBackup, BackupStep.Finished -> store.dispatch(NavigationAction.PopBackTo())
BackupStep.ScanOriginCard, BackupStep.AddBackupCards, BackupStep.EnterAccessCode,
BackupStep.ReenterAccessCode, BackupStep.SetAccessCode, BackupStep.WritePrimaryCard,
-> {
store.dispatch(BackupAction.DiscardBackup)
store.dispatch(NavigationAction.PopBackTo())
}
is BackupStep.WriteBackupCard ->
store.dispatch(GlobalAction.ShowDialog(BackupDialog.BackupInProgress))
}
}

View file

@ -3,12 +3,13 @@ package com.tangem.tap.features.onboarding.products.wallet.redux
import com.tangem.tap.backupService
import com.tangem.tap.common.redux.AppState
import com.tangem.tap.common.redux.global.GlobalAction
import com.tangem.tap.features.onboarding.products.wallet.saltPay.redux.OnboardingSaltPayAction
import com.tangem.tap.features.onboarding.products.wallet.saltPay.redux.OnboardingSaltPayReducer
import org.rekotlin.Action
class OnboardingWalletReducer {
companion object {
fun reduce(action: Action, state: AppState): OnboardingWalletState =
internalReduce(action, state)
fun reduce(action: Action, state: AppState): OnboardingWalletState = internalReduce(action, state)
}
}
@ -16,13 +17,17 @@ private fun internalReduce(action: Action, appState: AppState): OnboardingWallet
val state = appState.onboardingWalletState
return when (action) {
is BackupAction -> state.copy(backupState = BackupReducer.reduce(action, state.backupState))
is GlobalAction.Onboarding.Start -> OnboardingWalletState(
backupState = state.backupState.copy(canSkipBackup = action.fromHomeScreen)
is GlobalAction.Onboarding -> ReducerForGlobalAction.reduce(action, state)
is OnboardingSaltPayAction -> OnboardingSaltPayReducer.reduce(action, state)
is BackupAction -> state.copy(backupState = BackupReducer.reduce(action, state.backupState, state.isSaltPay))
is OnboardingWalletAction.GetToCreateWalletStep -> OnboardingWalletState(
step = OnboardingWalletStep.CreateWallet,
)
is OnboardingWalletAction.GetToCreateWalletStep ->
OnboardingWalletState(step = OnboardingWalletStep.CreateWallet)
is OnboardingWalletAction.ProceedBackup -> state.copy(step = OnboardingWalletStep.Backup)
is OnboardingWalletAction.GetToSaltPayStep -> state.copy(
step = OnboardingWalletStep.SaltPay,
)
is OnboardingWalletAction.ResumeBackup -> state.copy(step = OnboardingWalletStep.Backup)
is OnboardingWalletAction.SetArtworkUrl -> {
state.copy(cardArtworkUrl = action.artworkUrl)
}
@ -31,17 +36,47 @@ private fun internalReduce(action: Action, appState: AppState): OnboardingWallet
}
}
private class ReducerForGlobalAction {
companion object {
fun reduce(action: GlobalAction.Onboarding, state: OnboardingWalletState): OnboardingWalletState {
return when (action) {
is GlobalAction.Onboarding.Start -> {
OnboardingWalletState(
backupState = state.backupState.copy(
maxBackupCards = if (action.scanResponse.isSaltPay()) 1 else 2,
canSkipBackup = if (action.scanResponse.isSaltPay()) false else action.canSkipBackup,
),
isSaltPay = action.scanResponse.isSaltPay(),
)
}
is GlobalAction.Onboarding.StartForUnfinishedBackup -> {
OnboardingWalletState(
backupState = state.backupState.copy(
maxBackupCards = 2,
canSkipBackup = false,
),
isSaltPay = false,
)
}
else -> state
}
}
}
}
class BackupReducer {
private class BackupReducer {
companion object {
fun reduce(
action: BackupAction, state: BackupState,
action: BackupAction,
state: BackupState,
isSaltPay: Boolean,
): BackupState {
return when (action) {
is BackupAction.IntroduceBackup -> BackupState(
backupStep = BackupStep.InitBackup,
canSkipBackup = state.canSkipBackup,
maxBackupCards = if (isSaltPay) 1 else 2,
canSkipBackup = !isSaltPay,
)
BackupAction.StartAddingPrimaryCard -> state.copy(backupStep = BackupStep.ScanOriginCard)
BackupAction.StartAddingBackupCards -> {
@ -50,9 +85,6 @@ class BackupReducer {
BackupAction.AddBackupCard.Success -> {
state.copy(backupCardsNumber = state.backupCardsNumber + 1)
}
BackupAction.FinishAddingBackupCards -> {
state
}
BackupAction.ShowAccessCodeInfoScreen -> {
state.copy(backupStep = BackupStep.SetAccessCode)
}
@ -62,13 +94,11 @@ class BackupReducer {
is BackupAction.SaveFirstAccessCode -> {
state.copy(
backupStep = BackupStep.ReenterAccessCode,
accessCode = action.accessCode
accessCode = action.accessCode,
)
}
is BackupAction.SetAccessCodeError -> {
state.copy(
accessCodeError = action.error
)
state.copy(accessCodeError = action.error)
}
is BackupAction.PrepareToWritePrimaryCard -> {
if (state.primaryCardId == null) {
@ -76,12 +106,10 @@ class BackupReducer {
primaryCardId = backupService.primaryCardId,
backupCardIds = backupService.backupCardIds,
backupCardsNumber = backupService.backupCardIds.size,
backupStep = BackupStep.WritePrimaryCard
backupStep = BackupStep.WritePrimaryCard,
)
} else {
state.copy(
backupStep = BackupStep.WritePrimaryCard
)
state.copy(backupStep = BackupStep.WritePrimaryCard)
}
}
is BackupAction.PrepareToWriteBackupCard -> {
@ -90,43 +118,21 @@ class BackupReducer {
primaryCardId = backupService.primaryCardId,
backupCardIds = backupService.backupCardIds,
backupCardsNumber = backupService.backupCardIds.size,
backupStep = BackupStep.WriteBackupCard(action.cardNumber)
backupStep = BackupStep.WriteBackupCard(action.cardNumber),
)
} else {
state.copy(
backupStep = BackupStep.WriteBackupCard(action.cardNumber)
)
state.copy(backupStep = BackupStep.WriteBackupCard(action.cardNumber))
}
}
is BackupAction.FinishBackup -> {
state.copy(
backupStep = BackupStep.Finished
)
state.copy(backupStep = BackupStep.Finished)
}
BackupAction.OnAccessCodeDialogClosed -> {
state.copy(backupStep = BackupStep.AddBackupCards)
}
BackupAction.DiscardBackup -> BackupState()
BackupAction.WritePrimaryCard -> state
is BackupAction.WriteBackupCard -> state
is BackupAction.SaveAccessCodeConfirmation -> state
BackupAction.ScanPrimaryCard -> state
BackupAction.ShowEnterAccessCodeScreen -> state
BackupAction.ShowReenterAccessCodeScreen -> state
BackupAction.AddBackupCard -> state
BackupAction.DismissBackup -> state
is BackupAction.LoadBackupCardArtwork -> state
is BackupAction.CheckAccessCode -> state
BackupAction.DetermineBackupStep -> state
BackupAction.CheckForUnfinishedBackup -> state
BackupAction.ResumeBackup -> state
BackupAction.DiscardSavedBackup -> state
BackupAction.StartBackup -> state
else -> state
}
}
}
}

View file

@ -1,7 +1,11 @@
package com.tangem.tap.features.onboarding.products.wallet.redux
import android.graphics.Bitmap
import com.tangem.common.CardFilter
import com.tangem.domain.common.SaltPayWorkaround
import com.tangem.tap.common.redux.StateDialog
import com.tangem.tap.features.onboarding.products.wallet.saltPay.redux.OnboardingSaltPayState
import com.tangem.tap.features.onboarding.products.wallet.saltPay.redux.SaltPayActivationStep
import org.rekotlin.StateType
/**
@ -10,42 +14,69 @@ import org.rekotlin.StateType
data class OnboardingWalletState(
val step: OnboardingWalletStep = OnboardingWalletStep.None,
val backupState: BackupState = BackupState(),
val onboardingSaltPayState: OnboardingSaltPayState? = null,
val isSaltPay: Boolean = false,
val cardArtworkUrl: String? = null,
val showConfetti: Boolean = false,
) : StateType {
val backupCardIdFilter: CardFilter.Companion.CardIdFilter?
get() = when {
isSaltPay -> CardFilter.Companion.CardIdFilter.Allow(
items = SaltPayWorkaround.walletCardIds.toSet(),
ranges = SaltPayWorkaround.walletCardIdRanges,
)
else -> null
}
fun getMaxProgress(): Int = when {
isSaltPay -> 12
else -> 6
}
fun getProgressStep(): Int {
return when {
step == OnboardingWalletStep.CreateWallet -> {
1
}
step == OnboardingWalletStep.CreateWallet -> 1
step == OnboardingWalletStep.Backup -> {
when (backupState.backupStep) {
BackupStep.InitBackup -> 2
BackupStep.ScanOriginCard -> 3
BackupStep.AddBackupCards -> 4
BackupStep.EnterAccessCode -> 4
BackupStep.ReenterAccessCode -> 4
BackupStep.SetAccessCode -> 4
BackupStep.WritePrimaryCard, is BackupStep.WriteBackupCard -> 5
BackupStep.Finished -> 6
BackupStep.Finished -> getMaxProgress()
}
}
step == OnboardingWalletStep.Done -> 6
step == OnboardingWalletStep.SaltPay && isSaltPay -> {
when (onboardingSaltPayState!!.step) {
SaltPayActivationStep.None, SaltPayActivationStep.NoGas -> 0
SaltPayActivationStep.NeedPin -> 7
SaltPayActivationStep.CardRegistration -> 8
SaltPayActivationStep.KycIntro, SaltPayActivationStep.KycStart,
SaltPayActivationStep.KycWaiting, SaltPayActivationStep.KycReject,
-> 9
SaltPayActivationStep.Claim -> 10
SaltPayActivationStep.ClaimInProgress -> 11
SaltPayActivationStep.Finished -> getMaxProgress()
}
}
step == OnboardingWalletStep.Done -> getMaxProgress()
else -> 1
}
}
}
enum class OnboardingWalletStep {
None, CreateWallet, Backup, Done
None, CreateWallet, Backup, SaltPay, Done
}
data class BackupState(
val primaryCardId: String? = null,
val backupCardsNumber: Int = 0,
val backupCardIds: List<CardId> = emptyList(),
@Transient
val backupCardsArtworks: Map<CardId, Bitmap> = emptyMap(),
val accessCode: String = "",
val accessCodeError: AccessCodeError? = null,
@ -72,7 +103,7 @@ sealed class BackupStep {
object Finished : BackupStep()
}
sealed class BackupDialog: StateDialog {
sealed class BackupDialog : StateDialog {
object AddMoreBackupCards : StateDialog
object BackupInProgress : StateDialog
object UnfinishedBackupFound : StateDialog

View file

@ -0,0 +1,182 @@
package com.tangem.tap.features.onboarding.products.wallet.saltPay
import com.tangem.blockchain.blockchains.ethereum.CompiledEthereumTransaction
import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager
import com.tangem.blockchain.blockchains.ethereum.SignedEthereumTransaction
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.AmountType
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.BlockchainSdkError
import com.tangem.blockchain.common.TransactionSigner
import com.tangem.blockchain.common.WalletManager
import com.tangem.blockchain.extensions.Result
import com.tangem.blockchain.extensions.SimpleResult
import com.tangem.blockchain.extensions.successOr
import com.tangem.common.extensions.guard
import com.tangem.domain.common.extensions.successOr
import com.tangem.tap.common.extensions.isPositive
import com.tangem.tap.common.extensions.safeUpdate
import com.tangem.tap.common.extensions.stub
import com.tangem.tap.domain.getFirstToken
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import java.math.BigDecimal
import java.math.BigInteger
import java.util.concurrent.atomic.AtomicLong
/**
[REDACTED_AUTHOR]
*/
class GnosisRegistrator(
val walletManager: EthereumWalletManager,
) {
private val walletAddress = walletManager.wallet.address
private val token = walletManager.wallet.getFirstToken().guard {
throw NullPointerException("WalletManager for GnosisRegistrator must contain token")
}
private val otpProcessorContractAddress: String = when (walletManager.wallet.blockchain) {
Blockchain.SaltPay -> "0x3B4397C817A26521Df8bD01a949AFDE2251d91C2"
Blockchain.SaltPayTestnet -> "0x710BF23486b549836509a08c184cE0188830f197"
else -> throw IllegalArgumentException("GnosisRegistrator supports only the SaltPay blockchain")
}
private val addressTreasureSafe = "0x8e9260a049d3Aa9ac60D0d4F27017320E0e2396B"
private val atomicNonce = AtomicLong()
suspend fun checkHasGas(): Result<Boolean> {
val wallet = walletManager.safeUpdate().successOr {
return Result.Failure(BlockchainSdkError.WrappedThrowable(it.error))
}
val hasGas = wallet.amounts[AmountType.Coin]?.value?.isPositive() ?: false
return Result.Success(hasGas)
}
suspend fun prepareBeforeMakingTxs(): Result<Unit> {
walletManager.safeUpdate().successOr {
return Result.Failure(BlockchainSdkError.WrappedThrowable(it.error))
}
atomicNonce.set(walletManager.txCount)
return Result.Success(Unit)
}
suspend fun sendTransactions(transactions: List<SignedEthereumTransaction>): Result<List<String>> {
val results = transactions.map {
coroutineScope { async { walletManager.sendRaw(it.compiledTransaction, it.signature) } }
}.awaitAll()
val errors = results.filterIsInstance<SimpleResult.Failure>()
return if (errors.isEmpty()) {
Result.Success(listOf())
} else {
Result.Failure(errors[0].error)
}
}
suspend fun makeSetSpendLimitTx(value: BigDecimal): Result<CompiledEthereumTransaction> {
val limitAmount = Amount(walletManager.cardTokens.first(), value)
val feeAmount = walletManager.getFeeToSetSpendLimit(otpProcessorContractAddress, limitAmount)
.extractFeeAmount().successOr { return it }
val compiledEthereumTransaction = walletManager.transactionBuilder.buildSetSpendLimitToSign(
processorContractAddress = otpProcessorContractAddress,
cardAddress = walletAddress,
amount = limitAmount,
transactionFee = feeAmount,
gasLimit = walletManager.gasLimitToSetSpendLimit,
nonce = atomicNonce.getAndIncrement().toBigInteger(),
) ?: return Result.Failure(BlockchainSdkError.CustomError("Can't create the 'setSpendLimit' transaction"))
return Result.Success(compiledEthereumTransaction)
}
suspend fun makeInitOtpTx(rootOTP: ByteArray, rootOTPCounter: Int): Result<CompiledEthereumTransaction> {
val feeAmount = walletManager.getFeeToInitOTP(otpProcessorContractAddress, rootOTP, rootOTPCounter)
.extractFeeAmount().successOr { return it }
val compiledEthereumTransaction = walletManager.transactionBuilder.buildInitOTPToSign(
processorContractAddress = otpProcessorContractAddress,
cardAddress = walletAddress,
otp = rootOTP,
otpCounter = rootOTPCounter,
transactionFee = feeAmount,
gasLimit = walletManager.gasLimitToInitOTP,
nonce = atomicNonce.getAndIncrement().toBigInteger(),
) ?: return Result.Failure(BlockchainSdkError.CustomError("Can't create the 'initOtp' transaction"))
return Result.Success(compiledEthereumTransaction)
}
suspend fun makeSetWalletTx(): Result<CompiledEthereumTransaction> {
val feeAmount = walletManager.getFeeToSetWallet(otpProcessorContractAddress)
.extractFeeAmount().successOr { return it }
val compiledEthereumTransaction = walletManager.transactionBuilder.buildSetWalletToSign(
processorContractAddress = otpProcessorContractAddress,
cardAddress = walletAddress,
transactionFee = feeAmount,
gasLimit = walletManager.gasLimitToSetWallet,
nonce = atomicNonce.getAndIncrement().toBigInteger(),
) ?: return Result.Failure(BlockchainSdkError.CustomError("Can't create the 'setWallet' transaction"))
return Result.Success(compiledEthereumTransaction)
}
suspend fun makeApprovalTx(value: BigDecimal): Result<CompiledEthereumTransaction> {
val approveAmount = Amount(token, value)
val feeAmount = walletManager.getFeeToApprove(approveAmount, otpProcessorContractAddress)
.extractFeeAmount().successOr { return it }
val compiledEthereumTransaction = walletManager.transactionBuilder.buildApproveToSign(
transactionData = walletManager.createTransaction(
amount = approveAmount,
fee = feeAmount,
destination = otpProcessorContractAddress,
),
nonce = atomicNonce.getAndIncrement().toBigInteger(),
gasLimit = walletManager.gasLimitToApprove,
) ?: return Result.Failure(BlockchainSdkError.CustomError("Can't create the 'approval' transaction"))
return Result.Success(compiledEthereumTransaction)
}
suspend fun getAllowance(): Result<Amount> {
return walletManager.getAllowance(addressTreasureSafe, token)
}
suspend fun transferFrom(amountToClaim: BigDecimal, signer: TransactionSigner): Result<Unit> {
val amount = Amount(token, amountToClaim)
val feeAmount = walletManager.getFeeToTransferFrom(amount, addressTreasureSafe)
.extractFeeAmount().successOr { return it }
val transactionData = walletManager.createTransferFromTransaction(amount, feeAmount, addressTreasureSafe)
walletManager.transferFrom(transactionData, signer).successOr { return Result.Failure(it.error) }
return Result.Success(Unit)
}
private fun Result<List<Amount>>.extractFeeAmount(): Result<Amount> {
return when (this) {
is Result.Success -> {
if (this.data.size != 3) Result.Failure(BlockchainSdkError.FailedToLoadFee)
else Result.Success(this.data[1])
}
is Result.Failure -> this
}
}
private fun Long?.toBigInteger(): BigInteger? = this?.let { BigInteger.valueOf(it) }
private fun Long.toBigInteger(): BigInteger = BigInteger.valueOf(this)
companion object {
fun stub(): GnosisRegistrator {
return GnosisRegistrator(WalletManager.stub())
}
}
}

View file

@ -0,0 +1,18 @@
package com.tangem.tap.features.onboarding.products.wallet.saltPay
import com.tangem.common.Filter
/**
[REDACTED_AUTHOR]
*/
abstract class PinFilter : Filter<String>
class AllSymbolsTheSameFilter : PinFilter() {
override fun filter(value: String): Boolean {
if (value.isEmpty()) return false
val array = value.toCharArray()
val firstSymbol = array[0]
return array.all { it == firstSymbol }
}
}

View file

@ -0,0 +1,198 @@
package com.tangem.tap.features.onboarding.products.wallet.saltPay
import android.net.Uri
import com.tangem.blockchain.blockchains.ethereum.SignedEthereumTransaction
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.AmountType
import com.tangem.blockchain.common.TransactionSigner
import com.tangem.blockchain.extensions.successOr
import com.tangem.common.extensions.guard
import com.tangem.common.extensions.isZero
import com.tangem.common.services.Result
import com.tangem.domain.common.extensions.successOr
import com.tangem.network.api.paymentology.AttestationResponse
import com.tangem.network.api.paymentology.PaymentologyApiService
import com.tangem.network.api.paymentology.RegisterKYCRequest
import com.tangem.network.api.paymentology.RegisterWalletRequest
import com.tangem.network.api.paymentology.RegisterWalletResponse
import com.tangem.network.api.paymentology.RegistrationResponse
import com.tangem.operations.attestation.AttestWalletKeyResponse
import com.tangem.tap.common.extensions.safeUpdate
import com.tangem.tap.domain.getFirstToken
import com.tangem.tap.domain.tokens.UserWalletId
import com.tangem.tap.features.onboarding.products.wallet.saltPay.message.SaltPayActivationError
import com.tangem.tap.persistence.SaltPayActivationStorage
import java.math.BigDecimal
/**
[REDACTED_AUTHOR]
*/
class SaltPayActivationManager(
val cardId: String,
val cardPublicKey: ByteArray,
val walletPublicKey: ByteArray,
private val kycProvider: KYCProvider,
private val paymentologyService: PaymentologyApiService,
private val gnosisRegistrator: GnosisRegistrator,
private val registrationStorage: SaltPayActivationStorage,
) {
val kycUrlProvider = KYCUrlProvider(walletPublicKey, kycProvider)
private val approvalValue: BigDecimal = BigDecimal(2).pow(256).minus(BigDecimal.ONE)
.movePointLeft(gnosisRegistrator.walletManager.wallet.blockchain.decimals())
private val spendLimitValue: BigDecimal = BigDecimal("100")
fun transactionIsSent(): Boolean {
return registrationStorage.data.transactionsSent
}
suspend fun checkHasGas(): Result<Unit> {
return when (val hasGasResult = gnosisRegistrator.checkHasGas()) {
is com.tangem.blockchain.extensions.Result.Success -> if (hasGasResult.data) {
Result.Success(Unit)
} else {
Result.Failure(SaltPayActivationError.NoGas)
}
is com.tangem.blockchain.extensions.Result.Failure -> Result.Failure(SaltPayActivationError.NoGas)
}
}
suspend fun registerKYC(): Result<Unit> {
return when (val result = paymentologyService.registerKYC(makeRegisterKYCRequest())) {
is Result.Success -> Result.Success(Unit)
is Result.Failure -> result
}
}
suspend fun checkRegistration(): Result<RegistrationResponse.Item> {
val registrationResponse = paymentologyService.checkRegistration(cardId, cardPublicKey).successOr { return it }
return try {
if (!registrationResponse.success || registrationResponse.results.isEmpty()) {
throw SaltPayActivationError.EmptyResponse(registrationResponse.error)
}
Result.Success(registrationResponse.results[0])
} catch (ex: SaltPayActivationError) {
Result.Failure(ex)
}
}
suspend fun requestAttestationChallenge(): Result<AttestationResponse> {
return paymentologyService.requestAttestationChallenge(cardId, cardPublicKey)
}
suspend fun sendTransactions(
signedTransactions: List<SignedEthereumTransaction>,
): com.tangem.blockchain.extensions.Result<List<String>> {
return gnosisRegistrator.sendTransactions(signedTransactions)
}
suspend fun registerWallet(
attestResponse: AttestWalletKeyResponse,
pinCode: String,
): Result<RegisterWalletResponse> {
val request = RegisterWalletRequest(
cardId = cardId,
publicKey = cardPublicKey,
walletPublicKey = walletPublicKey,
walletSalt = attestResponse.salt,
walletSignature = attestResponse.walletSignature,
cardSalt = attestResponse.publicKeySalt ?: byteArrayOf(),
cardSignature = attestResponse.cardSignature ?: byteArrayOf(),
pin = pinCode,
)
val result = paymentologyService.registerWallet(request)
registrationStorage.data = registrationStorage.data.copy(
transactionsSent = result is Result.Success,
)
return result
}
suspend fun getAmountToClaim(): Result<Amount> {
val amount = gnosisRegistrator.getAllowance().successOr {
return Result.Failure(SaltPayActivationError.FailedToGetFundsToClaim)
}
return if (amount.value == null || amount.value?.isZero() == true) {
Result.Failure(SaltPayActivationError.NoFundsToClaim)
} else {
Result.Success(amount)
}
}
suspend fun claim(amountToClaim: BigDecimal, signer: TransactionSigner): Result<Unit> {
val result = gnosisRegistrator.transferFrom(amountToClaim, signer).successOr {
return Result.Failure(SaltPayActivationError.ClaimTransactionFailed)
}
return Result.Success(Unit)
}
suspend fun getTokenAmount(): Result<BigDecimal> {
val wallet = gnosisRegistrator.walletManager.safeUpdate().successOr { return it }
val token = wallet.getFirstToken().guard {
throw UnsupportedOperationException()
}
val amountValue = wallet.amounts[AmountType.Token(token)]?.value.guard {
throw UnsupportedOperationException()
}
return Result.Success(amountValue)
}
fun makeRegistrationTask(challenge: ByteArray): SaltPayRegistrationTask {
return SaltPayRegistrationTask(
gnosisRegistrator = gnosisRegistrator,
challenge = challenge,
walletPublicKey = walletPublicKey,
approvalValue = approvalValue,
spendLimitValue = spendLimitValue,
)
}
private fun makeRegisterKYCRequest(): RegisterKYCRequest = RegisterKYCRequest(
cardId = cardId,
publicKey = cardPublicKey,
kycProvider = "UTORG",
kycRefId = kycUrlProvider.kycRefId,
)
companion object {
fun stub(): SaltPayActivationManager = SaltPayActivationManager(
kycProvider = SaltPayConfig.stub().kycProvider,
paymentologyService = PaymentologyApiService.stub(),
gnosisRegistrator = GnosisRegistrator.stub(),
registrationStorage = SaltPayActivationStorage.stub(),
cardId = "",
cardPublicKey = byteArrayOf(),
walletPublicKey = byteArrayOf(),
)
}
}
class KYCUrlProvider(
walletPublicKey: ByteArray,
kycProvider: KYCProvider,
) {
val kycRefId = UserWalletId(walletPublicKey).stringValue
val requestUrl: String = makeWebRequestUrl(kycProvider)
val doneUrl = "https://success.tangem.com/"
private fun makeWebRequestUrl(kycProvider: KYCProvider): String {
val baseUri = Uri.parse(kycProvider.baseUrl)
return Uri.Builder()
.scheme(baseUri.scheme)
.authority(baseUri.authority)
.encodedPath(baseUri.path)
.appendQueryParameter(kycProvider.sidParameterKey, kycProvider.sidValue)
.appendQueryParameter(kycProvider.externalIdParameterKey, kycRefId)
.build().toString()
}
}

View file

@ -0,0 +1,27 @@
package com.tangem.tap.features.onboarding.products.wallet.saltPay
import com.tangem.tap.common.zendesk.ZendeskConfig
/**
[REDACTED_AUTHOR]
*/
data class SaltPayConfig(
val zendesk: ZendeskConfig,
val kycProvider: KYCProvider,
) {
companion object {
fun stub(): SaltPayConfig {
return SaltPayConfig(
zendesk = ZendeskConfig("", "", "", "", ""),
kycProvider = KYCProvider("", "", "", ""),
)
}
}
}
data class KYCProvider(
val baseUrl: String,
val externalIdParameterKey: String,
val sidParameterKey: String,
val sidValue: String,
)

View file

@ -0,0 +1,43 @@
package com.tangem.tap.features.onboarding.products.wallet.saltPay
import com.tangem.common.core.TangemSdkError
import com.tangem.tap.common.extensions.dispatchDialogShow
import com.tangem.tap.common.redux.AppDialog
import com.tangem.tap.features.onboarding.products.wallet.saltPay.dialog.SaltPayDialog
import com.tangem.tap.features.onboarding.products.wallet.saltPay.message.SaltPayActivationError
import com.tangem.tap.store
/**
[REDACTED_AUTHOR]
*/
class SaltPayExceptionHandler {
companion object {
fun handle(throwable: Throwable) {
when (throwable) {
is SaltPayActivationError -> {
val dialog = when (throwable) {
is SaltPayActivationError.NoGas -> SaltPayDialog.Activation.NoGas
else -> SaltPayDialog.Activation.OnError(throwable)
}
store.dispatchDialogShow(dialog)
}
is TangemSdkError -> {
when (throwable) {
is TangemSdkError.NetworkError -> {
val message = (throwable).customMessage
store.dispatchDialogShow(AppDialog.SimpleOkErrorDialog(message))
}
else -> {
// do nothing
}
}
}
else -> {
val message = throwable.message ?: "SaltPay unknown error"
store.dispatchDialogShow(AppDialog.SimpleOkErrorDialog(message))
}
}
}
}
}

View file

@ -0,0 +1,170 @@
package com.tangem.tap.features.onboarding.products.wallet.saltPay
import com.tangem.blockchain.blockchains.ethereum.CompiledEthereumTransaction
import com.tangem.blockchain.blockchains.ethereum.SignedEthereumTransaction
import com.tangem.blockchain.extensions.successOr
import com.tangem.common.CompletionResult
import com.tangem.common.core.CardSession
import com.tangem.common.core.CardSessionRunnable
import com.tangem.common.core.CompletionCallback
import com.tangem.common.core.TangemSdkError
import com.tangem.common.extensions.guard
import com.tangem.operations.CommandResponse
import com.tangem.operations.GenerateOTPCommand
import com.tangem.operations.GenerateOTPResponse
import com.tangem.operations.attestation.AttestWalletKeyCommand
import com.tangem.operations.attestation.AttestWalletKeyResponse
import com.tangem.operations.sign.SignHashesCommand
import com.tangem.tap.scope
import kotlinx.coroutines.launch
import java.math.BigDecimal
/**
[REDACTED_AUTHOR]
*/
data class RegistrationTaskResponse(
val signedTransactions: List<SignedEthereumTransaction>,
val attestResponse: AttestWalletKeyResponse,
) : CommandResponse
class SaltPayRegistrationTask(
private var gnosisRegistrator: GnosisRegistrator,
private var challenge: ByteArray,
private val walletPublicKey: ByteArray,
private val approvalValue: BigDecimal,
private val spendLimitValue: BigDecimal,
) : CardSessionRunnable<RegistrationTaskResponse> {
private var generateOTPResponse: GenerateOTPResponse? = null
private var attestWalletResponse: AttestWalletKeyResponse? = null
private var signedTransactions: List<SignedEthereumTransaction> = listOf()
override fun run(session: CardSession, callback: CompletionCallback<RegistrationTaskResponse>) {
generateOTP(session, callback)
}
private fun generateOTP(session: CardSession, callback: CompletionCallback<RegistrationTaskResponse>) {
GenerateOTPCommand().run(session) { result ->
when (result) {
is CompletionResult.Success -> {
generateOTPResponse = result.data
attestWallet(session, callback)
}
is CompletionResult.Failure -> callback(CompletionResult.Failure(result.error))
}
}
}
private fun attestWallet(
session: CardSession,
callback: CompletionCallback<RegistrationTaskResponse>,
) {
val card = session.environment.card.guard {
callback(CompletionResult.Failure(TangemSdkError.MissingPreflightRead()))
return
}
val walletPublicKey = card.wallets.firstOrNull()?.publicKey
if (walletPublicKey == null || !walletPublicKey.contentEquals(this.walletPublicKey)) {
callback(CompletionResult.Failure(TangemSdkError.WalletNotFound()))
return
}
val attestWalletCommand = AttestWalletKeyCommand(
publicKey = walletPublicKey,
challenge = challenge,
confirmationMode = AttestWalletKeyCommand.ConfirmationMode.Dynamic,
)
attestWalletCommand.run(session) { result ->
when (result) {
is CompletionResult.Success -> {
attestWalletResponse = result.data
scope.launch { prepareTransactions(session, callback) }
}
is CompletionResult.Failure -> callback(CompletionResult.Failure(result.error))
}
}
}
private suspend fun prepareTransactions(
session: CardSession,
callback: CompletionCallback<RegistrationTaskResponse>,
) {
val generateOTPResponse = this.generateOTPResponse.guard {
callback(CompletionResult.Failure(TangemSdkError.UnknownError()))
return
}
gnosisRegistrator.prepareBeforeMakingTxs().successOr {
callback(CompletionResult.Failure(it.error))
return
}
val approvalTx = gnosisRegistrator.makeApprovalTx(approvalValue).successOr {
callback(CompletionResult.Failure(it.error))
return
}
val setWalletTx = gnosisRegistrator.makeSetWalletTx().successOr {
callback(CompletionResult.Failure(it.error))
return
}
val initOtpTx = gnosisRegistrator.makeInitOtpTx(
generateOTPResponse.rootOTP,
generateOTPResponse.rootOTPCounter,
).successOr {
callback(CompletionResult.Failure(it.error))
return
}
val setSpendLimitTx = gnosisRegistrator.makeSetSpendLimitTx(spendLimitValue).successOr {
callback(CompletionResult.Failure(it.error))
return
}
signTransactions(
transactions = listOf(
approvalTx,
setWalletTx,
initOtpTx,
setSpendLimitTx,
),
session = session,
callback = callback,
)
}
private fun signTransactions(
transactions: List<CompiledEthereumTransaction>,
session: CardSession,
callback: CompletionCallback<RegistrationTaskResponse>,
) {
val walletPublicKey = session.environment.card?.wallets?.firstOrNull()?.publicKey.guard {
callback(CompletionResult.Failure(TangemSdkError.WalletNotFound()))
return
}
val hashes = transactions.map { it.hash }.toTypedArray()
val signHashesCommand = SignHashesCommand(hashes, walletPublicKey)
signHashesCommand.run(session) { result ->
when (result) {
is CompletionResult.Success -> {
signedTransactions = transactions.zip(result.data.signatures).map {
SignedEthereumTransaction(it.first, it.second)
}
complete(callback)
}
is CompletionResult.Failure -> callback(CompletionResult.Failure(result.error))
}
}
}
private fun complete(callback: CompletionCallback<RegistrationTaskResponse>) {
if (signedTransactions.isEmpty() || attestWalletResponse == null) {
callback(CompletionResult.Failure(TangemSdkError.UnknownError()))
} else {
val result = RegistrationTaskResponse(signedTransactions, attestWalletResponse!!)
callback(CompletionResult.Success(result))
}
}
}

View file

@ -0,0 +1,166 @@
package com.tangem.tap.features.onboarding.products.wallet.saltPay
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.provider.MediaStore
import android.text.TextUtils
import android.webkit.PermissionRequest
import android.webkit.ValueCallback
import android.webkit.WebChromeClient
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.ComponentActivity
import androidx.core.app.ActivityCompat
import com.tangem.tap.common.ActivityResultCallbackHolder
import com.tangem.tap.common.OnActivityResultCallback
import com.tangem.tap.common.extensions.dispatchDialogShow
import com.tangem.tap.common.extensions.isPermissionGranted
import com.tangem.tap.common.redux.AppDialog
import com.tangem.tap.store
import com.tangem.wallet.R
import java.io.File
/**
[REDACTED_AUTHOR]
*/
class UtorgWebViewClient(
private val onSuccess: () -> Unit,
private val successUrl: String,
) : WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean =
if (successUrl == request.url.toString()) {
onSuccess()
true
} else {
false
}
}
class UtorgWebChromeClient(
private val activity: ComponentActivity,
private val activityResultCallbackHolder: ActivityResultCallbackHolder = activity as ActivityResultCallbackHolder,
) : WebChromeClient() {
private val fileChooseRequestCode = 1002
private val permissionRequestCode = 1003
private val onActivityResultCallback: OnActivityResultCallback = ::onActivityResult
private val permissionList = listOf(
"android.permission.CAMERA",
)
private var filePathCallback: ValueCallback<Array<Uri>>? = null
private var mediaUri: Uri? = null
override fun onShowFileChooser(
webView: WebView?,
filePathCallback: ValueCallback<Array<Uri>>,
fileChooserParams: FileChooserParams?,
): Boolean {
activityResultCallbackHolder.addOnActivityResultCallback(onActivityResultCallback)
if (!readyToShowFileChooser()) return false
val type: String
if (fileChooserParams != null && fileChooserParams.acceptTypes != null && fileChooserParams.acceptTypes.isNotEmpty()) {
type = if (!TextUtils.isEmpty(fileChooserParams.acceptTypes[0])) fileChooserParams.acceptTypes[0] else "*/*"
this.filePathCallback = filePathCallback
} else {
return false
}
proceedOnType(type, fileChooserParams.isCaptureEnabled)
return true
}
private fun readyToShowFileChooser(): Boolean {
val permissionsToGrant = permissionList.filterNot { activity.isPermissionGranted(it) }.toTypedArray()
return if (permissionsToGrant.isNotEmpty()) {
ActivityCompat.requestPermissions(activity, permissionsToGrant, permissionRequestCode)
false
} else {
true
}
}
private fun proceedOnType(type: String, isCaptureEnabled: Boolean) {
if (isCaptureEnabled) {
mediaUri = Uri.fromFile(File(getImageCaptureCachePath()))
val imageCaptureIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
imageCaptureIntent.putExtra("output", mediaUri)
activity.startActivityForResult(
Intent.createChooser(imageCaptureIntent, "Image Chooser"),
fileChooseRequestCode,
)
} else {
mediaUri = Uri.fromFile(File(getImageCaptureCachePath()))
val getContentIntent = Intent(Intent.ACTION_GET_CONTENT)
getContentIntent.addCategory(Intent.CATEGORY_OPENABLE)
getContentIntent.type = "image/*"
// val imageCaptureIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
// imageCaptureIntent.putExtra("output", mediaUri)
val chooserIntent = Intent.createChooser(getContentIntent, "Pick a photo")
// chooserIntent.putExtra(Intent.EXTRA_INITIAL_INTENTS, arrayOf(imageCaptureIntent))
activity.startActivityForResult(
Intent.createChooser(chooserIntent, "Image Chooser"),
fileChooseRequestCode,
)
}
}
private fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
when (requestCode) {
fileChooseRequestCode -> {
if (resultCode != Activity.RESULT_OK) {
filePathCallback?.onReceiveValue(null)
clean()
return
}
if (data == null) {
filePathCallback?.onReceiveValue(arrayOf(mediaUri!!))
} else {
val uri = FileChooserParams.parseResult(resultCode, data)
filePathCallback?.onReceiveValue(uri)
}
clean()
}
permissionRequestCode -> {
val notGranted = permissionList.filterNot { activity.isPermissionGranted(it) }
if (notGranted.isNotEmpty()) {
val permissionsName = notGranted.joinToString("\n") { it.permissionName() }
store.dispatchDialogShow(
AppDialog.SimpleOkDialog(
header = activity.getString(R.string.common_warning),
message = "Permission access is required to pass authorization." +
"To grant permission \n" +
"$permissionsName \n" +
"you must enable them in the app's settings",
),
)
}
}
}
}
override fun onPermissionRequest(request: PermissionRequest) {
request.grant(request.resources)
}
private fun getImageCaptureCachePath(): String = "cached_photo_" + System.currentTimeMillis() + ".jpg"
private fun clean() {
activityResultCallbackHolder.removeOnActivityResultCallback(onActivityResultCallback)
filePathCallback = null
mediaUri = null
}
private fun String.permissionName(): String {
return this.slice(this.lastIndexOf(".") until this.length)
}
}

View file

@ -0,0 +1,39 @@
package com.tangem.tap.features.onboarding.products.wallet.saltPay.dialog
import android.app.Dialog
import android.content.Context
import androidx.appcompat.app.AlertDialog
import com.tangem.common.extensions.guard
import com.tangem.tap.common.extensions.dispatchDebugErrorNotification
import com.tangem.tap.common.feedback.SupportInfo
import com.tangem.tap.common.redux.global.GlobalAction
import com.tangem.tap.store
import com.tangem.wallet.R
/**
[REDACTED_AUTHOR]
*/
class NoFundsForActivationDialog {
companion object {
fun create(context: Context): Dialog {
return AlertDialog.Builder(context).apply {
setTitle(R.string.saltpay_error_no_gas_title)
setMessage(R.string.saltpay_error_no_gas_message)
//TODO: SaltPay: change onboarding_supplement_button_kyc_waiting -> to appropriate string
setPositiveButton(R.string.onboarding_supplement_button_kyc_waiting) { _, _ ->
val config = store.state.globalState.configManager?.config?.saltPayConfig?.zendesk.guard {
store.dispatchDebugErrorNotification("SaltPayConfig not initialized")
return@setPositiveButton
}
store.dispatch(GlobalAction.OpenChat(SupportInfo(), config))
}
setNegativeButton(R.string.common_cancel) { _, _ ->
}
setOnDismissListener {
store.dispatch(GlobalAction.HideDialog)
}
setCancelable(false)
}.create()
}
}
}

View file

@ -0,0 +1,33 @@
package com.tangem.tap.features.onboarding.products.wallet.saltPay.dialog
import android.app.Dialog
import android.content.Context
import androidx.appcompat.app.AlertDialog
import com.tangem.tap.common.redux.global.GlobalAction
import com.tangem.tap.domain.moduleMessage.ConvertedDialogMessage
import com.tangem.tap.domain.moduleMessage.saltPay.SaltPayErrorConverter
import com.tangem.tap.store
import com.tangem.wallet.R
/**
[REDACTED_AUTHOR]
*/
class RegistrationErrorDialog {
companion object {
fun create(context: Context, dialog: SaltPayDialog.Activation.OnError): Dialog {
val convertedMessage = SaltPayErrorConverter(context).convert(dialog.error) as ConvertedDialogMessage
return AlertDialog.Builder(context).apply {
setTitle(convertedMessage.title)
setMessage(convertedMessage.message)
setPositiveButton(R.string.common_ok) { _, _ ->
store.dispatch(GlobalAction.HideDialog)
}
setOnDismissListener {
store.dispatch(GlobalAction.HideDialog)
}
setCancelable(false)
}.create()
}
}
}

View file

@ -0,0 +1,16 @@
package com.tangem.tap.features.onboarding.products.wallet.saltPay.dialog
import com.tangem.common.extensions.VoidCallback
import com.tangem.tap.common.redux.StateDialog
import com.tangem.tap.features.onboarding.products.wallet.saltPay.message.SaltPayActivationError
/**
[REDACTED_AUTHOR]
*/
sealed class SaltPayDialog : StateDialog {
sealed class Activation : SaltPayDialog() {
object NoGas : SaltPayDialog()
data class OnError(val error: SaltPayActivationError) : SaltPayDialog()
data class TryToInterrupt(val onOk: VoidCallback, val onCancel: VoidCallback) : SaltPayDialog()
}
}

View file

@ -0,0 +1,26 @@
package com.tangem.tap.features.onboarding.products.wallet.saltPay.dialog
import android.app.Dialog
import android.content.Context
import androidx.appcompat.app.AlertDialog
import com.tangem.tap.common.extensions.dispatchDialogHide
import com.tangem.tap.store
import com.tangem.wallet.R
/**
[REDACTED_AUTHOR]
*/
class TryToInterruptRegistrationDialog {
companion object {
fun create(context: Context, dialog: SaltPayDialog.Activation.TryToInterrupt): Dialog {
return AlertDialog.Builder(context).apply {
setTitle(context.getString(R.string.onboarding_exit_alert_title))
setMessage(context.getString(R.string.onboarding_exit_alert_message))
setPositiveButton(R.string.common_ok) { _, _ -> dialog.onOk() }
setNegativeButton(R.string.common_cancel) { _, _ -> dialog.onCancel() }
setOnDismissListener { store.dispatchDialogHide() }
setCancelable(false)
}.create()
}
}
}

View file

@ -0,0 +1,56 @@
package com.tangem.tap.features.onboarding.products.wallet.saltPay.message
import com.tangem.common.module.ModuleError
import com.tangem.common.module.ModuleErrorCode
import com.tangem.common.module.ModuleMessage
sealed interface SaltPayModuleMessage : ModuleMessage
sealed class SaltPayModuleError(
subCode: Int,
override val message: String,
override val data: Any? = null,
) : SaltPayModuleMessage, ModuleError() {
override val code: Int = ModuleErrorCode.SALT_PAY + subCode
companion object {
// base code used for all errors in the module
internal const val ERROR_CODE_REGISTRATION = 100
// const val CODE_ANY_OTHER = 200..299, 300..399 etc
}
}
sealed class SaltPayActivationError(
subCode: Int,
message: String? = null,
override val data: Any? = null,
) : SaltPayModuleError(
subCode = ERROR_CODE_REGISTRATION + subCode,
message = message ?: EMPTY_MESSAGE,
) {
object Unknown : SaltPayActivationError(4)
object Empty : SaltPayActivationError(4)
// gnosis
object FailedToMakeTxData : SaltPayActivationError(1)
object FailedToSendTx : SaltPayActivationError(2)
object NeedPin : SaltPayActivationError(3)
object NoGas : SaltPayActivationError(5) //
class EmptyResponse(message: String?) : SaltPayActivationError(6, message)
class CardNotFound(message: String?) : SaltPayActivationError(8, message)
class CardDisabled(message: String?) : SaltPayActivationError(9, message)
class CardNotPassed(message: String?) : SaltPayActivationError(10, message)
object EmptyDynamicAttestResponse : SaltPayActivationError(11)
object EmptyBackupCardScanned : SaltPayActivationError(12)
object WeakPin : SaltPayActivationError(13)
object FailedToGetFundsToClaim : SaltPayActivationError(14)
object NoFundsToClaim : SaltPayActivationError(15)
object ClaimTransactionFailed : SaltPayActivationError(16)
object NotClaimed : SaltPayActivationError(17)
companion object {
const val EMPTY_MESSAGE = ""
}
}

View file

@ -0,0 +1,39 @@
package com.tangem.tap.features.onboarding.products.wallet.saltPay.redux
import com.tangem.blockchain.common.Amount
import com.tangem.tap.features.onboarding.products.wallet.saltPay.SaltPayActivationManager
import com.tangem.tap.features.onboarding.products.wallet.saltPay.SaltPayConfig
import org.rekotlin.Action
import java.math.BigDecimal
/**
[REDACTED_AUTHOR]
*/
sealed class OnboardingSaltPayAction : Action {
sealed class Init : OnboardingSaltPayAction() {
data class SetDependencies(
val registrationManager: SaltPayActivationManager,
val saltPayConfig: SaltPayConfig,
) : Init()
}
data class SetInProgress(val isInProgress: Boolean) : OnboardingSaltPayAction()
data class SetClaimRefreshInProgress(val isInProgress: Boolean) : OnboardingSaltPayAction()
data class SetAccessCode(val accessCode: String?) : OnboardingSaltPayAction()
object Update : OnboardingSaltPayAction()
object RegisterCard : OnboardingSaltPayAction()
object OpenUtorgKYC : OnboardingSaltPayAction()
object UtorgKYCRedirectSuccess : OnboardingSaltPayAction()
object RegisterKYC : OnboardingSaltPayAction()
object Claim : OnboardingSaltPayAction()
object RefreshClaim : OnboardingSaltPayAction()
data class TrySetPin(val pin: String) : OnboardingSaltPayAction()
data class SetPin(val pin: String) : OnboardingSaltPayAction()
data class SetStep(val newStep: SaltPayActivationStep? = null) : OnboardingSaltPayAction()
data class SetAmountToClaim(val amount: Amount?) : OnboardingSaltPayAction()
data class SetTokenBalance(val balanceValue: BigDecimal) : OnboardingSaltPayAction()
}

View file

@ -0,0 +1,416 @@
package com.tangem.tap.features.onboarding.products.wallet.saltPay.redux
import com.tangem.Message
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.extensions.successOr
import com.tangem.common.Filter
import com.tangem.common.extensions.guard
import com.tangem.common.services.Result
import com.tangem.domain.common.extensions.successOr
import com.tangem.domain.common.extensions.withMainContext
import com.tangem.network.api.paymentology.KYCStatus
import com.tangem.network.api.paymentology.RegistrationResponse
import com.tangem.tap.common.extensions.dispatchOnMain
import com.tangem.tap.common.redux.AppState
import com.tangem.tap.common.redux.global.GlobalAction
import com.tangem.tap.domain.TangemSigner
import com.tangem.tap.features.demo.DemoHelper
import com.tangem.tap.features.onboarding.products.wallet.redux.OnboardingWalletState
import com.tangem.tap.features.onboarding.products.wallet.saltPay.AllSymbolsTheSameFilter
import com.tangem.tap.features.onboarding.products.wallet.saltPay.SaltPayActivationManager
import com.tangem.tap.features.onboarding.products.wallet.saltPay.SaltPayExceptionHandler
import com.tangem.tap.features.onboarding.products.wallet.saltPay.message.SaltPayActivationError
import com.tangem.tap.scope
import com.tangem.tap.store
import com.tangem.tap.tangemSdk
import com.tangem.tap.tangemSdkManager
import com.tangem.wallet.R
import kotlinx.coroutines.launch
import org.rekotlin.Action
import org.rekotlin.Middleware
import timber.log.Timber
/**
[REDACTED_AUTHOR]
*/
class OnboardingSaltPayMiddleware {
companion object {
val handler = onboardingSaltPayMiddleware
}
}
private val onboardingSaltPayMiddleware: Middleware<AppState> = { dispatch, state ->
{ next ->
{ action ->
handleOnboardingSaltPayAction(action, state)
next(action)
}
}
}
private fun handleOnboardingSaltPayAction(anyAction: Action, appState: () -> AppState?) {
if (DemoHelper.tryHandle(appState, anyAction)) return
val action = anyAction as? OnboardingSaltPayAction ?: return
appState() ?: return
fun getAppState(): AppState = appState()!!
fun getOnboardingWalletState(): OnboardingWalletState = getAppState().onboardingWalletState
fun getState(): OnboardingSaltPayState = getOnboardingWalletState().onboardingSaltPayState!!
when (action) {
is OnboardingSaltPayAction.Update -> {
handleInProgress = true
val state = getState()
scope.launch {
val updateResult = state.saltPayManager.updateActivationStatus(
amountToClaim = state.amountToClaim,
step = state.step,
).successOr {
onException(it.error)
return@launch
}
handleInProgress = false
withMainContext {
store.dispatch(OnboardingSaltPayAction.SetStep(updateResult.step))
}
}
}
is OnboardingSaltPayAction.RegisterCard -> {
handleInProgress = true
val state = getState()
scope.launch {
val saltPayManager = state.saltPayManager
checkGasIfNeeded(state.saltPayManager, state.step).successOr {
onException(it.error)
return@launch
}
val pinCode = state.pinCode.guard {
onError(SaltPayActivationError.NeedPin)
return@launch
}
val attestationResponse = saltPayManager.requestAttestationChallenge().successOr {
onException(it.error)
return@launch
}
if (!attestationResponse.success) {
onError(SaltPayActivationError.CardNotFound(attestationResponse.error))
return@launch
}
val registrationResponse = tangemSdkManager.runTaskAsync(
runnable = saltPayManager.makeRegistrationTask(attestationResponse.challenge!!),
cardId = saltPayManager.cardId,
initialMessage = Message(
header = null,
body = tangemSdkManager.getString(R.string.registration_task_alert_message),
),
accessCode = state.accessCode,
).successOr {
onException(it.error as Exception)
return@launch
}
saltPayManager.sendTransactions(registrationResponse.signedTransactions).successOr {
onError(SaltPayActivationError.FailedToSendTx)
return@launch
}
saltPayManager.registerWallet(registrationResponse.attestResponse, pinCode).successOr {
onException(it.error)
return@launch
}
withMainContext {
handleInProgress = false
store.dispatch(OnboardingSaltPayAction.SetStep(SaltPayActivationStep.KycIntro))
}
}
}
is OnboardingSaltPayAction.OpenUtorgKYC -> {
store.dispatch(OnboardingSaltPayAction.SetStep(SaltPayActivationStep.KycStart))
}
is OnboardingSaltPayAction.UtorgKYCRedirectSuccess -> {
store.dispatch(OnboardingSaltPayAction.RegisterKYC)
}
is OnboardingSaltPayAction.RegisterKYC -> {
val state = getState()
handleInProgress = true
scope.launch {
registerKYCIfNeeded(state.saltPayManager, state.step).successOr {
onException(it.error)
return@launch
}
handleInProgress = false
withMainContext { store.dispatch(OnboardingSaltPayAction.Update) }
}
}
is OnboardingSaltPayAction.TrySetPin -> {
try {
assertPinValid(action.pin, getState().pinLength)
store.dispatch(OnboardingSaltPayAction.SetPin(action.pin))
store.dispatch(OnboardingSaltPayAction.SetStep(SaltPayActivationStep.CardRegistration))
} catch (error: SaltPayActivationError) {
SaltPayExceptionHandler.handle(error)
}
}
is OnboardingSaltPayAction.Claim -> {
val state = getState()
handleInProgress = true
scope.launch {
val amountToClaim = getAmountToClaimIfNeeded(state.saltPayManager, state.amountToClaim).guard {
onException(SaltPayActivationError.NoFundsToClaim)
return@launch
}
val scanResponse = store.state.globalState.scanResponse
?: store.state.globalState.onboardingState.onboardingManager?.scanResponse
val signer = TangemSigner(
card = scanResponse!!.card,
tangemSdk = tangemSdk,
initialMessage = Message(),
accessCode = state.accessCode,
) { signResponse ->
store.dispatch(
GlobalAction.UpdateWalletSignedHashes(
walletSignedHashes = signResponse.totalSignedHashes,
walletPublicKey = state.saltPayManager.walletPublicKey,
remainingSignatures = signResponse.remainingSignatures,
),
)
}
state.saltPayManager.claim(amountToClaim.value!!, signer).successOr {
dispatchOnMain(OnboardingSaltPayAction.SetStep(SaltPayActivationStep.Claim))
onException(it.error)
return@launch
}
dispatchOnMain(OnboardingSaltPayAction.SetStep(SaltPayActivationStep.ClaimInProgress))
dispatchOnMain(OnboardingSaltPayAction.RefreshClaim)
}
}
is OnboardingSaltPayAction.RefreshClaim -> {
handleInProgress = true
handleClaimRefreshInProgress = true
val state = getState()
scope.launch {
val balance = state.saltPayManager.getTokenAmount().successOr {
SaltPayExceptionHandler.handle(it.error)
handleClaimRefreshInProgress = false
return@launch
}
handleInProgress = false
handleClaimRefreshInProgress = false
dispatchOnMain(OnboardingSaltPayAction.SetTokenBalance(balance))
dispatchOnMain(OnboardingSaltPayAction.SetStep(SaltPayActivationStep.Finished))
}
}
else -> {
/* do nothing, only reduce */
}
}
}
data class UpdateResult(
val step: SaltPayActivationStep = SaltPayActivationStep.None,
val amountToClaim: Amount? = null,
)
suspend fun SaltPayActivationManager.updateActivationStatus(
amountToClaim: Amount?,
step: SaltPayActivationStep,
): Result<UpdateResult> {
Timber.d("updateSaltPayStatus")
var updateResult = UpdateResult()
checkGasIfNeeded(this, step).successOr {
return Result.Failure(it.error)
}
registerKYCIfNeeded(this, step).successOr {
return Result.Failure(it.error)
}
val saltPayStep = checkRegistration(this).successOr {
return Result.Failure(it.error)
}
val fetchedAmountToClaim = getAmountToClaimIfNeeded(this, amountToClaim)
updateResult = when {
// if fetchedAmountToClaim = null -> then it already claimed
saltPayStep == SaltPayActivationStep.Claim && fetchedAmountToClaim == null -> {
updateResult.copy(
step = SaltPayActivationStep.Finished,
amountToClaim = fetchedAmountToClaim,
)
}
else -> updateResult.copy(
step = saltPayStep,
amountToClaim = fetchedAmountToClaim,
)
}
Timber.d("updateSaltPayStatus: success: %s", updateResult)
return Result.Success(updateResult)
}
private suspend fun getAmountToClaimIfNeeded(
saltPayManager: SaltPayActivationManager,
amountToClaim: Amount?,
): Amount? {
Timber.d("getAmountToClaimIfNeeded: for amount: %s", amountToClaim)
if (amountToClaim != null) {
Timber.d("getAmountToClaimIfNeeded: checks no need, amount already persist")
return amountToClaim
}
return when (val result = saltPayManager.getAmountToClaim()) {
is Result.Success -> {
Timber.d("getAmountToClaimIfNeeded: success: %s", result.data)
return result.data
}
is Result.Failure -> {
Timber.e(result.error, "getAmountToClaimIfNeeded: failure")
null
}
}
}
private var handleClaimRefreshInProgress: Boolean = false
set(value) {
field = value
store.dispatchOnMain(OnboardingSaltPayAction.SetClaimRefreshInProgress(value))
}
private var handleInProgress: Boolean = false
set(value) {
field = value
store.dispatchOnMain(OnboardingSaltPayAction.SetInProgress(value))
}
private suspend fun onError(error: SaltPayActivationError) {
withMainContext {
handleInProgress = false
SaltPayExceptionHandler.handle(error)
}
}
private suspend fun onException(error: Throwable) {
withMainContext {
handleInProgress = false
SaltPayExceptionHandler.handle(error)
}
}
private suspend fun checkGasIfNeeded(
saltPayManager: SaltPayActivationManager,
step: SaltPayActivationStep,
): Result<Unit> {
Timber.d("checkGasIfNeeded: for step: %s", step)
if (step == SaltPayActivationStep.KycStart ||
step == SaltPayActivationStep.KycWaiting ||
step == SaltPayActivationStep.Finished
) {
Timber.d("checkGasIfNeeded: no need to check for step: %s", step)
return Result.Success(Unit)
}
val result = saltPayManager.checkHasGas()
when (result) {
is Result.Success -> Timber.d("checkGasIfNeeded: has GAS")
is Result.Failure -> Timber.d("checkGasIfNeeded: has NO GAS")
}
return result
}
private suspend fun registerKYCIfNeeded(
saltPayManager: SaltPayActivationManager,
step: SaltPayActivationStep,
): Result<Unit> {
Timber.d("registerKYCIfNeeded: step: %s", step)
if (step != SaltPayActivationStep.KycStart) {
Timber.d("registerKYCIfNeeded: return Success, because step != KycStart")
return Result.Success(Unit)
}
withMainContext {
Timber.d("registerKYCIfNeeded: set new step: KycWaiting")
store.dispatch(OnboardingSaltPayAction.SetStep(SaltPayActivationStep.KycWaiting))
}
Timber.d("saltPayManager.registerKYC()")
return saltPayManager.registerKYC()
}
private suspend fun checkRegistration(
saltPayManager: SaltPayActivationManager,
): Result<SaltPayActivationStep> {
Timber.d("checkRegistration")
return try {
val registrationResponseItem = saltPayManager.checkRegistration().successOr {
Timber.e(it.error, "checkRegistration: failure")
return it
}
Timber.d("checkRegistration: success: try convert to SaltPayStep")
val step = registrationResponseItem.toSaltPayStep()
Timber.d("checkRegistration: step: %s", step)
Result.Success(step)
} catch (ex: SaltPayActivationError) {
Timber.e(ex, "checkRegistration: step")
Result.Failure(ex)
}
}
@Throws(SaltPayActivationError::class)
fun RegistrationResponse.Item.toSaltPayStep(): SaltPayActivationStep {
return when {
passed != true -> throw SaltPayActivationError.CardNotPassed(this.error)
disabledByAdmin == true -> throw SaltPayActivationError.CardDisabled(this.error)
// go to claim screen
active == true -> SaltPayActivationStep.Claim
// pinSet is false, go toPin screen
pinSet == false -> SaltPayActivationStep.NeedPin
kycStatus != null -> {
when (kycStatus) {
KYCStatus.NOT_STARTED, KYCStatus.STARTED -> SaltPayActivationStep.KycStart
KYCStatus.WAITING_FOR_APPROVAL -> SaltPayActivationStep.KycWaiting
KYCStatus.CORRECTION_REQUESTED, KYCStatus.REJECTED -> SaltPayActivationStep.KycReject
KYCStatus.APPROVED -> SaltPayActivationStep.Claim
null -> throw UnsupportedOperationException()
}
}
// kycDate is set, go to kyc waiting screen
kycDate != null -> SaltPayActivationStep.KycWaiting
else -> SaltPayActivationStep.KycIntro
}
}
@Throws(SaltPayActivationError::class)
private fun assertPinValid(pin: String, pinLength: Int) {
val array = pin.toCharArray()
if (array.size < pinLength) throw SaltPayActivationError.WeakPin
val filters: List<Filter<String>> = listOf(
AllSymbolsTheSameFilter(),
)
if (filters.any { it.filter(pin) }) {
throw SaltPayActivationError.WeakPin
}
}

View file

@ -0,0 +1,78 @@
package com.tangem.tap.features.onboarding.products.wallet.saltPay.redux
import com.tangem.tap.features.onboarding.products.wallet.redux.OnboardingWalletState
import org.rekotlin.Action
/**
[REDACTED_AUTHOR]
*/
class OnboardingSaltPayReducer {
companion object {
fun reduce(action: Action, state: OnboardingWalletState): OnboardingWalletState {
return internalReduce(action, state)
}
}
}
private fun internalReduce(anyAction: Action, onboardingWalletState: OnboardingWalletState): OnboardingWalletState {
val action = anyAction as? OnboardingSaltPayAction ?: return onboardingWalletState
if (onboardingWalletState.onboardingSaltPayState == null && action is OnboardingSaltPayAction.Init.SetDependencies) {
return onboardingWalletState.copy(
onboardingSaltPayState = OnboardingSaltPayState(
saltPayManager = action.registrationManager,
saltPayConfig = action.saltPayConfig,
),
)
}
val oldState = onboardingWalletState.onboardingSaltPayState ?: return onboardingWalletState
val newState = when (action) {
is OnboardingSaltPayAction.SetAccessCode -> {
oldState.copy(
accessCode = action.accessCode,
)
}
is OnboardingSaltPayAction.SetPin -> {
oldState.copy(pinCode = action.pin)
}
is OnboardingSaltPayAction.SetStep -> {
if (action.newStep == null) {
oldState
} else {
oldState.copy(
step = action.newStep,
)
}
}
is OnboardingSaltPayAction.SetAmountToClaim -> {
oldState.copy(
amountToClaim = action.amount,
)
}
is OnboardingSaltPayAction.SetTokenBalance -> {
oldState.copy(
tokenAmount = oldState.tokenAmount.copy(value = action.balanceValue),
)
}
is OnboardingSaltPayAction.SetInProgress -> {
oldState.copy(
inProgress = action.isInProgress,
)
}
is OnboardingSaltPayAction.SetClaimRefreshInProgress -> {
oldState.copy(
claimInProgress = action.isInProgress,
)
}
else -> oldState
}
return when (newState) {
oldState -> onboardingWalletState
else -> {
onboardingWalletState.copy(onboardingSaltPayState = newState)
}
}
}

View file

@ -0,0 +1,130 @@
package com.tangem.tap.features.onboarding.products.wallet.saltPay.redux
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.WalletManagerFactory
import com.tangem.common.extensions.guard
import com.tangem.common.extensions.toHexString
import com.tangem.common.json.MoshiJsonConverter
import com.tangem.domain.common.SaltPayWorkaround
import com.tangem.domain.common.ScanResponse
import com.tangem.network.api.paymentology.PaymentologyApiService
import com.tangem.tap.common.toggleWidget.WidgetState
import com.tangem.tap.copyToClipboard
import com.tangem.tap.domain.extensions.makeSaltPayWalletManager
import com.tangem.tap.features.onboarding.products.wallet.saltPay.GnosisRegistrator
import com.tangem.tap.features.onboarding.products.wallet.saltPay.KYCProvider
import com.tangem.tap.features.onboarding.products.wallet.saltPay.SaltPayActivationManager
import com.tangem.tap.features.onboarding.products.wallet.saltPay.SaltPayConfig
import com.tangem.tap.features.wallet.redux.ProgressState
import com.tangem.tap.persistence.SaltPayActivationStorage
import com.tangem.tap.preferencesStorage
import com.tangem.tap.store
import java.math.BigDecimal
/**
[REDACTED_AUTHOR]
*/
data class OnboardingSaltPayState(
@Transient
val saltPayManager: SaltPayActivationManager = SaltPayActivationManager.stub(),
val saltPayConfig: SaltPayConfig = SaltPayConfig.stub(),
val pinCode: String? = null,
val accessCode: String? = null,
val amountToClaim: Amount? = null,
val tokenAmount: Amount = Amount(SaltPayWorkaround.tokenFrom(Blockchain.SaltPay), BigDecimal.ZERO),
val step: SaltPayActivationStep = SaltPayActivationStep.None,
val saltPayCardArtworkUrl: String? = null,
val inProgress: Boolean = false,
val claimInProgress: Boolean = false,
) {
val mainButtonState: WidgetState
get() = if (inProgress) ProgressState.Loading else ProgressState.Done
fun readyToClaim(): Boolean = amountToClaim != null
val pinLength: Int = 4
companion object {
fun initDependency(scanResponse: ScanResponse): Pair<SaltPayActivationManager, SaltPayConfig> {
val globalState = store.state.globalState
val saltPayConfig = globalState.configManager?.config?.saltPayConfig.guard {
throw NullPointerException("SaltPayConfig is not initialized")
}
val gnosisRegistrator = makeGnosisRegistrator(
scanResponse = scanResponse,
wmFactory = globalState.tapWalletManager.walletManagerFactory,
)
val registrationManager = makeSaltPayRegistrationManager(
scanResponse = scanResponse,
gnosisRegistrator = gnosisRegistrator,
paymentologyService = store.state.domainNetworks.paymentologyService,
registrationStorage = preferencesStorage.saltPayActivationStorage,
kycProvider = saltPayConfig.kycProvider,
)
test(scanResponse, gnosisRegistrator)
return registrationManager to saltPayConfig
}
//TODO: delete
private fun test(scanResponse: ScanResponse, gnosisRegistrator: GnosisRegistrator) {
val map = mapOf(
"CID" to scanResponse.card.cardId,
"cardPublicKey" to scanResponse.card.cardPublicKey.toHexString(),
"walletPublicKey" to gnosisRegistrator.walletManager.wallet.publicKey.blockchainKey.toHexString(),
"walletAddress" to gnosisRegistrator.walletManager.wallet.address,
)
val json = MoshiJsonConverter.INSTANCE.prettyPrint(map)
copyToClipboard(json)
}
fun makeSaltPayRegistrationManager(
scanResponse: ScanResponse,
gnosisRegistrator: GnosisRegistrator,
paymentologyService: PaymentologyApiService,
registrationStorage: SaltPayActivationStorage,
kycProvider: KYCProvider,
): SaltPayActivationManager {
if (!scanResponse.isSaltPay()) {
throw IllegalArgumentException("Can't initialize the OnboardingSaltPayMiddleware if card is not SalPay")
}
val saltPaySingleWallet = scanResponse.card.wallets.firstOrNull().guard {
throw NullPointerException("SaltPay card must have one wallet at least")
}
return SaltPayActivationManager(
cardId = scanResponse.card.cardId,
cardPublicKey = scanResponse.card.cardPublicKey,
walletPublicKey = saltPaySingleWallet.publicKey,
kycProvider = kycProvider,
paymentologyService = paymentologyService,
gnosisRegistrator = gnosisRegistrator,
registrationStorage = registrationStorage,
)
}
fun makeGnosisRegistrator(
scanResponse: ScanResponse,
wmFactory: WalletManagerFactory,
): GnosisRegistrator {
return GnosisRegistrator(
walletManager = wmFactory.makeSaltPayWalletManager(scanResponse),
)
}
}
}
enum class SaltPayActivationStep {
None,
NoGas,
NeedPin,
CardRegistration,
KycIntro,
KycStart,
KycWaiting,
KycReject,
Claim,
ClaimInProgress,
Finished;
}

View file

@ -0,0 +1,377 @@
package com.tangem.tap.features.onboarding.products.wallet.saltPay.ui
import android.view.View
import android.view.ViewGroup
import android.view.animation.OvershootInterpolator
import android.widget.ProgressBar
import androidx.annotation.LayoutRes
import androidx.annotation.StringRes
import androidx.constraintlayout.widget.ConstraintSet
import androidx.core.view.children
import androidx.transition.TransitionManager
import coil.load
import coil.size.Scale
import com.google.android.material.appbar.MaterialToolbar
import com.google.android.material.button.MaterialButton
import com.tangem.common.extensions.guard
import com.tangem.tap.common.compose.PinCodeWidget
import com.tangem.tap.common.extensions.configureSettings
import com.tangem.tap.common.extensions.dispatchDebugErrorNotification
import com.tangem.tap.common.extensions.dispatchDialogShow
import com.tangem.tap.common.extensions.getDrawableCompat
import com.tangem.tap.common.extensions.hide
import com.tangem.tap.common.extensions.setDrawable
import com.tangem.tap.common.extensions.show
import com.tangem.tap.common.extensions.stop
import com.tangem.tap.common.extensions.toFormattedCurrencyString
import com.tangem.tap.common.feedback.SupportInfo
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.toggleWidget.IndeterminateProgressButtonWidget
import com.tangem.tap.common.toggleWidget.RefreshBalanceWidget
import com.tangem.tap.common.transitions.InternalNoteLayoutTransition
import com.tangem.tap.features.onboarding.products.wallet.redux.BackupState
import com.tangem.tap.features.onboarding.products.wallet.redux.BackupStep
import com.tangem.tap.features.onboarding.products.wallet.redux.OnboardingWalletAction
import com.tangem.tap.features.onboarding.products.wallet.redux.OnboardingWalletState
import com.tangem.tap.features.onboarding.products.wallet.redux.OnboardingWalletStep
import com.tangem.tap.features.onboarding.products.wallet.saltPay.UtorgWebChromeClient
import com.tangem.tap.features.onboarding.products.wallet.saltPay.UtorgWebViewClient
import com.tangem.tap.features.onboarding.products.wallet.saltPay.dialog.SaltPayDialog
import com.tangem.tap.features.onboarding.products.wallet.saltPay.redux.OnboardingSaltPayAction
import com.tangem.tap.features.onboarding.products.wallet.saltPay.redux.OnboardingSaltPayState
import com.tangem.tap.features.onboarding.products.wallet.saltPay.redux.SaltPayActivationStep
import com.tangem.tap.features.onboarding.products.wallet.ui.OnboardingWalletFragment
import com.tangem.tap.features.wallet.redux.ProgressState
import com.tangem.tap.store
import com.tangem.wallet.R
import com.tangem.wallet.databinding.LayoutOnboardingContainerBottomBinding
import com.tangem.wallet.databinding.LayoutOnboardingContainerTopBinding
import com.tangem.wallet.databinding.LayoutOnboardingMainBinding
/**
[REDACTED_AUTHOR]
*/
internal class OnboardingSaltPayView(
private val walletFragment: OnboardingWalletFragment,
) {
private val toolbar: MaterialToolbar by lazy { walletFragment.binding.toolbar }
private val claimBinding: LayoutOnboardingMainBinding by lazy { walletFragment.bindingSaltPay.claim }
private val topContainer: LayoutOnboardingContainerTopBinding by lazy { claimBinding.onboardingTopContainer }
private val actionContainer: LayoutOnboardingContainerBottomBinding by lazy { claimBinding.onboardingActionContainer }
private val btnRefreshBalanceWidget by lazy {
RefreshBalanceWidget(claimBinding.onboardingTopContainer.onboardingWalletContainer)
}
private var progressButton: SaltPayProgressButton? = null
private var previousStep: SaltPayActivationStep = SaltPayActivationStep.None
fun newState(state: OnboardingWalletState) {
handleCardArtworks(state)
when (state.step) {
OnboardingWalletStep.SaltPay -> setSaltPayStep(state.onboardingSaltPayState)
OnboardingWalletStep.Backup -> {
if (state.backupState.backupStep == BackupStep.Finished) {
// if backup finished -> switch OnboardingWalletStep to the SaltPay
store.dispatch(OnboardingWalletAction.GetToSaltPayStep)
} else {
// if not -> back to the standard backup process
walletFragment.handleOnboardingStep(state)
}
}
else -> walletFragment.handleOnboardingStep(state)
}
}
private fun handleCardArtworks(state: OnboardingWalletState) = with(walletFragment.binding) {
//TODO: SaltPay: remove hardCode
if (state.onboardingSaltPayState?.saltPayCardArtworkUrl == null) {
imvFrontCard.load(R.drawable.img_salt_pay_visa) {
scale(Scale.FILL)
crossfade(enable = true)
}
} else {
walletFragment.loadImageIntoImageView(state.onboardingSaltPayState.saltPayCardArtworkUrl, imvFrontCard)
}
imvFirstBackupCard.load(R.drawable.card_placeholder_wallet)
// if (state.onboardingSaltPayState?.saltPayCardArtworkUrl == null) {
// //if saltPay url not loaded -> load from resource
// val bitmap = BitmapFactory.decodeResource(walletFragment.resources, R.drawable.img_salt_pay_visa)
// imvFrontCard.setImageBitmap(bitmap)
// } else {
// walletFragment.loadImageIntoImageView(state.onboardingSaltPayState.saltPayCardArtworkUrl, imvFrontCard)
// }
// walletFragment.loadImageIntoImageView(state.cardArtworkUrl, imvFirstBackupCard)
}
fun showAddBackupCards(state: BackupState) = with(walletFragment.binding) {
when (state.backupCardsNumber) {
0 -> {
tvHeader.text = walletFragment.getText(R.string.onboarding_saltpay_title_no_backup_card)
tvBody.text = walletFragment.getText(R.string.onboarding_saltpay_subtitle_no_backup_cards)
}
1 -> {
tvHeader.text = walletFragment.getText(R.string.onboarding_saltpay_title_one_backup_card)
tvBody.text = walletFragment.getText(R.string.onboarding_saltpay_subtitle_one_backup_card)
}
else -> {}
}
}
private fun setSaltPayStep(onboardingSaltPayState: OnboardingSaltPayState?) {
val state = onboardingSaltPayState.guard {
store.dispatchDebugErrorNotification("SaltPayView: setSaltPayStep: onboardingSaltPayState = null")
return
}
walletFragment.binding.onboardingWalletContainer.hide()
walletFragment.bindingSaltPay.onboardingSaltpayContainer.show()
when (state.step) {
SaltPayActivationStep.NoGas -> handleNoGas()
SaltPayActivationStep.NeedPin -> handleNeedPin()
SaltPayActivationStep.CardRegistration -> handleCardRegistration()
SaltPayActivationStep.KycIntro -> handleKycIntro()
SaltPayActivationStep.KycStart -> handleKycStart(state)
SaltPayActivationStep.KycWaiting -> handleKycWaiting()
SaltPayActivationStep.KycReject -> handleKycReject()
SaltPayActivationStep.Claim -> handleClaim(state)
SaltPayActivationStep.ClaimInProgress -> handleClaim(state)
SaltPayActivationStep.Finished -> handleClaim(state)
}
progressButton?.changeState(state.mainButtonState)
}
private fun handleNoGas() {
// normally this shouldn't happen
store.dispatch(NavigationAction.PopBackTo(AppScreen.Home))
store.dispatchDialogShow(SaltPayDialog.Activation.NoGas)
}
private fun handleNeedPin() = with(walletFragment.bindingSaltPay) {
toolbar.title = getString(R.string.onboarding_navbar_pin)
showOnlyView(pinCode.root) {
progressButton = SaltPayProgressButton(pinCode.root)
progressButton?.mainView?.isEnabled = false
var enteredPinCode = ""
pinCode.flPinCodeContainer.setContent {
PinCodeWidget { code, isLastSymbolEntered ->
enteredPinCode = code
progressButton?.mainView?.isEnabled = isLastSymbolEntered
}
}
pinCode.btnSetPin.setOnClickListener {
store.dispatch(OnboardingSaltPayAction.TrySetPin(enteredPinCode))
}
}
}
private fun handleCardRegistration() = with(walletFragment.bindingSaltPay) {
toolbar.title = getString(R.string.onboarding_navbar_register_wallet)
showOnlyView(connectCard.root) {
progressButton = SaltPayProgressButton(connectCard.root)
connectCard.btnConnect.setOnClickListener {
store.dispatch(OnboardingSaltPayAction.RegisterCard)
}
}
}
private fun handleKycIntro() = with(walletFragment.bindingSaltPay) {
toolbar.title = getString(R.string.onboarding_navbar_kyc_start)
showOnlyView(verifyIdentity.root) {
progressButton = SaltPayProgressButton(verifyIdentity.root)
verifyIdentity.btnVerify.setOnClickListener {
store.dispatch(OnboardingSaltPayAction.OpenUtorgKYC)
}
}
}
private fun handleKycStart(state: OnboardingSaltPayState) = with(walletFragment.bindingSaltPay) {
toolbar.title = getString(R.string.onboarding_navbar_kyc_start)
showOnlyView(webvVerifyIdentity) {
progressButton = null
webvVerifyIdentity.configureSettings()
webvVerifyIdentity.webViewClient = UtorgWebViewClient(
successUrl = state.saltPayManager.kycUrlProvider.doneUrl,
onSuccess = {
webvVerifyIdentity.stop()
store.dispatch(OnboardingSaltPayAction.UtorgKYCRedirectSuccess)
},
)
webvVerifyIdentity.webChromeClient = UtorgWebChromeClient(walletFragment.requireActivity())
webvVerifyIdentity.loadUrl(state.saltPayManager.kycUrlProvider.requestUrl)
}
}
private fun handleKycWaiting() = with(walletFragment.bindingSaltPay.kycInProgress) {
toolbar.title = getString(R.string.onboarding_navbar_kyc_progress)
showOnlyView(root)
imvInProgress.setDrawable(R.drawable.ic_in_progress)
tvHeader.text = getString(R.string.onboarding_title_kyc_waiting)
tvBody.text = getString(R.string.onboarding_subtitle_kyc_waiting)
btnOpenSupportChat.hide()
btnOpenSupportChat.setOnClickListener {
store.dispatch(GlobalAction.OpenChat(SupportInfo()))
}
btnKycAction.text = getString(R.string.onboarding_button_kyc_waiting)
btnKycAction.setOnClickListener {
store.dispatch(OnboardingSaltPayAction.Update)
}
progressButton = SaltPayProgressButton(root)
}
private fun handleKycReject() = with(walletFragment.bindingSaltPay.kycInProgress) {
toolbar.title = getString(R.string.onboarding_navbar_kyc_progress)
showOnlyView(root)
imvInProgress.setDrawable(R.drawable.ic_reject)
tvHeader.text = getString(R.string.onboarding_title_kyc_retry)
tvBody.text = getString(R.string.onboarding_subtitle_kyc_retry)
btnOpenSupportChat.hide()
btnKycAction.text = getString(R.string.onboarding_button_kyc_start)
btnKycAction.setOnClickListener {
store.dispatch(OnboardingSaltPayAction.Update)
}
progressButton = SaltPayProgressButton(root)
}
private fun handleClaim(state: OnboardingSaltPayState) = with(walletFragment.bindingSaltPay) {
toolbar.title = getString(R.string.onboarding_navbar_claim)
val btnMain = actionContainer.btnContainer.findViewById<MaterialButton>(R.id.btn_main_action)
val tvHeader = actionContainer.tvHeader
val tvBody = actionContainer.tvBody
showOnlyView(claimBinding.root) {
// topContainer.progress.pbState.hide()
progressButton = SaltPayProgressButton(actionContainer.root)
topContainer.imvCardBackground.apply {
setBackgroundDrawable(context.getDrawableCompat(R.drawable.shape_rectangle_rounded_8))
}
topContainer.imvFrontCard.load(R.drawable.img_salt_pay_visa) {
scale(Scale.FILL)
crossfade(enable = true)
}
actionContainer.btnAlternativeAction.hide()
updateConstraints(state.step, R.layout.lp_onboarding_topup_wallet)
}
btnRefreshBalanceWidget.changeState(if (state.claimInProgress) ProgressState.Loading else ProgressState.Done)
if (btnRefreshBalanceWidget.isShowing != true) {
btnRefreshBalanceWidget.mainView.setOnClickListener {
store.dispatch(OnboardingSaltPayAction.RefreshClaim)
}
}
val tokenAmountString = tokenAmountString(state)
topContainer.onboardingTvBalance.tvBalanceValue.text = tokenAmountString ?: ""
topContainer.onboardingTvBalance.tvBalanceCurrency.text = ""
when (state.step) {
SaltPayActivationStep.Claim -> {
btnRefreshBalanceWidget.mainView.hide()
claimValueString(state)?.let {
tvHeader.text = getString(R.string.onboarding_title_claim, it)
}
tvBody.text = getString(R.string.onboarding_subtitle_claim)
btnMain.text = getString(R.string.onboarding_button_claim)
btnMain.setOnClickListener {
store.dispatch(OnboardingSaltPayAction.Claim)
}
}
SaltPayActivationStep.ClaimInProgress -> {
btnRefreshBalanceWidget.mainView.show()
tvHeader.text = getString(R.string.onboarding_title_claim_progress)
tvBody.text = getString(R.string.onboarding_subtitle_claim_progress)
btnMain.text = getString(R.string.onboarding_button_claim)
}
SaltPayActivationStep.Finished -> {
tvHeader.setText(R.string.common_success)
tvBody.setText(R.string.onboarding_subtitle_success_claim)
btnMain.setText(R.string.onboarding_button_continue_wallet)
btnMain.setOnClickListener {
walletFragment.showConfetti(false)
store.dispatch(OnboardingWalletAction.FinishOnboarding)
}
btnRefreshBalanceWidget.mainView.setOnClickListener(null)
updateConstraints(state.step, R.layout.lp_onboarding_done_activation)
walletFragment.showConfetti(true)
}
else -> {}
}
// need to recreate after any step
progressButton = SaltPayProgressButton(actionContainer.root)
}
private fun tokenAmountString(state: OnboardingSaltPayState): String? {
return state.tokenAmount.value?.toFormattedCurrencyString(
decimals = state.tokenAmount.decimals,
currency = state.tokenAmount.currencySymbol,
)
}
private fun claimValueString(state: OnboardingSaltPayState): String? {
return state.amountToClaim?.value?.toFormattedCurrencyString(
decimals = state.amountToClaim.decimals,
currency = state.amountToClaim.currencySymbol,
)
}
private fun updateConstraints(currentStep: SaltPayActivationStep, @LayoutRes layoutId: Int) {
if (this.previousStep == currentStep) return
with(claimBinding.onboardingTopContainer) {
val constraintSet = ConstraintSet()
constraintSet.clone(root.context, layoutId)
constraintSet.applyTo(onboardingWalletContainer)
val transition = InternalNoteLayoutTransition()
transition.interpolator = OvershootInterpolator()
TransitionManager.beginDelayedTransition(onboardingWalletContainer, transition)
}
}
private fun showOnlyView(view: View, onShowListener: (() -> Unit)? = null) {
walletFragment.bindingSaltPay.onboardingSaltpayContainer.children
.filter { it.id != view.id }
.forEach { it.hide() }
view.show { onShowListener?.invoke() }
}
private fun getString(@StringRes resId: Int): String {
return walletFragment.getString(resId)
}
private fun getString(@StringRes resId: Int, vararg formatArgs: Any?): String {
return walletFragment.getString(resId, *formatArgs)
}
private class SaltPayProgressButton(
root: ViewGroup,
) : IndeterminateProgressButtonWidget(findButton(root), findProgress(root)) {
companion object {
fun findButton(view: ViewGroup): MaterialButton {
return findContainer(view).children.firstOrNull { it is MaterialButton } as MaterialButton
}
fun findProgress(view: ViewGroup): View {
return findContainer(view).children.firstOrNull { it is ProgressBar }!!
}
fun findContainer(view: ViewGroup): ViewGroup {
return view.children.first { it.id == R.id.btn_container } as ViewGroup
}
}
}
}

View file

@ -0,0 +1,299 @@
package com.tangem.tap.features.onboarding.products.wallet.ui
import com.google.android.material.button.MaterialButton
import com.tangem.tap.common.extensions.show
import com.tangem.tap.common.postUiDelayBg
import com.tangem.tap.features.onboarding.products.wallet.redux.BackupState
import com.tangem.tap.features.onboarding.products.wallet.ui.BackupCardType.FIRST_BACKUP
import com.tangem.tap.features.onboarding.products.wallet.ui.BackupCardType.ORIGIN
import com.tangem.tap.features.onboarding.products.wallet.ui.BackupCardType.SECOND_BACKUP
import com.tangem.wallet.databinding.FragmentOnboardingWalletBinding
/**
[REDACTED_AUTHOR]
*/
interface BackupAnimator {
fun updateBackupState(backupState: BackupState)
fun setupCreateWalletState()
fun showBackupIntro(state: BackupState)
fun showScanOriginCard()
fun showAddBackupCards(state: BackupState, backupCardsCounts: Int)
fun showWritePrimaryCard(state: BackupState)
fun showWriteBackupCard(state: BackupState, backupCard: Int)
fun showSuccess(onEnd: () -> Unit)
}
class WalletBackupAnimator(
private val cardsWidget: WalletCardsWidget,
) : BackupAnimator {
private val viewState: State = when (cardsWidget.leapfrogWidget.getViewsCount()) {
2 -> State.TwoCards
3 -> State.TreeCards
else -> throw UnsupportedOperationException()
}
private var currentStep = -1
override fun updateBackupState(backupState: BackupState) {
}
override fun setupCreateWalletState() {
currentStep = STEP_CREATE_WALLET
cardsWidget.toFolded(false)
ORIGIN.alpha(1f)
FIRST_BACKUP.alpha(1f)
SECOND_BACKUP.alpha(1f)
}
override fun showBackupIntro(state: BackupState) {
currentStep = STEP_BACKUP_INTRO
ORIGIN.show()
FIRST_BACKUP.show()
SECOND_BACKUP.show()
cardsWidget.toWelcome()
}
override fun showScanOriginCard() {
currentStep = STEP_SCAN_ORIGIN_CARD
cardsWidget.toFolded()
}
override fun showAddBackupCards(state: BackupState, backupCardsCounts: Int) {
when (backupCardsCounts) {
0 -> {
when (currentStep) {
STEP_CREATE_WALLET -> {
cardsWidget.toFolded(false) { cardsWidget.toFan() }
FIRST_BACKUP.animateAlpha(0.6f, 0)
SECOND_BACKUP.animateAlpha(0.2f, 400)
}
STEP_BACKUP_INTRO -> {
cardsWidget.toFolded(duration = 200) { cardsWidget.toFan() }
FIRST_BACKUP.animateAlpha(0.6f, 400, 250)
SECOND_BACKUP.animateAlpha(0.2f, 400, 250)
}
STEP_SCAN_ORIGIN_CARD -> {
cardsWidget.toFan()
FIRST_BACKUP.animateAlpha(0.6f, 400, 250)
SECOND_BACKUP.animateAlpha(0.2f, 400, 250)
}
}
}
1 -> {
cardsWidget.toFan(false)
FIRST_BACKUP.alpha(1f)
SECOND_BACKUP.alpha(0.2f)
}
2 -> {
cardsWidget.toFan(false)
FIRST_BACKUP.alpha(1f)
SECOND_BACKUP.alpha(1f)
}
}
currentStep = STEP_ADD_BACKUP_CARDS
}
override fun showWritePrimaryCard(state: BackupState) {
currentStep = STEP_WRITE_PRIMARY_CARD
cardsWidget.toLeapfrog(
onEndFold = {
FIRST_BACKUP.alpha(0.4f)
SECOND_BACKUP.alpha(0.2f)
},
)
}
override fun showWriteBackupCard(state: BackupState, backupCard: Int) {
when (viewState) {
State.TwoCards -> {
if (currentStep == STEP_WRITE_BACKUP_CARD) return
val delay = if (currentStep == UNDEFINED) 700L else 0
postUiDelayBg(delay) {
FIRST_BACKUP.alpha(1f)
cardsWidget.leapfrogWidget.leap {
ORIGIN.alpha(0.4f)
}
}
}
State.TreeCards -> {
when (backupCard) {
1 -> {
FIRST_BACKUP.alpha(1f)
cardsWidget.leapfrogWidget.leap {
SECOND_BACKUP.alpha(0.4f)
ORIGIN.alpha(0.2f)
}
}
2 -> {
SECOND_BACKUP.alpha(1f)
cardsWidget.leapfrogWidget.leap {
ORIGIN.alpha(0.4f)
FIRST_BACKUP.alpha(0.2f)
}
}
}
}
}
currentStep = STEP_WRITE_BACKUP_CARD
}
override fun showSuccess(onEnd: () -> Unit) {
currentStep = STEP_SUCCESS
cardsWidget.leapfrogWidget.fold(onEnd = onEnd)
}
private enum class State {
TreeCards,
TwoCards
}
private fun BackupCardType.animateAlpha(
alpha: Float,
duration: Long = 0L,
startDelay: Long = 0L,
) {
when (this) {
ORIGIN -> {
cardsWidget.getOriginCardView().animate().apply {
alpha(alpha)
this.startDelay = startDelay
this.duration = duration
}
}
FIRST_BACKUP -> {
cardsWidget.getFirstBackupCardView().animate().apply {
alpha(alpha)
this.startDelay = startDelay
this.duration = duration
}
}
SECOND_BACKUP -> {
if (viewState != State.TreeCards) return
cardsWidget.getSecondBackupCardView().animate().apply {
alpha(alpha)
this.startDelay = startDelay
this.duration = duration
}
}
}
}
private fun BackupCardType.alpha(alpha: Float) {
when (this) {
ORIGIN -> cardsWidget.getOriginCardView().alpha = alpha
FIRST_BACKUP -> cardsWidget.getFirstBackupCardView().alpha = alpha
SECOND_BACKUP -> {
if (viewState != State.TreeCards) return
cardsWidget.getSecondBackupCardView().alpha = alpha
}
}
}
private fun BackupCardType.show() {
when (this) {
ORIGIN -> cardsWidget.getOriginCardView().show()
FIRST_BACKUP -> cardsWidget.getFirstBackupCardView().show()
SECOND_BACKUP -> {
if (viewState != State.TreeCards) return
cardsWidget.getSecondBackupCardView().show()
}
}
}
companion object {
private const val UNDEFINED = -1
private const val STEP_CREATE_WALLET = 0
private const val STEP_BACKUP_INTRO = 1
private const val STEP_SCAN_ORIGIN_CARD = 2
private const val STEP_ADD_BACKUP_CARDS = 3
private const val STEP_WRITE_PRIMARY_CARD = 4
private const val STEP_WRITE_BACKUP_CARD = 5
private const val STEP_SUCCESS = 6
}
}
class TestBackupAnimation(
private val animator: BackupAnimator,
binding: FragmentOnboardingWalletBinding,
) : BackupAnimator {
private lateinit var state: BackupState
private var steps = 0
init {
init(binding, this)
}
fun setStep(step: Int, onStepUpdate: (Int) -> Unit = {}) {
steps = step
when (steps) {
0 -> setupCreateWalletState()
1 -> showBackupIntro(state)
2 -> showScanOriginCard()
3 -> showAddBackupCards(state, 0)
4 -> showAddBackupCards(state, 1)
5 -> showAddBackupCards(state, 2)
6 -> showWritePrimaryCard(state)
7 -> showWriteBackupCard(state, 1)
8 -> showWriteBackupCard(state, 2)
else -> setStep(0)
}
onStepUpdate(steps)
}
fun onNext(onStepUpdate: (Int) -> Unit) {
steps++
setStep(steps, onStepUpdate)
}
override fun updateBackupState(backupState: BackupState) {
this.state = backupState
}
override fun setupCreateWalletState() {
animator.setupCreateWalletState()
}
override fun showBackupIntro(state: BackupState) {
animator.showBackupIntro(state)
}
override fun showScanOriginCard() {
animator.showScanOriginCard()
}
override fun showAddBackupCards(state: BackupState, backupCardsCounts: Int) {
animator.showAddBackupCards(state, backupCardsCounts)
}
override fun showWritePrimaryCard(state: BackupState) {
animator.showWritePrimaryCard(state)
}
override fun showWriteBackupCard(state: BackupState, backupCard: Int) {
animator.showWriteBackupCard(state, backupCard)
}
override fun showSuccess(onEnd: () -> Unit) {
animator.showSuccess(onEnd)
}
companion object {
fun init(binding: FragmentOnboardingWalletBinding, animator: TestBackupAnimation) {
val button = MaterialButton(binding.onboardingRoot.context).apply { text = "Next" }
button.setOnClickListener { animator.onNext { button.text = "$it" } }
binding.onboardingRoot.addView(button, 0)
}
}
}

View file

@ -5,9 +5,10 @@ import android.util.TypedValue
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import android.widget.FrameLayout
import android.widget.ImageView
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
import androidx.core.view.MenuProvider
import androidx.transition.TransitionInflater
import androidx.transition.TransitionManager
import by.kirich1409.viewbindingdelegate.viewBinding
@ -19,39 +20,42 @@ import com.tangem.common.core.CardIdDisplayFormat
import com.tangem.tangem_sdk_new.ui.widget.leapfrogWidget.LeapfrogWidget
import com.tangem.tangem_sdk_new.ui.widget.leapfrogWidget.PropertyCalculator
import com.tangem.tap.common.extensions.hide
import com.tangem.tap.common.extensions.inflate
import com.tangem.tap.common.extensions.show
import com.tangem.tap.features.BaseFragment
import com.tangem.tap.features.FragmentOnBackPressedHandler
import com.tangem.tap.features.addBackPressHandler
import com.tangem.tap.features.onboarding.OnboardingMenuProvider
import com.tangem.tap.features.onboarding.products.wallet.redux.BackupAction
import com.tangem.tap.features.onboarding.products.wallet.redux.BackupState
import com.tangem.tap.features.onboarding.products.wallet.redux.BackupStep
import com.tangem.tap.features.onboarding.products.wallet.redux.OnboardingWalletAction
import com.tangem.tap.features.onboarding.products.wallet.redux.OnboardingWalletState
import com.tangem.tap.features.onboarding.products.wallet.redux.OnboardingWalletStep
import com.tangem.tap.features.onboarding.products.wallet.saltPay.ui.OnboardingSaltPayView
import com.tangem.tap.features.onboarding.products.wallet.ui.dialogs.AccessCodeDialog
import com.tangem.tap.store
import com.tangem.wallet.R
import com.tangem.wallet.databinding.FragmentOnboardingWalletBinding
import com.tangem.wallet.databinding.LayoutOnboardingSaltpayBinding
import com.tangem.wallet.databinding.ViewOnboardingProgressBinding
import org.rekotlin.StoreSubscriber
class OnboardingWalletFragment : Fragment(R.layout.fragment_onboarding_wallet),
class OnboardingWalletFragment : BaseFragment(R.layout.fragment_onboarding_wallet),
StoreSubscriber<OnboardingWalletState>, FragmentOnBackPressedHandler {
private var accessCodeDialog: AccessCodeDialog? = null
private val pbBinding: ViewOnboardingProgressBinding by viewBinding(ViewOnboardingProgressBinding::bind)
internal val binding: FragmentOnboardingWalletBinding by viewBinding(FragmentOnboardingWalletBinding::bind)
internal val bindingSaltPay: LayoutOnboardingSaltpayBinding by lazy { binding.onboardingSaltpayContainer }
private val onboardingSaltPayView: OnboardingSaltPayView = OnboardingSaltPayView(this)
private lateinit var cardsWidget: WalletCardsWidget
private val binding: FragmentOnboardingWalletBinding by viewBinding(
FragmentOnboardingWalletBinding::bind
)
private val pbBinding: ViewOnboardingProgressBinding by viewBinding(
ViewOnboardingProgressBinding::bind)
private var accessCodeDialog: AccessCodeDialog? = null
private lateinit var animator: BackupAnimator
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
postponeEnterTransition()
setHasOptionsMenu(true)
override fun configureTransitions() {
val inflater = TransitionInflater.from(requireContext())
enterTransition = inflater.inflateTransition(R.transition.fade)
exitTransition = inflater.inflateTransition(R.transition.fade)
@ -60,33 +64,58 @@ class OnboardingWalletFragment : Fragment(R.layout.fragment_onboarding_wallet),
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val typedValue = TypedValue()
resources.getValue(R.dimen.device_scale_factor_for_twins_welcome, typedValue, true)
val deviceScaleFactor = typedValue.float
val leapfrogCalculator = PropertyCalculator(
yTranslationFactor = 25f * deviceScaleFactor,
)
val leapfrog = LeapfrogWidget(binding.flCardsContainer, leapfrogCalculator)
cardsWidget = WalletCardsWidget(leapfrog, deviceScaleFactor) { 200f * deviceScaleFactor }
startPostponedEnterTransition()
val scaleFactor = getDeviceScaleFactor()
initCardsWidget(createLeapfrogWidget(binding.flCardsContainer, scaleFactor), scaleFactor)
binding.viewPagerBackupInfo.adapter = BackupInfoAdapter()
TabLayoutMediator(
binding.tabLayoutBackupInfo,
binding.viewPagerBackupInfo
binding.viewPagerBackupInfo,
) { tab, position ->
//Some implementation
}.attach()
(activity as? AppCompatActivity)?.setSupportActionBar(binding.toolbar)
store.dispatch(OnboardingWalletAction.Init)
binding.toolbar.setNavigationOnClickListener { activity?.onBackPressed() }
store.dispatch(OnboardingWalletAction.LoadArtwork)
addBackPressHandler(this)
store.dispatch(OnboardingWalletAction.Init)
store.dispatch(OnboardingWalletAction.StartSaltPayCardActivation)
store.dispatch(OnboardingWalletAction.LoadArtwork)
}
override fun loadToolbarMenu(): MenuProvider? = OnboardingMenuProvider()
private fun reInitCardsWidget(backupCardsCounts: Int) = with(binding) {
val viewBackupCount = flCardsContainer.childCount - 1
if (viewBackupCount <= 0) return@with
if (viewBackupCount == backupCardsCounts) return@with
cardsWidget.toFolded(false)
if (viewBackupCount > backupCardsCounts) {
flCardsContainer.removeViewAt(viewBackupCount - 1)
} else {
flCardsContainer.inflate(R.layout.view_onboarding_card, true)
}
val scaleFactor = getDeviceScaleFactor()
initCardsWidget(createLeapfrogWidget(flCardsContainer, scaleFactor), scaleFactor)
}
private fun createLeapfrogWidget(container: FrameLayout, deviceScaleFactor: Float): LeapfrogWidget {
val leapfrogCalculator = PropertyCalculator(yTranslationFactor = 25f * deviceScaleFactor)
return LeapfrogWidget(container, leapfrogCalculator)
}
private fun initCardsWidget(leapfrogWidget: LeapfrogWidget, deviceScaleFactor: Float, isTest: Boolean = false) {
cardsWidget = WalletCardsWidget(leapfrogWidget, deviceScaleFactor) { 200f * deviceScaleFactor }
animator = if (isTest) {
TestBackupAnimation(WalletBackupAnimator(cardsWidget), binding)
} else {
WalletBackupAnimator(cardsWidget)
}
}
override fun onStart() {
@ -105,30 +134,25 @@ class OnboardingWalletFragment : Fragment(R.layout.fragment_onboarding_wallet),
override fun newState(state: OnboardingWalletState) {
if (activity == null || view == null) return
if (state.isSaltPay) reInitCardsWidget(1)
animator.updateBackupState(state.backupState)
requireActivity().invalidateOptionsMenu()
with(binding) {
loadImageIntoImageView(state.cardArtworkUrl, imvFrontCard)
loadImageIntoImageView(state.cardArtworkUrl, imvFirstBackupCard)
loadImageIntoImageView(state.cardArtworkUrl, imvSecondBackupCard)
}
pbBinding.pbState.max = 6
pbBinding.pbState.max = state.getMaxProgress()
pbBinding.pbState.progress = state.getProgressStep()
when (state.step) {
OnboardingWalletStep.None -> {
}
OnboardingWalletStep.CreateWallet -> setupCreateWalletState()
OnboardingWalletStep.Backup -> setBackupState(state.backupState)
OnboardingWalletStep.Done -> {
}
if (state.isSaltPay) {
onboardingSaltPayView.newState(state)
} else {
loadImageIntoImageView(state.cardArtworkUrl, binding.imvFrontCard)
loadImageIntoImageView(state.cardArtworkUrl, binding.imvFirstBackupCard)
loadImageIntoImageView(state.cardArtworkUrl, binding.imvSecondBackupCard)
handleOnboardingStep(state)
}
}
private fun loadImageIntoImageView(url: String?, view: ImageView) {
internal fun loadImageIntoImageView(url: String?, view: ImageView) {
view.load(url) {
placeholder(R.drawable.card_placeholder_black)
error(R.drawable.card_placeholder_black)
@ -136,86 +160,78 @@ class OnboardingWalletFragment : Fragment(R.layout.fragment_onboarding_wallet),
}
}
internal fun handleOnboardingStep(state: OnboardingWalletState) {
when (state.step) {
OnboardingWalletStep.CreateWallet -> setupCreateWalletState()
OnboardingWalletStep.Backup -> setBackupState(state.backupState, state.isSaltPay)
else -> {}
}
}
private fun setupCreateWalletState() = with(binding) {
layoutButtonsCommon.btnMainAction.setText(R.string.onboarding_create_wallet_button_create_wallet)
layoutButtonsCommon.btnMainAction.setOnClickListener { store.dispatch(OnboardingWalletAction.CreateWallet) }
layoutButtonsCommon.btnAlternativeAction.hide()
layoutButtonsCommon.btnWalletMainAction.setText(R.string.onboarding_create_wallet_button_create_wallet)
layoutButtonsCommon.btnWalletMainAction.setOnClickListener { store.dispatch(OnboardingWalletAction.CreateWallet) }
layoutButtonsCommon.btnWalletAlternativeAction.hide()
toolbar.title = getText(R.string.onboarding_getting_started)
tvHeader.setText(R.string.onboarding_create_wallet_header)
tvBody.setText(R.string.onboarding_create_wallet_body)
cardsWidget.toFolded(false) { startPostponedEnterTransition() }
animator.setupCreateWalletState()
}
private fun setBackupState(state: BackupState) {
private fun setBackupState(state: BackupState, isSaltPay: Boolean) {
when (state.backupStep) {
BackupStep.InitBackup -> showBackupIntro(state)
BackupStep.InitBackup -> showBackupIntro(state, isSaltPay)
BackupStep.ScanOriginCard -> showScanOriginCard()
BackupStep.AddBackupCards -> showAddBackupCards(state)
BackupStep.AddBackupCards -> showAddBackupCards(state, isSaltPay)
BackupStep.SetAccessCode -> showSetAccessCode()
BackupStep.EnterAccessCode -> showEnterAccessCode(state)
BackupStep.ReenterAccessCode -> showReenterAccessCode(state)
is BackupStep.WritePrimaryCard -> showWritePrimaryCard(state)
is BackupStep.WriteBackupCard -> showWriteBackupCard(state)
is BackupStep.WritePrimaryCard -> showWritePrimaryCard(state, isSaltPay)
is BackupStep.WriteBackupCard -> showWriteBackupCard(state, isSaltPay)
BackupStep.Finished -> showSuccess()
}
}
private fun showBackupIntro(state: BackupState) = with(binding) {
private fun showBackupIntro(state: BackupState, isSaltPay: Boolean) = with(binding) {
imvFirstBackupCard.show()
imvSecondBackupCard.show()
cardsWidget.toWelcome()
tvHeader.hide()
tvBody.hide()
viewPagerBackupInfo.show()
tabLayoutBackupInfo.show()
with(layoutButtonsCommon) {
btnMainAction.text = getText(R.string.onboarding_button_backup_now)
btnAlternativeAction.text = getText(R.string.onboarding_button_skip_backup)
btnAlternativeAction.show(state.canSkipBackup)
btnMainAction.setOnClickListener { store.dispatch(BackupAction.StartBackup) }
btnAlternativeAction.setOnClickListener { store.dispatch(BackupAction.DismissBackup) }
}
btnWalletMainAction.text = getText(R.string.onboarding_button_backup_now)
btnWalletMainAction.setOnClickListener { store.dispatch(BackupAction.StartBackup) }
startPostponedEnterTransition()
btnWalletAlternativeAction.text = getText(R.string.onboarding_button_skip_backup)
btnWalletAlternativeAction.setOnClickListener { store.dispatch(BackupAction.DismissBackup) }
btnWalletAlternativeAction.show(state.canSkipBackup && !isSaltPay)
}
animator.showBackupIntro(state)
}
private fun showScanOriginCard() = with(binding) {
prepareBackupView()
cardsWidget.toFolded()
tvHeader.text = getText(R.string.onboarding_title_scan_origin_card)
tvBody.text = getString(
R.string.onboarding_subtitle_scan_origin_card,
)
with(layoutButtonsCommon) {
btnMainAction.text = getString(R.string.onboarding_button_scan_origin_card)
btnAlternativeAction.hide()
btnMainAction.setOnClickListener { store.dispatch(BackupAction.ScanPrimaryCard) }
btnWalletMainAction.text = getString(R.string.onboarding_button_scan_origin_card)
btnWalletAlternativeAction.hide()
btnWalletMainAction.setOnClickListener { store.dispatch(BackupAction.ScanPrimaryCard) }
}
animator.showScanOriginCard()
}
private fun prepareBackupView() = with(binding) {
toolbar.title = getText(R.string.onboarding_navbar_title_creating_backup)
tvHeader.show()
tvBody.show()
viewPagerBackupInfo.hide()
tabLayoutBackupInfo.hide()
imvFirstBackupCard.show()
imvSecondBackupCard.show()
}
private fun showAddBackupCards(state: BackupState) = with(binding) {
private fun showAddBackupCards(state: BackupState, isSaltPay: Boolean) = with(binding) {
prepareBackupView()
accessCodeDialog?.dismiss()
@ -230,36 +246,43 @@ class OnboardingWalletFragment : Fragment(R.layout.fragment_onboarding_wallet),
layoutButtonsAddCards.btnAddCard.isEnabled = false
}
when (state.backupCardsNumber) {
0 -> {
cardsWidget.toFan() {
cardsWidget.getFirstBackupCardView().animate().alpha(0.6f).setDuration(200)
cardsWidget.getSecondBackupCardView().animate().alpha(0.2f).setDuration(200)
}
tvHeader.text = getText(R.string.onboarding_title_no_backup_cards)
tvBody.text = getText(R.string.onboarding_subtitle_no_backup_cards)
}
1 -> {
tvHeader.text = getText(R.string.onboarding_title_one_backup_card)
tvBody.text = getText(R.string.onboarding_subtitle_one_backup_card)
cardsWidget.toFan(false)
cardsWidget.getFirstBackupCardView().animate().alpha(1f).setDuration(400)
cardsWidget.getSecondBackupCardView().alpha = 0.2f
}
2 -> {
tvHeader.text = getText(R.string.onboarding_title_two_backup_cards)
tvBody.text = getText(R.string.onboarding_subtitle_two_backup_cards)
cardsWidget.toFan(false)
cardsWidget.getFirstBackupCardView().alpha = 1f
cardsWidget.getSecondBackupCardView().animate().alpha(1f).setDuration(400)
}
}
layoutButtonsAddCards.btnContinue.text = getText(R.string.onboarding_button_finalize_backup)
layoutButtonsAddCards.btnContinue.setOnClickListener { store.dispatch(BackupAction.FinishAddingBackupCards) }
layoutButtonsAddCards.btnContinue.isEnabled = state.backupCardsNumber != 0
if (isSaltPay) {
onboardingSaltPayView.showAddBackupCards(state)
} else {
when (state.backupCardsNumber) {
0 -> {
tvHeader.text = getText(R.string.onboarding_title_no_backup_cards)
tvBody.text = getText(R.string.onboarding_subtitle_no_backup_cards)
}
1 -> {
tvHeader.text = getText(R.string.onboarding_title_one_backup_card)
tvBody.text = getText(R.string.onboarding_subtitle_one_backup_card)
}
2 -> {
tvHeader.text = getText(R.string.onboarding_title_two_backup_cards)
tvBody.text = getText(R.string.onboarding_subtitle_two_backup_cards)
}
else -> {}
}
}
animator.showAddBackupCards(state, state.backupCardsNumber)
}
private fun prepareBackupView() = with(binding) {
toolbar.title = getText(R.string.onboarding_navbar_title_creating_backup)
tvHeader.show()
tvBody.show()
viewPagerBackupInfo.hide()
tabLayoutBackupInfo.hide()
imvFirstBackupCard.show()
imvSecondBackupCard.show()
}
private fun showSetAccessCode() {
@ -290,80 +313,71 @@ class OnboardingWalletFragment : Fragment(R.layout.fragment_onboarding_wallet),
accessCodeDialog?.showError(state.accessCodeError)
}
private fun showWritePrimaryCard(state: BackupState) = with(binding) {
private fun showWritePrimaryCard(state: BackupState, isSaltPay: Boolean) = with(binding) {
accessCodeDialog?.dismiss()
reInitCardsWidget(state.backupCardsNumber)
prepareViewForFinalizeStep()
cardsWidget.getSecondBackupCardView().show(state.backupCardsNumber == 2)
cardsWidget.toLeapfrog() {
cardsWidget.getFirstBackupCardView().alpha = 0.6f
cardsWidget.getSecondBackupCardView().alpha = 0.2f
}
val cardIdFormatter = CardIdFormatter(CardIdDisplayFormat.LastMasked(4))
tvHeader.text = getText(R.string.onboarding_title_prepare_origin)
tvBody.text = getString(
R.string.onboarding_subtitle_scan_primary_card_format,
state.primaryCardId?.let { cardIdFormatter.getFormattedCardId(it) }
)
layoutButtonsCommon.btnMainAction.text = getText(R.string.onboarding_button_backup_origin)
layoutButtonsCommon.btnMainAction.setOnClickListener { store.dispatch(BackupAction.WritePrimaryCard) }
if (isSaltPay) {
tvHeader.text = getText(R.string.onboarding_saltpay_title_prepare_origin)
tvBody.text = getString(R.string.onboarding_twins_interrupt_warning)
layoutButtonsCommon.btnWalletMainAction.text = getText(R.string.onboarding_saltpay_button_backup_origin)
} else {
tvHeader.text = getText(R.string.onboarding_title_prepare_origin)
tvBody.text = getString(
R.string.onboarding_subtitle_scan_primary_card_format,
state.primaryCardId?.let { cardIdFormatter.getFormattedCardId(it) },
)
layoutButtonsCommon.btnWalletMainAction.text = getText(R.string.onboarding_button_backup_origin)
}
layoutButtonsCommon.btnWalletMainAction.setOnClickListener { store.dispatch(BackupAction.WritePrimaryCard) }
animator.showWritePrimaryCard(state)
}
private fun prepareViewForFinalizeStep() = with(binding) {
layoutButtonsAddCards.root.hide()
layoutButtonsCommon.root.show()
imvFirstBackupCard.show()
imvSecondBackupCard.show()
toolbar.title = getText(R.string.onboarding_button_finalize_backup)
layoutButtonsAddCards.root.hide()
layoutButtonsCommon.root.show()
layoutButtonsCommon.btnWalletAlternativeAction.hide()
imvCardBackground.hide()
cardsWidget.toLeapfrog()
layoutButtonsCommon.btnAlternativeAction.hide()
imvFirstBackupCard.show()
imvSecondBackupCard.show()
}
private fun showWriteBackupCard(state: BackupState) = with(binding) {
private fun showWriteBackupCard(state: BackupState, isSaltPay: Boolean) = with(binding) {
prepareViewForFinalizeStep()
cardsWidget.getSecondBackupCardView().show(state.backupCardsNumber == 2)
val cardNumber = (state.backupStep as? BackupStep.WriteBackupCard)?.cardNumber ?: 1
when (cardNumber) {
1 -> cardsWidget.leapfrogWidget.leap() {
cardsWidget.getOriginCardView().alpha = 0.4f
cardsWidget.getFirstBackupCardView().alpha = 0.2f
cardsWidget.getSecondBackupCardView().alpha = 1f
}
2 -> cardsWidget.leapfrogWidget.leap() {
cardsWidget.getOriginCardView().alpha = 0.4f
cardsWidget.getFirstBackupCardView().alpha = 0.2f
cardsWidget.getSecondBackupCardView().alpha = 1f
}
if (isSaltPay) {
tvHeader.text = getString(R.string.onboarding_saltpay_title_backup_card)
tvBody.text = getString(R.string.onboarding_twins_interrupt_warning)
layoutButtonsCommon.btnWalletMainAction.text = getString(R.string.onboarding_saltpay_title_backup_card)
} else {
val cardIdFormatter = CardIdFormatter(CardIdDisplayFormat.LastMasked(4))
tvHeader.text = getString(R.string.onboarding_title_backup_card_format, cardNumber)
tvBody.text = getString(
R.string.onboarding_subtitle_scan_backup_card_format,
cardIdFormatter.getFormattedCardId(state.backupCardIds[cardNumber - 1]),
)
layoutButtonsCommon.btnWalletMainAction.text = getString(
R.string.onboarding_button_backup_card_format,
cardNumber,
)
}
val cardIdFormatter = CardIdFormatter(CardIdDisplayFormat.LastMasked(4))
tvHeader.text =
getString(R.string.onboarding_title_backup_card_format, cardNumber)
tvBody.text = getString(
R.string.onboarding_subtitle_scan_backup_card_format,
cardIdFormatter.getFormattedCardId(state.backupCardIds[cardNumber - 1])
)
layoutButtonsCommon.btnMainAction.text = getString(
R.string.onboarding_button_backup_card_format,
cardNumber
)
layoutButtonsCommon.btnMainAction.setOnClickListener {
layoutButtonsCommon.btnWalletMainAction.setOnClickListener {
store.dispatch(BackupAction.WriteBackupCard(cardNumber))
}
animator.showWriteBackupCard(state, cardNumber)
}
private fun showSuccess() = with(binding) {
internal fun showSuccess() = with(binding) {
toolbar.title = getString(R.string.onboarding_done_header)
tvHeader.text = getText(R.string.onboarding_done_header)
tvHeader.show()
@ -372,29 +386,40 @@ class OnboardingWalletFragment : Fragment(R.layout.fragment_onboarding_wallet),
tabLayoutBackupInfo.hide()
tvBody.text = getText(R.string.onboarding_subtitle_success_tangem_wallet_onboarding)
layoutButtonsCommon.btnMainAction.text = getText(R.string.onboarding_button_continue_wallet)
layoutButtonsCommon.btnAlternativeAction.hide()
layoutButtonsCommon.btnMainAction.setOnClickListener {
vConfetti.lavConfetti.cancelAnimation()
vConfetti.lavConfetti.hide()
layoutButtonsCommon.btnWalletMainAction.text = getText(R.string.onboarding_button_continue_wallet)
layoutButtonsCommon.btnWalletAlternativeAction.hide()
layoutButtonsCommon.btnWalletMainAction.setOnClickListener {
showConfetti(false)
store.dispatch(OnboardingWalletAction.FinishOnboarding)
}
cardsWidget.leapfrogWidget.fold {
animator.showSuccess {
flCardsContainer.hide()
imvCardBackground.hide()
vConfetti.lavConfetti.show()
vConfetti.lavConfetti.playAnimation()
showConfetti(true)
imvSuccess.alpha = 0f
imvSuccess.show()
imvSuccess.animate()?.alpha(1f)?.duration = 400
}
}
imvSuccess.animate()
?.alpha(1f)
?.setDuration(400)
internal fun showConfetti(show: Boolean) = with(binding.vConfetti) {
lavConfetti.show(show)
if (show) {
lavConfetti.playAnimation()
} else {
lavConfetti.cancelAnimation()
}
}
override fun handleOnBackPressed() {
store.dispatch(OnboardingWalletAction.OnBackPressed)
}
}
private fun getDeviceScaleFactor(): Float {
val typedValue = TypedValue()
resources.getValue(R.dimen.device_scale_factor_for_twins_welcome, typedValue, true)
return typedValue.float
}
}

View file

@ -16,82 +16,100 @@ class WalletCardsWidget(
val getTopOfAnchorViewForActivateState: () -> Float,
) {
var currentState: WidgetState? = null
private val animDuration: Long = 400
fun toWelcome(animate: Boolean = true, onEnd: () -> Unit = {}) {
var currentState: WidgetState? = null
private set
fun toWelcome(
animate: Boolean = true,
onEnd: () -> Unit = {},
) {
if (currentState == WidgetState.WELCOME) return
currentState = WidgetState.WELCOME
val animator = createAnimator(animate, onEnd)
animator.playTogether(
createAnimator(BackupCardType.ORIGIN, createWelcomeProperties(BackupCardType.ORIGIN)),
createAnimator(BackupCardType.FIRST_BACKUP, createWelcomeProperties(BackupCardType.FIRST_BACKUP)),
createAnimator(BackupCardType.SECOND_BACKUP, createWelcomeProperties(BackupCardType.SECOND_BACKUP))
)
val animator = createAnimator(animate, onEnd = onEnd)
animator.playTogether(createAnimators(::createWelcomeProperties))
leapfrogWidget.fold { animator.start() }
}
fun toFolded(animate: Boolean = true, onEnd: () -> Unit = {}) {
fun toFolded(
animate: Boolean = true,
duration: Long = animDuration,
onEnd: () -> Unit = {},
) {
if (currentState == WidgetState.FOLDED) return
currentState = WidgetState.FOLDED
val animator = createAnimator(animate, onEnd)
animator.playTogether(
createAnimator(BackupCardType.ORIGIN, createLeapfrogProperties(BackupCardType.ORIGIN)),
createAnimator(BackupCardType.FIRST_BACKUP, createLeapfrogProperties(BackupCardType.FIRST_BACKUP)),
createAnimator(BackupCardType.SECOND_BACKUP, createLeapfrogProperties(BackupCardType.SECOND_BACKUP))
)
leapfrogWidget.fold(animate) { animator.start() }
val animator = createAnimator(animate, duration) {
leapfrogWidget.fold(animate, onEnd)
}
animator.playTogether(createAnimators(::createLeapfrogProperties))
animator.start()
}
fun toFan(animate: Boolean = true, onEnd: () -> Unit = {}) {
fun toFan(
animate: Boolean = true,
onEnd: () -> Unit = {},
) {
if (currentState == WidgetState.FAN) return
currentState = WidgetState.FAN
val animator = createAnimator(animate, onEnd)
animator.playTogether(
createAnimator(BackupCardType.ORIGIN, createFanProperties(BackupCardType.ORIGIN)),
createAnimator(BackupCardType.FIRST_BACKUP, createFanProperties(BackupCardType.FIRST_BACKUP)),
createAnimator(BackupCardType.SECOND_BACKUP, createFanProperties(BackupCardType.SECOND_BACKUP))
)
val animator = createAnimator(animate, onEnd = onEnd)
animator.playTogether(createAnimators(::createFanProperties))
leapfrogWidget.fold { animator.start() }
}
fun toLeapfrog(animate: Boolean = true, onEnd: () -> Unit = {}) {
fun toLeapfrog(
animate: Boolean = true,
onEndFold: () -> Unit = {},
onEnd: () -> Unit = {},
) {
if (currentState == WidgetState.LEAPFROG) return
currentState = WidgetState.LEAPFROG
val animator = createAnimator(animate, onEnd)
animator.playTogether(
createAnimator(BackupCardType.ORIGIN, createLeapfrogProperties(BackupCardType.ORIGIN)),
createAnimator(BackupCardType.FIRST_BACKUP, createLeapfrogProperties(BackupCardType.FIRST_BACKUP)),
createAnimator(BackupCardType.SECOND_BACKUP, createLeapfrogProperties(BackupCardType.SECOND_BACKUP))
)
leapfrogWidget.fold {
animator.doOnEnd {
val onAnimatorEnd = {
leapfrogWidget.fold(animate) {
leapfrogWidget.initViews()
leapfrogWidget.unfold()
onEndFold()
leapfrogWidget.unfold(animate, onEnd)
}
animator.start()
}
val animator = createAnimator(animate, onEnd = onAnimatorEnd)
animator.playTogether(createAnimators(::createLeapfrogProperties))
animator.start()
}
private fun createAnimator(animate: Boolean, onEnd: () -> Unit): AnimatorSet {
private fun createAnimators(propertyFactory: (BackupCardType) -> CardProperties): List<ObjectAnimator> {
return getCardTypesTypes().map { createCardAnimator(it, propertyFactory(it)) }
}
private fun getCardTypesTypes(): List<BackupCardType> = when (leapfrogWidget.getViewsCount()) {
2 -> listOf(BackupCardType.ORIGIN, BackupCardType.FIRST_BACKUP)
3 -> listOf(BackupCardType.ORIGIN, BackupCardType.FIRST_BACKUP, BackupCardType.SECOND_BACKUP)
else -> throw UnsupportedOperationException()
}
private fun createAnimator(animate: Boolean, duration: Long = animDuration, onEnd: () -> Unit): AnimatorSet {
return AnimatorSet().apply {
duration = if (animate) 400 else 0
this.duration = if (animate) duration else 0
doOnEnd { onEnd() }
}
}
private fun createAnimator(
private fun createCardAnimator(
cardType: BackupCardType,
properties: CardProperties,
): ObjectAnimator {
val view = getLeapViewByCardNumber(cardType).view
val animator = ObjectAnimator.ofPropertyValuesHolder(view,
*properties.createValuesHolders().toTypedArray())
val view = getLeapViewByCard(cardType).view
val animator = ObjectAnimator.ofPropertyValuesHolder(
view,
*properties.createValuesHolders().toTypedArray(),
)
view.elevation = properties.elevation
return animator
}
@ -176,7 +194,7 @@ class WalletCardsWidget(
private fun createActivateProperties(cardType: BackupCardType): CardProperties {
val topOfAnchorView = getTopOfAnchorViewForActivateState()
val twinProperties = CardProperties.from(getLeapViewByCardNumber(cardType).state)
val twinProperties = CardProperties.from(getLeapViewByCard(cardType).state)
return twinProperties.copy(
xTranslation = twinProperties.xTranslation,
yTranslation = twinProperties.yTranslation - topOfAnchorView,
@ -185,33 +203,47 @@ class WalletCardsWidget(
)
}
private fun getLeapViewByCardNumber(cardType: BackupCardType): LeapView {
return when (cardType) {
BackupCardType.ORIGIN -> leapfrogWidget.getViewByPosition(0)
BackupCardType.FIRST_BACKUP -> leapfrogWidget.getViewByPosition(1)
BackupCardType.SECOND_BACKUP -> leapfrogWidget.getViewByPosition(2)
}
}
@Throws(UnsupportedOperationException::class)
fun getOriginCardView(): ImageView {
return getLeapViewByCardNumber(BackupCardType.ORIGIN).view as ImageView
return getLeapViewByCard(BackupCardType.ORIGIN).view as ImageView
}
@Throws(UnsupportedOperationException::class)
fun getFirstBackupCardView(): ImageView {
return getLeapViewByCardNumber(BackupCardType.FIRST_BACKUP).view as ImageView
return getLeapViewByCard(BackupCardType.FIRST_BACKUP).view as ImageView
}
@Throws(UnsupportedOperationException::class)
fun getSecondBackupCardView(): ImageView {
return getLeapViewByCardNumber(BackupCardType.SECOND_BACKUP).view as ImageView
return getLeapViewByCard(BackupCardType.SECOND_BACKUP).view as ImageView
}
@Throws(UnsupportedOperationException::class)
private fun getLeapViewByCard(cardType: BackupCardType): LeapView {
fun getByIndex(index: Int): LeapView {
return leapfrogWidget.getViewByPosition(leapfrogWidget.getViewPositionByIndex(index))
}
return when (leapfrogWidget.getViewsCount()) {
2 -> when (cardType) {
BackupCardType.ORIGIN -> getByIndex(1)
BackupCardType.FIRST_BACKUP -> getByIndex(0)
BackupCardType.SECOND_BACKUP -> throw UnsupportedOperationException()
}
3 -> when (cardType) {
BackupCardType.ORIGIN -> getByIndex(2)
BackupCardType.FIRST_BACKUP -> getByIndex(1)
BackupCardType.SECOND_BACKUP -> getByIndex(0)
}
else -> throw UnsupportedOperationException()
}
}
enum class WidgetState { WELCOME, FOLDED, FAN, LEAPFROG }
}
enum class BackupCardType { ORIGIN, FIRST_BACKUP, SECOND_BACKUP }
private data class CardProperties(
val xTranslation: Float = 0f,
val yTranslation: Float = 0f,

View file

@ -14,7 +14,7 @@ class ConfirmDiscardingBackupDialog {
setTitle(R.string.welcome_interrupted_backup_discard_title)
setMessage(R.string.welcome_interrupted_backup_discard_message)
setPositiveButton(R.string.welcome_interrupted_backup_discard_resume) { _, _ ->
store.dispatch(BackupAction.ResumeBackup)
store.dispatch(BackupAction.ResumeFoundUnfinishedBackup)
}
setNegativeButton(R.string.welcome_interrupted_backup_discard_discard) { _, _ ->
store.dispatch(BackupAction.DiscardSavedBackup)

View file

@ -16,7 +16,7 @@ class UnfinishedBackupFoundDialog {
setMessage(R.string.welcome_interrupted_backup_alert_message)
setPositiveButton(R.string.welcome_interrupted_backup_alert_resume) { _, _ ->
store.dispatch(GlobalAction.HideDialog)
store.dispatch(BackupAction.ResumeBackup)
store.dispatch(BackupAction.ResumeFoundUnfinishedBackup)
}
setNegativeButton(R.string.welcome_interrupted_backup_alert_discard) { _, _ ->
store.dispatch(GlobalAction.HideDialog)

View file

@ -1,13 +0,0 @@
package com.tangem.tap.features.onboarding.service
//class OnboardingWalletService(
// override val fromScreen: AppScreen,
// override var scanResponse: ScanResponse,
// cardInfoStorage: UsedCardsPrefStorage,
// walletManagerFactory: WalletManagerFactory,
//) : ProductOnboardingService(cardInfoStorage, walletManagerFactory) {
//
// override fun navigateToScreen(): AppScreen {
// return AppScreen.OnboardingWallet
// }
//}

View file

@ -245,8 +245,7 @@ private fun sendTransaction(
}
is SimpleResult.Failure -> {
store.state.globalState.feedbackManager?.infoHolder?.updateOnSendError(
wallet = walletManager.wallet,
host = walletManager.currentHost,
walletManager = walletManager,
amountToSend = amountToSend,
feeAmount = feeAmount,
destinationAddress = destinationAddress,

View file

@ -1,11 +1,37 @@
package com.tangem.tap.features.tokens.addCustomToken.compose
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.*
import androidx.compose.material.BottomSheetScaffold
import androidx.compose.material.BottomSheetScaffoldState
import androidx.compose.material.BottomSheetState
import androidx.compose.material.BottomSheetValue
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ExtendedFloatingActionButton
import androidx.compose.material.FabPosition
import androidx.compose.material.FloatingActionButtonDefaults
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Scaffold
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.runtime.*
import androidx.compose.material.rememberBottomSheetScaffoldState
import androidx.compose.material.rememberScaffoldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
@ -15,14 +41,23 @@ import androidx.compose.ui.unit.dp
import com.tangem.domain.AddCustomTokenError
import com.tangem.domain.common.form.DataField
import com.tangem.domain.common.form.FieldId
import com.tangem.domain.features.addCustomToken.CustomTokenFieldId.*
import com.tangem.domain.features.addCustomToken.CustomTokenFieldId.ContractAddress
import com.tangem.domain.features.addCustomToken.CustomTokenFieldId.Decimals
import com.tangem.domain.features.addCustomToken.CustomTokenFieldId.DerivationPath
import com.tangem.domain.features.addCustomToken.CustomTokenFieldId.Name
import com.tangem.domain.features.addCustomToken.CustomTokenFieldId.Network
import com.tangem.domain.features.addCustomToken.CustomTokenFieldId.Symbol
import com.tangem.domain.features.addCustomToken.redux.AddCustomTokenAction
import com.tangem.domain.features.addCustomToken.redux.AddCustomTokenState
import com.tangem.domain.features.addCustomToken.redux.ScreenState
import com.tangem.domain.features.addCustomToken.redux.ViewStates
import com.tangem.domain.redux.domainStore
import com.tangem.tap.common.compose.*
import com.tangem.tap.common.moduleMessage.ModuleMessageConverter
import com.tangem.tap.common.compose.AddCustomTokenWarning
import com.tangem.tap.common.compose.ClosePopupTrigger
import com.tangem.tap.common.compose.ComposeDialogManager
import com.tangem.tap.common.compose.ToggledRippleTheme
import com.tangem.tap.common.compose.keyboardAsState
import com.tangem.tap.domain.moduleMessage.ModuleMessageConverter
import com.tangem.tap.features.tokens.addCustomToken.compose.test.TestCase
import com.tangem.tap.features.tokens.addCustomToken.compose.test.TestCasesList
import com.tangem.wallet.R

View file

@ -278,7 +278,7 @@ class TokensMiddleware {
val factory = store.state.globalState.tapWalletManager.walletManagerFactory
val derivationStyle = scanResponse.card.derivationStyle
val addActions = currencyList.mapNotNull { currency ->
val addActions = currencyList.mapIndexedNotNull { index, currency ->
when (currency) {
is Currency.Blockchain -> {
val derivationPath = currency.derivationPath?.let { DerivationPath(it) }
@ -293,12 +293,12 @@ class TokensMiddleware {
scanResponse = scanResponse,
blockchain = currency.blockchain,
derivationParams = derivationParams,
) ?: return@mapNotNull null
) ?: return@mapIndexedNotNull null
val blockchainNetwork = BlockchainNetwork.fromWalletManager(walletManager)
WalletAction.MultiWallet.AddBlockchain(
blockchain = blockchainNetwork,
walletManager = walletManager,
save = true,
save = index == currencyList.lastIndex,
)
}
is Currency.Token -> {
@ -309,7 +309,7 @@ class TokensMiddleware {
WalletAction.MultiWallet.AddToken(
token = currency.token,
blockchain = blockchainNetwork,
save = true,
save = index == currencyList.lastIndex,
)
}
}

View file

@ -30,10 +30,14 @@ import com.tangem.tap.store
import com.tangem.wallet.R
import com.tangem.wallet.databinding.FragmentAddTokensBinding
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.rekotlin.StoreSubscriber
class AddTokensFragment : Fragment(R.layout.fragment_add_tokens),
StoreSubscriber<TokensState> {
@ -123,7 +127,7 @@ class AddTokensFragment : Fragment(R.layout.fragment_add_tokens),
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.popular_tokens, menu)
inflater.inflate(R.menu.menu_popular_tokens, menu)
val addCustomTokenAllowed = store.state.tokensState.allowToAdd
menu.findItem(R.id.menu_navigate_add_custom_token).isVisible = addCustomTokenAllowed

View file

@ -59,9 +59,9 @@ sealed interface Currency {
fun isToken(): Boolean = this is Token
fun toTokenResponse(): TokenResponse {
return TokenResponse(
id = coinId ?: "",
id = coinId,
networkId = blockchain.toNetworkId(),
derivationPath = derivationPath ?: DERIVATION_PATH_RAW_VALUE,
derivationPath = derivationPath,
name = currencyName,
symbol = currencySymbol,
decimals = decimals,
@ -70,7 +70,6 @@ sealed interface Currency {
}
companion object {
private const val DERIVATION_PATH_RAW_VALUE = "m/44/0'/0/0"
fun fromBlockchainNetwork(
blockchainNetwork: BlockchainNetwork,
token: com.tangem.blockchain.common.Token? = null,
@ -111,12 +110,9 @@ sealed interface Currency {
)
}
fun fromTokenResponse(tokenResponse: TokenResponse): Currency {
val derivationPath = if (tokenResponse.derivationPath == DERIVATION_PATH_RAW_VALUE) {
null
} else {
tokenResponse.derivationPath
}
fun fromTokenResponse(tokenResponse: TokenResponse): Currency? {
val blockchain = com.tangem.blockchain.common.Blockchain.fromNetworkId(tokenResponse.networkId)
?: return null
return when {
tokenResponse.contractAddress != null -> Token(
com.tangem.blockchain.common.Token(
@ -126,12 +122,12 @@ sealed interface Currency {
decimals = tokenResponse.decimals,
id = tokenResponse.id,
),
blockchain = com.tangem.blockchain.common.Blockchain.fromNetworkId(tokenResponse.networkId)!!,
derivationPath = derivationPath,
blockchain = blockchain,
derivationPath = tokenResponse.derivationPath,
)
else -> Blockchain(
blockchain = com.tangem.blockchain.common.Blockchain.fromNetworkId(tokenResponse.networkId)!!,
derivationPath = derivationPath,
blockchain = blockchain,
derivationPath = tokenResponse.derivationPath,
)
}
}

View file

@ -5,8 +5,7 @@ sealed class WalletWarning(
) {
data class ExistentialDeposit(
val currencyName: String,
val currencySymbols: String,
val existentialDepositString: String,
val edStringValueWithSymbol: String,
) : WalletWarning(1)
object TransactionInProgress : WalletWarning(10)

View file

@ -39,7 +39,7 @@ sealed class WalletAction : Action {
data class NoAccount(
val wallet: Wallet,
val blockchain: BlockchainNetwork,
val amountToCreateAccount: String
val amountToCreateAccount: String,
) : WalletAction()
data class Failure(val wallet: Wallet, val errorMessage: String? = null) : WalletAction()
@ -47,6 +47,10 @@ sealed class WalletAction : Action {
data class SetArtworkId(val artworkId: String?) : WalletAction()
sealed class UserTokens : WalletAction() {
object Loading : UserTokens()
object Loaded : UserTokens()
}
sealed class MultiWallet : WalletAction() {
data class SetIsMultiwalletAllowed(val isMultiwalletAllowed: Boolean) : MultiWallet()

View file

@ -45,6 +45,7 @@ data class WalletState(
val totalBalance: TotalBalance? = null,
val showBackupWarning: Boolean = false,
val missingDerivations: List<BlockchainNetwork> = emptyList(),
val loadingUserTokens: Boolean = false,
) : StateType {
// if you do not delegate - the application crashes on startup,
@ -192,21 +193,28 @@ data class WalletState(
fun removeWallet(walletData: WalletData?): WalletState {
if (walletData == null) return this
return if (walletData.currency is Currency.Blockchain) {
val walletStores = wallets.filterNot {
it.blockchainNetwork.blockchain == walletData.currency.blockchain
&& it.blockchainNetwork.derivationPath == walletData.currency.derivationPath
return when (val currency = walletData.currency) {
is Currency.Blockchain -> {
val walletStores = wallets.filterNot {
it.blockchainNetwork.blockchain == currency.blockchain
&& it.blockchainNetwork.derivationPath == currency.derivationPath
}
copy(wallets = walletStores)
.updateTotalBalance()
.updateProgressState()
}
is Currency.Token -> {
val walletStore = getWalletStore(walletData.currency)
val walletDataList = walletStore?.walletsData
?.filterNot { it.currency == walletData.currency }
?: emptyList()
val updatedWalletManager = walletStore?.walletManager?.also { it.removeToken(currency.token) }
val updatedWalletStore = walletStore?.copy(
walletsData = walletDataList,
walletManager = updatedWalletManager,
)
updateWalletStore(updatedWalletStore)
}
copy(wallets = walletStores)
.updateTotalBalance()
.updateProgressState()
} else {
val walletStore = getWalletStore(walletData.currency)
val walletDataList = walletStore?.walletsData
?.filterNot { it.currency == walletData.currency }
?: emptyList()
val updatedWalletStore = walletStore?.copy(walletsData = walletDataList)
updateWalletStore(updatedWalletStore)
}
}
@ -352,8 +360,7 @@ data class WalletData(
if (existentialDepositString != null) {
val warning = WalletWarning.ExistentialDeposit(
currencyName = currency.currencyName,
currencySymbols = currency.currencySymbol,
existentialDepositString = existentialDepositString,
edStringValueWithSymbol = "$existentialDepositString ${currency.currencySymbol}",
)
walletWarnings.add(warning)
}

View file

@ -143,8 +143,8 @@ class MultiWalletMiddleware {
is WalletAction.MultiWallet.ShowWalletBackupWarning -> Unit
is WalletAction.MultiWallet.BackupWallet -> {
store.state.globalState.scanResponse?.let {
store.dispatch(GlobalAction.Onboarding.Start(it, canSkipBackup = false))
store.dispatch(NavigationAction.NavigateTo(AppScreen.OnboardingWallet))
store.dispatch(GlobalAction.Onboarding.Start(it, fromHomeScreen = false))
}
}
is WalletAction.MultiWallet.ScanToGetDerivations -> {

View file

@ -135,7 +135,19 @@ class WalletMiddleware {
.plus(Currency.Blockchain(wallet.blockchain, wallet.publicKey.derivationPath?.rawPath))
}
action.coinsList != null -> action.coinsList
else -> walletState.walletsData.map { it.currency }
else -> {
if (walletState.isMultiwalletAllowed) {
walletState.walletsData.map { it.currency }
} else {
val derivationPath = walletState.primaryWallet?.currency?.derivationPath
val primaryBlockchain = walletState.primaryBlockchain
val primaryToken = walletState.primaryToken
listOfNotNull(
primaryBlockchain?.let { Currency.Blockchain(it, derivationPath) },
primaryToken?.let { Currency.Token(it, primaryBlockchain!!, derivationPath) },
)
}
}
}
val ratesResult = globalState.tapWalletManager.rates.loadFiatRate(
currencyId = appCurrencyId,

View file

@ -65,7 +65,7 @@ class MultiWalletReducer {
}
val selectedCurrency = if (!state.isMultiwalletAllowed) {
wallets[0].walletsData[0].currency
wallets.firstOrNull()?.walletsData?.firstOrNull()?.currency
} else {
state.selectedCurrency
}

View file

@ -21,6 +21,7 @@ import com.tangem.tap.features.wallet.ui.BalanceStatus
import com.tangem.tap.features.wallet.ui.BalanceWidgetData
import com.tangem.tap.features.wallet.ui.TokenData
import com.tangem.tap.store
import java.math.BigDecimal
class OnWalletLoadedReducer {
@ -115,6 +116,8 @@ class OnWalletLoadedReducer {
private fun onSingleWalletLoaded(wallet: Wallet, walletState: WalletState): WalletState {
if (wallet.blockchain != walletState.primaryBlockchain) return walletState
// val ratesRespository = store.state.globalState.tapWalletManager.ratesRepository
// val tokenCurrency =
val fiatCurrencyName = store.state.globalState.appCurrency.code
val token = wallet.getFirstToken()
val tokenData = if (token != null) {
@ -124,10 +127,12 @@ class OnWalletLoadedReducer {
val tokenFiatAmount =
tokenFiatRate?.let { tokenAmount.value?.toFiatString(it, fiatCurrencyName) }
TokenData(
tokenAmount.value?.toFormattedCurrencyString(
token.decimals, token.symbol
amount = tokenAmount.value ?: BigDecimal.ZERO,
tokenSymbol = tokenAmount.currencySymbol, fiatAmountFormatted = tokenFiatAmount,
fiatAmount = tokenFiatRate?.let { tokenAmount.value?.toFiatValue(tokenFiatRate) },
amountFormatted = tokenAmount.value?.toFormattedCurrencyString(
token.decimals, token.symbol,
) ?: "",
tokenAmount.currencySymbol, tokenFiatAmount
)
} else {
null

View file

@ -31,6 +31,7 @@ import com.tangem.tap.features.wallet.redux.WalletStore
import com.tangem.tap.features.wallet.ui.BalanceStatus
import com.tangem.tap.features.wallet.ui.BalanceWidgetData
import org.rekotlin.Action
import timber.log.Timber
import java.math.BigDecimal
class WalletReducer {
@ -314,6 +315,8 @@ private fun internalReduce(action: Action, state: AppState): WalletState {
is WalletAction.AppCurrencyAction -> {
newState = appCurrencyReducer.reduce(action, newState)
}
is WalletAction.UserTokens.Loading -> newState = newState.copy(loadingUserTokens = true)
is WalletAction.UserTokens.Loaded -> newState = newState.copy(loadingUserTokens = false)
else -> { /* no-op */
}
}
@ -387,16 +390,11 @@ private fun setNewFiatRate(
state = state
)
} else {
val fiatRate = fiatRates.entries.firstOrNull()
val currency = fiatRate?.key ?: return state
val rate = fiatRate.value ?: return state
setSingleWalletFiatRate(
rate = rate,
rateFormatted = rateFormatter(rate),
currency = currency,
setSingleWalletFiatRates(
fiatRates = fiatRates.mapNotNullValues { it.value },
rateFormatter = rateFormatter,
appCurrency = appCurrency,
state = state
state = state,
)
}
}
@ -427,10 +425,10 @@ private fun setMultiWalletFiatRate(
walletData.copy(
currencyData = currencyData.copy(
fiatAmountFormatted = fiatAmountFormatted,
fiatAmount = fiatAmount
fiatAmount = fiatAmount,
),
fiatRate = rate,
fiatRateString = rateFormatter(rate)
fiatRateString = rateFormatter(rate),
)
}
@ -438,15 +436,51 @@ private fun setMultiWalletFiatRate(
.updateWalletsData(newWalletsData)
}
private fun setSingleWalletFiatRates(
fiatRates: Map<Currency, BigDecimal>,
appCurrency: FiatCurrency,
rateFormatter: (BigDecimal) -> String,
state: WalletState,
): WalletState {
val blockchainFiatRate = fiatRates.entries.firstOrNull { it.key.isBlockchain() }
val tokenFiatRate = fiatRates.entries.firstOrNull { it.key.isToken() }
Timber.e("Token Fiat Rate is ${tokenFiatRate ?: "NULL"}")
val updatedState = updateStateWithFiatRate(blockchainFiatRate?.toPair(), appCurrency, rateFormatter, state)
return updateStateWithFiatRate(tokenFiatRate?.toPair(), appCurrency, rateFormatter, updatedState)
}
private fun updateStateWithFiatRate(
fiatRate: Pair<Currency, BigDecimal>?,
appCurrency: FiatCurrency,
rateFormatter: (BigDecimal) -> String,
state: WalletState,
): WalletState {
return if (fiatRate != null) {
val currency = fiatRate.first
val rate = fiatRate.second
setSingleWalletFiatRate(
rate = rate,
rateFormatted = rateFormatter(rate),
currency = currency,
appCurrency = appCurrency,
state = state,
)
} else {
state
}
}
private fun setSingleWalletFiatRate(
rate: BigDecimal,
rateFormatted: String,
currency: Currency,
appCurrency: FiatCurrency,
state: WalletState
state: WalletState,
): WalletState {
val wallet = state.primaryWalletManager?.wallet ?: return state
val token = wallet.getFirstToken()
Timber.e("Working with currency: ${currency.currencyName}")
if (currency == state.primaryWallet?.currency) {
val fiatAmount = wallet.amounts[AmountType.Coin]?.value
@ -457,23 +491,38 @@ private fun setSingleWalletFiatRate(
fiatRateString = rateFormatted
)
return state.updateWalletData(walletData)
} else if (currency is Currency.Token && currency.token == token) {
Timber.e("Working with token fiat rate")
val tokenFiatAmount = wallet.getTokenAmount(token)
?.value
?.toFiatString(rate, appCurrency.code)
val tokenAmountFormatted = tokenFiatAmount?.toFiatString(rate, appCurrency.code)
val tokenData = state.primaryWallet?.currencyData?.token?.copy(
fiatAmountFormatted = tokenAmountFormatted,
fiatAmount = tokenFiatAmount,
fiatRate = rate,
fiatRateString = rateFormatted
fiatRateString = rateFormatted,
)
// ?: TokenData(
// fiatAmountFormatted = tokenAmountFormatted,
// fiatAmount = tokenFiatAmount,
// fiatRate = rate,
// fiatRateString = rateFormatted,
// amount = "",
// tokenSymbol = currency.currencySymbol
// )
Timber.e("Token Data is ${tokenData ?: "NULL"}")
val walletData = state.primaryWallet?.copy(
currencyData = state.primaryWallet.currencyData.copy(
token = tokenData
)
token = tokenData,
),
)
val wallets = walletData?.let { listOf(walletData) } ?: emptyList()
return state.updateWalletsData(wallets)
Timber.e("Wallet Data is ${walletData ?: "NULL"}")
return state.updateWalletData(walletData)
}
return state
}

View file

@ -34,9 +34,11 @@ data class BalanceWidgetData(
)
data class TokenData(
val amount: String,
val amountFormatted: String,
val amount: BigDecimal? = null,
val tokenSymbol: String,
val fiatAmount: String? = null,
val fiatAmountFormatted: String? = null,
val fiatAmount: BigDecimal? = null,
val fiatRateString: String? = null,
val fiatRate: BigDecimal? = null,
)
@ -151,11 +153,11 @@ class BalanceWidget(
groupBaseCurrency.show()
tvCurrency.text = data.token?.tokenSymbol
tvBaseCurrency.text = data.currency
tvAmount.text = if (showAmount) data.token?.amount else ""
tvAmount.text = if (showAmount) data.token?.amountFormatted else ""
tvBaseAmount.text = if (showAmount) data.amountFormatted else ""
if (showAmount) {
tvFiatAmount.show()
tvFiatAmount.text = data.token?.fiatAmount
tvFiatAmount.text = data.token?.fiatAmountFormatted
}
}

View file

@ -42,7 +42,7 @@ import com.tangem.tap.features.wallet.redux.WalletState.Companion.UNKNOWN_AMOUNT
import com.tangem.tap.features.wallet.ui.adapters.PendingTransactionsAdapter
import com.tangem.tap.features.wallet.ui.adapters.WalletDetailWarningMessagesAdapter
import com.tangem.tap.features.wallet.ui.images.load
import com.tangem.tap.features.wallet.ui.test.TestWalletDetails
import com.tangem.tap.features.wallet.ui.test.TestWallet
import com.tangem.tap.store
import com.tangem.wallet.R
import com.tangem.wallet.databinding.FragmentWalletDetailsBinding
@ -125,7 +125,7 @@ class WalletDetailsFragment : Fragment(R.layout.fragment_wallet_details),
view?.findViewById<View>(R.id.l_balance)?.let { view ->
TestActions.initFor(
view = view,
actions = TestWalletDetails.solanaRentExemptWarning()
actions = TestWallet.solanaRentExemptWarning()
)
}
}
@ -345,7 +345,7 @@ class WalletDetailsFragment : Fragment(R.layout.fragment_wallet_details),
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.wallet_details, menu)
inflater.inflate(R.menu.menu_wallet_details, menu)
}
private fun TextView.setWarningStatus(mainMessage: Int, error: String? = null) {

View file

@ -26,7 +26,6 @@ import com.tangem.tap.domain.statePrinter.printScanResponseState
import com.tangem.tap.domain.statePrinter.printWalletState
import com.tangem.tap.domain.termsOfUse.CardTou
import com.tangem.tap.features.details.redux.DetailsAction
import com.tangem.tap.features.wallet.redux.Artwork
import com.tangem.tap.features.wallet.redux.ErrorType
import com.tangem.tap.features.wallet.redux.ProgressState
import com.tangem.tap.features.wallet.redux.WalletAction
@ -42,7 +41,6 @@ import com.tangem.wallet.R
import com.tangem.wallet.databinding.FragmentWalletBinding
import org.rekotlin.StoreSubscriber
class WalletFragment : Fragment(R.layout.fragment_wallet), StoreSubscriber<WalletState> {
private lateinit var warningsAdapter: WarningMessagesAdapter
@ -54,11 +52,14 @@ class WalletFragment : Fragment(R.layout.fragment_wallet), StoreSubscriber<Walle
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
activity?.onBackPressedDispatcher?.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
store.dispatch(NavigationAction.PopBackTo(AppScreen.Home))
}
})
activity?.onBackPressedDispatcher?.addCallback(
this,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
store.dispatch(NavigationAction.PopBackTo(AppScreen.Home))
}
},
)
val inflater = TransitionInflater.from(requireContext())
enterTransition = inflater.inflateTransition(R.transition.slide_right)
exitTransition = inflater.inflateTransition(R.transition.fade)
@ -89,7 +90,7 @@ class WalletFragment : Fragment(R.layout.fragment_wallet), StoreSubscriber<Walle
}
setupWarningsRecyclerView()
walletView.changeWalletView(this, binding)
// addCustomActionOnCard()
addCustomActionOnCard()
}
private fun addCustomActionOnCard() {
@ -111,7 +112,6 @@ class WalletFragment : Fragment(R.layout.fragment_wallet), StoreSubscriber<Walle
}
}
override fun newState(state: WalletState) {
if (activity == null || view == null) return
@ -127,7 +127,7 @@ class WalletFragment : Fragment(R.layout.fragment_wallet), StoreSubscriber<Walle
walletView = MultiWalletView()
walletView.changeWalletView(this, binding)
}
!state.isMultiwalletAllowed && walletView !is SingleWalletView -> {
!state.isMultiwalletAllowed && !isSaltPay && walletView !is SingleWalletView -> {
walletView = SingleWalletView()
walletView.changeWalletView(this, binding)
}
@ -139,11 +139,11 @@ class WalletFragment : Fragment(R.layout.fragment_wallet), StoreSubscriber<Walle
if (!state.shouldShowDetails) {
binding.toolbar.menu.removeItem(R.id.details_menu)
} else if (binding.toolbar.menu.findItem(R.id.details_menu) == null) {
binding.toolbar.inflateMenu(R.menu.wallet)
binding.toolbar.inflateMenu(R.menu.menu_wallet)
}
setupNoInternetHandling(state)
setupCardImage(state.cardImage)
setupCardImage(state)
if (!isSaltPay) showWarningsIfPresent(state.mainWarningsList)
@ -168,7 +168,7 @@ class WalletFragment : Fragment(R.layout.fragment_wallet), StoreSubscriber<Walle
binding.srlWallet.isRefreshing = false
(activity as? MainActivity)?.showSnackbar(
text = R.string.wallet_notification_no_internet,
buttonTitle = R.string.common_retry
buttonTitle = R.string.common_retry,
) { store.dispatch(WalletAction.LoadData) }
}
} else {
@ -176,13 +176,24 @@ class WalletFragment : Fragment(R.layout.fragment_wallet), StoreSubscriber<Walle
}
}
private fun setupCardImage(cardImage: Artwork?) {
binding.ivCard.load(cardImage?.artworkId) {
scale(Scale.FIT)
crossfade(enable = true)
placeholder(R.drawable.card_placeholder_black)
error(R.drawable.card_placeholder_black)
fallback(R.drawable.card_placeholder_black)
private fun setupCardImage(state: WalletState) {
//TODO: SaltPay: remove hardCode
if (store.state.globalState.scanResponse?.isSaltPay() == true) {
binding.ivCard.load(R.drawable.img_salt_pay_visa) {
scale(Scale.FIT)
crossfade(enable = true)
placeholder(R.drawable.card_placeholder_black)
error(R.drawable.card_placeholder_black)
fallback(R.drawable.card_placeholder_black)
}
} else {
binding.ivCard.load(state.cardImage?.artworkId) {
scale(Scale.FIT)
crossfade(enable = true)
placeholder(R.drawable.card_placeholder_black)
error(R.drawable.card_placeholder_black)
fallback(R.drawable.card_placeholder_black)
}
}
}
@ -191,11 +202,13 @@ class WalletFragment : Fragment(R.layout.fragment_wallet), StoreSubscriber<Walle
R.id.details_menu -> {
store.dispatch(GlobalAction.UpdateFeedbackInfo(store.state.walletState.walletManagers))
store.state.globalState.scanResponse?.let { scanNoteResponse ->
store.dispatch(DetailsAction.PrepareScreen(
scanNoteResponse,
store.state.walletState.walletManagers.map { it.wallet },
CardTou(),
))
store.dispatch(
DetailsAction.PrepareScreen(
scanNoteResponse,
store.state.walletState.walletManagers.map { it.wallet },
CardTou(),
),
)
store.dispatch(NavigationAction.NavigateTo(AppScreen.Details))
true
}
@ -206,7 +219,6 @@ class WalletFragment : Fragment(R.layout.fragment_wallet), StoreSubscriber<Walle
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
if (store.state.walletState.shouldShowDetails) inflater.inflate(R.menu.wallet, menu)
if (store.state.walletState.shouldShowDetails) inflater.inflate(R.menu.menu_wallet, menu)
}
}

View file

@ -18,7 +18,7 @@ class WalletWarningConverter(
is WalletWarning.ExistentialDeposit -> {
context.getString(
R.string.warning_existential_deposit_message,
message.currencyName, message.currencySymbols, message.existentialDepositString,
message.currencyName, message.edStringValueWithSymbol,
)
}
is WalletWarning.BalanceNotEnoughForFee -> {

View file

@ -110,8 +110,12 @@ class WalletAdapter
lContent.tvExchangeRate.text = wallet.fiatRateString
?: root.getString(id = R.string.token_item_no_rate)
cardWallet.setOnClickListener {
store.dispatch(WalletAction.MultiWallet.SelectWallet(wallet))
if (wallet.walletAddresses != null) {
cardWallet.setOnClickListener {
store.dispatch(WalletAction.MultiWallet.SelectWallet(wallet))
}
} else {
cardWallet.setOnClickListener(null)
}
}
}

View file

@ -36,6 +36,23 @@ class SimpleOkDialog {
}
}.create()
}
}
fun create(dialog: AppDialog.SimpleOkErrorDialog, context: Context): AlertDialog = create(
dialog = AppDialog.SimpleOkDialog(
header = context.getString(R.string.common_error),
message = dialog.message,
onOk = dialog.onOk,
),
context = context,
)
fun create(dialog: AppDialog.SimpleOkWarningDialog, context: Context): AlertDialog = create(
dialog = AppDialog.SimpleOkDialog(
header = context.getString(R.string.common_warning),
message = dialog.message,
onOk = dialog.onOk,
),
context = context,
)
}
}

View file

@ -1,9 +1,13 @@
package com.tangem.tap.features.wallet.ui.test
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.AmountType
import com.tangem.blockchain.common.Token
import com.tangem.blockchain.common.WalletManager
import com.tangem.common.extensions.guard
import com.tangem.tap.common.TestAction
import com.tangem.tap.common.TestActions
import com.tangem.tap.common.extensions.dispatchDebugErrorNotification
import com.tangem.tap.domain.tokens.models.BlockchainNetwork
import com.tangem.tap.features.wallet.redux.WalletAction
import com.tangem.tap.store
@ -13,7 +17,7 @@ import kotlin.random.Random
/**
[REDACTED_AUTHOR]
*/
class TestWalletDetails {
class TestWallet {
companion object {
fun solanaRentExemptWarning(): List<TestAction> {
val checker = SolanaRentWarningActionEmitter()
@ -26,6 +30,80 @@ class TestWalletDetails {
"BALANCE = 0.00089089" to { checker.setMoreThanRentExemptByOne() },
)
}
fun getBlockchainBalanceActions(blockchainNetwork: BlockchainNetwork): List<TestAction> {
return getBlockchainBalanceActions(
getWalletManager(blockchainNetwork),
blockchainNetwork.blockchain.decimals(),
)
}
fun getTokenBalanceAction(blockchainNetwork: BlockchainNetwork, token: Token): List<TestAction> {
return getTokenBalanceAction(getWalletManager(blockchainNetwork), token)
}
fun getBlockchainBalanceActions(walletManager: WalletManager?, decimals: Int): List<TestAction> {
val minValue = BigDecimal.ONE.movePointLeft(decimals)
val averageValue = BigDecimal.ONE.movePointRight(decimals)
.divide(BigDecimal(2)).movePointLeft(decimals)
val maxValue = BigDecimal(2).pow(32)
return listOf(
"0.0" to { setBalance(walletManager, BigDecimal.ZERO) },
"Min value" to { setBalance(walletManager, minValue) },
"Average value" to { setBalance(walletManager, averageValue) },
"Max value" to { setBalance(walletManager, maxValue) },
"1234567890123.01234567890123" to { setBalance(walletManager, BigDecimal.ZERO) },
)
}
fun getTokenBalanceAction(walletManager: WalletManager?, token: Token): List<TestAction> {
val minValue = BigDecimal.ONE.movePointLeft(token.decimals)
val averageValue = BigDecimal.ONE.movePointRight(token.decimals)
.divide(BigDecimal(2)).movePointLeft(token.decimals)
val maxValue = BigDecimal(2).pow(32)
return listOf(
"0.0" to { setTokenBalance(walletManager, BigDecimal.ZERO, token) },
"Min value" to { setTokenBalance(walletManager, minValue, token) },
"Average value" to { setTokenBalance(walletManager, averageValue, token) },
"Max value" to { setTokenBalance(walletManager, maxValue, token) },
"1234567890123.01234567890123" to { setTokenBalance(walletManager, BigDecimal.ZERO, token) },
)
}
fun setBalance(walletManager: WalletManager?, value: BigDecimal) {
val manager = walletManager.guard {
store.dispatchDebugErrorNotification("WalletManager not found")
return
}
val amount = manager.wallet.amounts[AmountType.Coin] ?: Amount(manager.wallet.blockchain)
setBalance(manager, amount, value)
}
private fun setTokenBalance(walletManager: WalletManager?, value: BigDecimal, token: Token? = null) {
val manager = walletManager.guard {
store.dispatchDebugErrorNotification("WalletManager not found")
return
}
val token = token.guard {
store.dispatchDebugErrorNotification("Token not found")
return
}
val amount = manager.wallet.amounts[AmountType.Token(token)] ?: Amount(token)
setBalance(manager, amount, value)
}
private fun setBalance(walletManager: WalletManager, amount: Amount, value: BigDecimal) {
TestActions.testAmountInjectionForWalletManagerEnabled = true
walletManager.wallet.setAmount(amount.copy(value = value))
store.dispatch(WalletAction.LoadData)
}
private fun getWalletManager(blockchainNetwork: BlockchainNetwork): WalletManager? {
return store.state.walletState.getWalletManager(blockchainNetwork)
}
}
}

View file

@ -81,6 +81,8 @@ class MultiWalletView : WalletView() {
handleRescanWarning(binding, state.missingDerivations.isNotEmpty())
walletsAdapter.submitList(state.walletsData, state.primaryBlockchain, state.primaryToken)
binding.pbLoadingUserTokens.show(state.loadingUserTokens)
binding.btnAddToken.setOnClickListener {
val card = store.state.globalState.scanResponse!!.card
store.dispatch(

View file

@ -1,12 +1,15 @@
package com.tangem.tap.features.wallet.ui.wallet
import com.tangem.domain.common.extensions.debounce
import com.tangem.tap.common.entities.FiatCurrency
import com.tangem.tap.common.extensions.animateVisibility
import com.tangem.tap.common.extensions.formatAmountAsSpannedString
import com.tangem.tap.features.wallet.redux.ProgressState
import com.tangem.tap.features.wallet.redux.WalletAction
import com.tangem.tap.mainScope
import com.tangem.tap.store
import com.tangem.wallet.databinding.LayoutSingleWalletBalanceBinding
import org.rekotlin.Action
import java.math.BigDecimal
data class SaltPayBalanceWidgetData(
@ -26,7 +29,7 @@ class SaltPayBalanceWidget(
veilBalance.veil()
veilBalanceCrypto.veil()
} else {
veilBalance.unVeil()
// veilBalance.unVeil()
veilBalanceCrypto.unVeil()
}
tvProcessing.animateVisibility(
@ -35,9 +38,18 @@ class SaltPayBalanceWidget(
veilBalanceCrypto.animateVisibility(
show = data.state != ProgressState.Error,
)
tvBalance.text = data.fiatAmount?.formatAmountAsSpannedString(
currencySymbol = data.fiatCurrency?.symbol ?: "",
)
if (data.fiatAmount == null) {
//TODO: SaltPay: A tricky solution to the problem with displaying rates
// If the rates are loaded after the walletManager.update() is completely updated,
// then this problem can be avoided
actionDebouncer(WalletAction.LoadFiatRate())
} else {
veilBalance.unVeil()
tvBalance.text = data.fiatAmount?.formatAmountAsSpannedString(
currencySymbol = data.fiatCurrency?.symbol ?: "",
)
}
tvBalanceCrypto.text = data.currency
tvCurrencyName.text = data.fiatCurrency?.code
@ -48,4 +60,5 @@ class SaltPayBalanceWidget(
}
}
private val actionDebouncer = debounce<Action>(500, mainScope) { store.dispatch(it) }

View file

@ -2,11 +2,12 @@ package com.tangem.tap.features.wallet.ui.wallet
import com.tangem.tap.common.extensions.hide
import com.tangem.tap.common.extensions.show
import com.tangem.tap.features.wallet.redux.WalletData
import com.tangem.tap.features.wallet.redux.WalletState
import com.tangem.tap.features.wallet.ui.TokenData
import com.tangem.tap.features.wallet.ui.WalletFragment
import com.tangem.tap.store
import com.tangem.wallet.databinding.FragmentWalletBinding
import timber.log.Timber
class SaltPaySingleWalletView : WalletView() {
override fun changeWalletView(fragment: WalletFragment, binding: FragmentWalletBinding) {
@ -18,8 +19,10 @@ class SaltPaySingleWalletView : WalletView() {
private fun showSingleWalletView(binding: FragmentWalletBinding) = with(binding) {
rvMultiwallet.hide()
btnAddToken.hide()
rowButtons.hide()
rvPendingTransaction.hide()
tvTwinCardNumber.hide()
pbLoadingUserTokens.hide()
lCardBalance.root.hide()
lAddress.root.hide()
lSingleWalletBalance.root.show()
@ -30,20 +33,21 @@ class SaltPaySingleWalletView : WalletView() {
override fun onNewState(state: WalletState) {
val binding = binding ?: return
state.primaryWallet ?: return
val tokenData = state.primaryWallet?.currencyData?.token ?: return
setupBalance(state, state.primaryWallet, binding)
setupBalance(state, tokenData, binding)
}
private fun setupBalance(state: WalletState, primaryWallet: WalletData, binding: FragmentWalletBinding) {
private fun setupBalance(state: WalletState, tokenData: TokenData, binding: FragmentWalletBinding) {
binding.lSingleWalletBalance.root.show()
Timber.e("Current address is ${state.primaryWalletManager?.wallet?.address}")
SaltPayBalanceWidget(
binding = binding.lSingleWalletBalance,
data = SaltPayBalanceWidgetData(
state = state.state,
currencySymbol = primaryWallet.currencyData.currencySymbol,
currency = primaryWallet.currencyData.amountFormatted,
fiatAmount = primaryWallet.currencyData.fiatAmount,
currencySymbol = tokenData.tokenSymbol,
currency = tokenData.amountFormatted,
fiatAmount = tokenData.fiatAmount, // TODO: show fiatAmount
fiatCurrency = store.state.globalState.appCurrency,
),
).setup()

View file

@ -6,6 +6,7 @@ import androidx.recyclerview.widget.LinearLayoutManager
import com.tangem.domain.common.TwinCardNumber
import com.tangem.tap.common.extensions.beginDelayedTransition
import com.tangem.tap.common.extensions.fitChipsByGroupWidth
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.features.onboarding.products.twins.redux.TwinCardsState
@ -36,6 +37,7 @@ class SingleWalletView : WalletView() {
rvMultiwallet.hide()
btnAddToken.hide()
rvPendingTransaction.hide()
pbLoadingUserTokens.hide()
lCardBalance.root.show()
lAddress.root.show()
lSingleWalletBalance.root.hide()
@ -85,11 +87,7 @@ class SingleWalletView : WalletView() {
private fun setupTwinCards(twinCardsState: TwinCardsState?, binding: FragmentWalletBinding) = with(binding) {
twinCardsState?.cardNumber?.let { cardNumber ->
tvTwinCardNumber.show()
val number = when (cardNumber) {
TwinCardNumber.First -> 1
TwinCardNumber.Second -> 2
}
tvTwinCardNumber.text = fragment?.getString(R.string.wallet_twins_chip_format, number, 2)
tvTwinCardNumber.text = tvTwinCardNumber.getQuantityString(R.plurals.card_label_card_count, 2)
}
if (twinCardsState?.cardNumber == null) {
tvTwinCardNumber.hide()