Updated on 2026-08-14

This commit is contained in:
Tangem 2023-05-02 13:37:47 +03:00
commit d5500f4d38
275 changed files with 8110 additions and 1823 deletions

View file

@ -0,0 +1,12 @@
package com.tangem.tap.features.customtoken.api.featuretoggles
/**
* Add custom token feature toggles
*
[REDACTED_AUTHOR]
*/
interface CustomTokenFeatureToggles {
/** Availability of redesigned screen (internal feature) */
val isRedesignedScreenEnabled: Boolean
}

View file

@ -0,0 +1,57 @@
package com.tangem.tap.features.customtoken.impl.data
import com.tangem.blockchain.common.Blockchain
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.domain.common.extensions.supportedBlockchains
import com.tangem.domain.common.extensions.toNetworkId
import com.tangem.tap.features.customtoken.impl.data.converters.FoundTokenConverter
import com.tangem.tap.features.customtoken.impl.domain.CustomTokenRepository
import com.tangem.tap.features.customtoken.impl.domain.models.FoundToken
import com.tangem.tap.proxy.AppStateHolder
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
/**
* Default implementation of custom token repository
*
* @property tangemTechApi TangemTech API
* @property dispatchers coroutine dispatchers provider
* @property reduxStateHolder redux state holder
*
[REDACTED_AUTHOR]
*/
class DefaultCustomTokenRepository(
private val tangemTechApi: TangemTechApi,
private val dispatchers: CoroutineDispatcherProvider,
private val reduxStateHolder: AppStateHolder,
) : CustomTokenRepository {
override suspend fun findToken(address: String, networkId: String?): FoundToken {
val supportedTokenNetworkIds = requireNotNull(reduxStateHolder.scanResponse?.card)
.supportedBlockchains()
.filter(Blockchain::canHandleTokens)
.map(Blockchain::toNetworkId)
return withContext(dispatchers.io) {
val foundCoin = tangemTechApi.getCoins(
contractAddress = address,
networkIds = networkId ?: supportedTokenNetworkIds.joinToString(separator = ","),
)
.coins.firstNotNullOfOrNull { coin ->
val networksWithTheSameAddress = coin.networks.filter { network ->
(network.contractAddress != null || network.decimalCount != null) &&
network.contractAddress?.equals(address, ignoreCase = true) == true &&
supportedTokenNetworkIds.contains(network.networkId)
}
if (networksWithTheSameAddress.isNotEmpty()) {
coin.copy(networks = networksWithTheSameAddress)
} else {
null
}
}
foundCoin?.let(FoundTokenConverter::convert) ?: error("Token not found")
}
}
}

View file

@ -0,0 +1,28 @@
package com.tangem.tap.features.customtoken.impl.data.converters
import com.tangem.datasource.api.tangemTech.models.CoinsResponse
import com.tangem.tap.features.customtoken.impl.domain.models.FoundToken
import com.tangem.utils.converter.Converter
/**
* Converter between data model [CoinsResponse.Coin] and domain model [FoundToken]
*
[REDACTED_AUTHOR]
*/
object FoundTokenConverter : Converter<CoinsResponse.Coin, FoundToken> {
override fun convert(value: CoinsResponse.Coin): FoundToken {
return FoundToken(
id = value.id,
name = value.name,
symbol = value.symbol,
network = value.networks.firstOrNull()?.let { network ->
FoundToken.Network(
id = network.networkId,
address = requireNotNull(network.contractAddress),
decimalCount = requireNotNull(network.decimalCount).toString(),
)
} ?: error("Found token networks is empty"),
)
}
}

View file

@ -0,0 +1,24 @@
package com.tangem.tap.features.customtoken.impl.di
import com.tangem.core.featuretoggle.manager.FeatureTogglesManager
import com.tangem.tap.features.customtoken.api.featuretoggles.CustomTokenFeatureToggles
import com.tangem.tap.features.customtoken.impl.featuretoggles.DefaultCustomTokenFeatureToggles
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
/**
[REDACTED_AUTHOR]
*/
@Module
@InstallIn(SingletonComponent::class)
internal object CustomTokenFeatureTogglesModule {
@Provides
@Singleton
fun providesCustomTokenFeatureToggles(featureTogglesManager: FeatureTogglesManager): CustomTokenFeatureToggles {
return DefaultCustomTokenFeatureToggles(featureTogglesManager)
}
}

View file

@ -0,0 +1,41 @@
package com.tangem.tap.features.customtoken.impl.di
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.lib.crypto.DerivationManager
import com.tangem.tap.features.customtoken.impl.data.DefaultCustomTokenRepository
import com.tangem.tap.features.customtoken.impl.domain.CustomTokenInteractor
import com.tangem.tap.features.customtoken.impl.domain.DefaultCustomTokenInteractor
import com.tangem.tap.proxy.AppStateHolder
import com.tangem.utils.coroutines.AppCoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ViewModelComponent
import dagger.hilt.android.scopes.ViewModelScoped
/**
[REDACTED_AUTHOR]
*/
@Module
@InstallIn(ViewModelComponent::class)
internal object CustomTokenInteractorModule {
@Provides
@ViewModelScoped
fun provideCustomTokenInteractor(
tangemTechApi: TangemTechApi,
appCoroutineDispatcherProvider: AppCoroutineDispatcherProvider,
reduxStateHolder: AppStateHolder,
derivationManager: DerivationManager,
): CustomTokenInteractor {
return DefaultCustomTokenInteractor(
featureRepository = DefaultCustomTokenRepository(
tangemTechApi = tangemTechApi,
dispatchers = appCoroutineDispatcherProvider,
reduxStateHolder = reduxStateHolder,
),
derivationManager = derivationManager,
reduxStateHolder = reduxStateHolder,
)
}
}

View file

@ -0,0 +1,21 @@
package com.tangem.tap.features.customtoken.impl.di
import com.tangem.tap.features.customtoken.impl.presentation.routers.CustomTokenRouter
import com.tangem.tap.features.customtoken.impl.presentation.routers.DefaultCustomTokenRouter
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ViewModelComponent
import dagger.hilt.android.scopes.ViewModelScoped
/**
[REDACTED_AUTHOR]
*/
@Module
@InstallIn(ViewModelComponent::class)
internal object CustomTokenRouterModule {
@Provides
@ViewModelScoped
fun provideAddCustomTokenRouter(): CustomTokenRouter = DefaultCustomTokenRouter()
}

View file

@ -0,0 +1,19 @@
package com.tangem.tap.features.customtoken.impl.domain
import com.tangem.blockchain.common.Blockchain
import com.tangem.tap.features.customtoken.impl.domain.models.FoundToken
import com.tangem.tap.features.wallet.models.Currency
/**
* Custom token interactor
*
[REDACTED_AUTHOR]
*/
interface CustomTokenInteractor {
/** Find token by [address] and [blockchain] */
suspend fun findToken(address: String, blockchain: Blockchain): FoundToken
/** Save token [currency] with contact address [address] */
suspend fun saveToken(currency: Currency, address: String)
}

View file

@ -0,0 +1,14 @@
package com.tangem.tap.features.customtoken.impl.domain
import com.tangem.tap.features.customtoken.impl.domain.models.FoundToken
/**
* Custom token repository
*
[REDACTED_AUTHOR]
*/
interface CustomTokenRepository {
/** Find token by [address] and [networkId] */
suspend fun findToken(address: String, networkId: String?): FoundToken
}

View file

@ -0,0 +1,96 @@
package com.tangem.tap.features.customtoken.impl.domain
import com.tangem.blockchain.common.Blockchain
import com.tangem.common.extensions.guard
import com.tangem.common.flatMap
import com.tangem.domain.common.extensions.toNetworkId
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.lib.crypto.DerivationManager
import com.tangem.lib.crypto.models.Currency.NativeToken
import com.tangem.lib.crypto.models.Currency.NonNativeToken
import com.tangem.tap.features.customtoken.impl.domain.models.FoundToken
import com.tangem.tap.features.wallet.models.Currency
import com.tangem.tap.proxy.AppStateHolder
import com.tangem.tap.scope
import com.tangem.tap.userWalletsListManager
import com.tangem.tap.walletCurrenciesManager
import kotlinx.coroutines.launch
import timber.log.Timber
/**
* Default implementation of custom token interactor
*
* @property featureRepository feature repository
* @property derivationManager derivation manager
* @property reduxStateHolder redux state holder
*
[REDACTED_AUTHOR]
*/
class DefaultCustomTokenInteractor(
private val featureRepository: CustomTokenRepository,
private val derivationManager: DerivationManager,
private val reduxStateHolder: AppStateHolder,
) : CustomTokenInteractor {
override suspend fun findToken(address: String, blockchain: Blockchain): FoundToken {
return featureRepository.findToken(
address = address,
networkId = if (blockchain != Blockchain.Unknown) blockchain.toNetworkId() else null,
)
}
override suspend fun saveToken(currency: Currency, address: String) {
val hasDerivation = derivationManager.hasDerivation(
networkId = currency.blockchain.toNetworkId(),
derivationPath = requireNotNull(currency.derivationPath),
)
if (!hasDerivation) {
derivationManager.deriveMissingBlockchains(
when (currency) {
is Currency.Blockchain -> NativeToken(
id = requireNotNull(currency.coinId),
name = currency.currencyName,
symbol = currency.currencySymbol,
networkId = currency.blockchain.toNetworkId(),
)
is Currency.Token -> NonNativeToken(
id = requireNotNull(currency.coinId),
name = currency.currencyName,
symbol = currency.currencySymbol,
networkId = currency.blockchain.toNetworkId(),
contractAddress = address,
decimalCount = currency.decimals,
)
},
)
submitAdd(
scanResponse = requireNotNull(reduxStateHolder.scanResponse),
currency = currency,
)
}
}
private fun submitAdd(scanResponse: ScanResponse, currency: Currency) {
val selectedUserWallet = userWalletsListManager.selectedUserWalletSync.guard {
Timber.e("Unable to add currencies, no user wallet selected")
return
}
scope.launch {
userWalletsListManager.update(
userWalletId = selectedUserWallet.walletId,
update = { userWallet ->
userWallet.copy(scanResponse = scanResponse)
},
)
.flatMap { updatedUserWallet ->
walletCurrenciesManager.addCurrencies(
userWallet = updatedUserWallet,
currenciesToAdd = listOf(currency),
)
}
}
}
}

View file

@ -0,0 +1,23 @@
package com.tangem.tap.features.customtoken.impl.domain.models
/**
* Found token model
*
* @property id id
* @property name name
* @property symbol symbol
* @property network network
*
[REDACTED_AUTHOR]
*/
data class FoundToken(val id: String, val name: String, val symbol: String, val network: Network) {
/**
* Found token network
*
* @property id id
* @property address address
* @property decimalCount decimal count
*/
data class Network(val id: String, val address: String, val decimalCount: String)
}

View file

@ -0,0 +1,19 @@
package com.tangem.tap.features.customtoken.impl.featuretoggles
import com.tangem.core.featuretoggle.manager.FeatureTogglesManager
import com.tangem.tap.features.customtoken.api.featuretoggles.CustomTokenFeatureToggles
/**
* Default implementation of CustomToken feature toggles
*
* @property featureTogglesManager manager for getting information about the availability of feature toggles
*
[REDACTED_AUTHOR]
*/
internal class DefaultCustomTokenFeatureToggles(
private val featureTogglesManager: FeatureTogglesManager,
) : CustomTokenFeatureToggles {
override val isRedesignedScreenEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(name = "REDESIGNED_CUSTOM_TOKEN_SCREEN_ENABLED")
}

View file

@ -0,0 +1,46 @@
package com.tangem.tap.features.customtoken.impl.presentation
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.fragment.app.Fragment
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.transition.TransitionInflater
import com.tangem.core.ui.res.TangemTheme
import com.tangem.tap.features.customtoken.impl.presentation.ui.AddCustomTokenScreen
import com.tangem.tap.features.customtoken.impl.presentation.viewmodels.AddCustomTokenViewModel
import com.tangem.wallet.R
import dagger.hilt.android.AndroidEntryPoint
/**
* Add custom token screen
*
[REDACTED_AUTHOR]
*/
@AndroidEntryPoint
internal class AddCustomTokenFragment : Fragment() {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
with(TransitionInflater.from(requireContext())) {
enterTransition = inflateTransition(R.transition.fade)
exitTransition = inflateTransition(R.transition.fade)
}
return ComposeView(inflater.context).apply {
setContent {
isTransitionGroup = true
val viewModel = hiltViewModel<AddCustomTokenViewModel>().apply {
LocalLifecycleOwner.current.lifecycle.addObserver(this)
}
TangemTheme {
AddCustomTokenScreen(stateHolder = viewModel.uiState)
}
}
}
}
}

View file

@ -0,0 +1,279 @@
package com.tangem.tap.features.customtoken.impl.presentation.models
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import com.tangem.blockchain.common.Blockchain
import com.tangem.tap.features.details.ui.cardsettings.TextReference
import com.tangem.wallet.R
/**
* Toolbar model of add custom token screen
*
* @property title title
* @property onBackButtonClick lambda be invoked when back button is been pressed
*/
internal data class AddCustomTokensToolbar(val title: TextReference, val onBackButtonClick: () -> Unit)
/**
* Model of block with fields for testing
*
* @property chooseTokenButtonText choose token button text
* @property clearButtonText clear button text
* @property resetButtonText reset button text
* @property onClearAddressButtonClick lambda be invoked when clear address button is been pressed
* @property onResetButtonClick lambda be invoked when reset form fields button is been pressed
*/
internal data class AddCustomTokenTestBlock(
val chooseTokenButtonText: String,
val clearButtonText: String,
val resetButtonText: String,
val onClearAddressButtonClick: () -> Unit,
val onResetButtonClick: () -> Unit,
)
/**
* Bottom sheet model for choose custom token
*
* @property categoriesBlocks tokens categories
* @property onTestTokenClick lambda be invoked when token is been pressed
*/
internal data class AddCustomTokenChooseTokenBottomSheet(
val categoriesBlocks: List<TokensCategoryBlock>,
val onTestTokenClick: (String) -> Unit,
) {
/**
* Tokens category model
*
* @property name category name
* @property items category items
*/
data class TokensCategoryBlock(val name: String, val items: List<TestTokenItem>)
/**
* Test token model
*
* @property name token name
* @property address token address
*/
data class TestTokenItem(val name: String, val address: String)
}
/**
* Form with fields model of add custom token screen
*
* @property contractAddressInputField input field to enter the contract address
* @property networkSelectorField selector field to select the token network
* @property tokenNameInputField input field to enter the token name
* @property tokenSymbolInputField input field to enter the token symbol
* @property decimalsInputField input field to enter the token decimals
* @property derivationPathSelectorField selector field to select the derivation path
*/
internal data class AddCustomTokenForm(
val contractAddressInputField: AddCustomTokenInputField.ContactAddress,
val networkSelectorField: AddCustomTokenSelectorField.Network,
val tokenNameInputField: AddCustomTokenInputField.TokenName,
val tokenSymbolInputField: AddCustomTokenInputField.TokenSymbol,
val decimalsInputField: AddCustomTokenInputField.Decimals,
val derivationPathSelectorField: AddCustomTokenSelectorField.DerivationPath?,
)
/** Base input field model of add custom token screen */
internal sealed interface AddCustomTokenInputField {
/** Current value */
val value: String
/** Lambda be invoked when value is been changed */
val onValueChange: (String) -> Unit
/** Keyboard options */
val keyboardOptions: KeyboardOptions
/** Label */
val label: TextReference
/** Input availability */
val isEnabled: Boolean
/** Flag that determine if current value has error */
val isError: Boolean
/** Placeholder (hint) */
val placeholder: TextReference
/** Flag that determine the processing of current value */
val isLoading: Boolean
/**
* Input field model to enter the contract address
*
* @property value current value
* @property onValueChange lambda be invoked when value is been changed
* @property isError flag that determine if current value has error
* @property isLoading flag that determine the processing of current value
*/
data class ContactAddress(
override val value: String,
override val onValueChange: (String) -> Unit,
override val isError: Boolean,
override val isLoading: Boolean,
) : AddCustomTokenInputField {
override val isEnabled = true
override val keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next)
override val label = TextReference.Res(R.string.custom_token_contract_address_input_title)
override val placeholder = TextReference.Str(value = "0x0000000000000000000000000000000000000000")
}
/**
* Input field model to enter the token name
*
* @property value current value
* @property onValueChange lambda be invoked when value is been changed
* @property isEnabled input availability
* @property isError flag that determine if current value has error
*/
data class TokenName(
override val value: String,
override val onValueChange: (String) -> Unit,
override val isEnabled: Boolean,
override val isError: Boolean,
) : AddCustomTokenInputField {
override val keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next)
override val label = TextReference.Res(R.string.custom_token_name_input_title)
override val placeholder = TextReference.Res(id = R.string.custom_token_name_input_placeholder)
override val isLoading = false
}
/**
* Input field model to enter the token symbol
*
* @property value current value
* @property onValueChange lambda be invoked when value is been changed
* @property isEnabled input availability
* @property isError flag that determine if current value has error
*/
data class TokenSymbol(
override val value: String,
override val onValueChange: (String) -> Unit,
override val isEnabled: Boolean,
override val isError: Boolean,
) : AddCustomTokenInputField {
override val keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next)
override val label = TextReference.Res(R.string.custom_token_token_symbol_input_title)
override val placeholder = TextReference.Res(id = R.string.custom_token_token_symbol_input_placeholder)
override val isLoading = false
}
/**
* Input field model to enter the token decimals
*
* @property value current value
* @property onValueChange lambda be invoked when value is been changed
* @property isEnabled input availability
* @property isError flag that determine if current value has error
*/
data class Decimals(
override val value: String,
override val onValueChange: (String) -> Unit,
override val isEnabled: Boolean,
override val isError: Boolean,
) : AddCustomTokenInputField {
override val keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Next)
override val label = TextReference.Res(R.string.custom_token_decimals_input_title)
override val placeholder = TextReference.Str(value = "8")
override val isLoading = false
}
}
/** Base selector field model of add custom token screen */
internal sealed interface AddCustomTokenSelectorField {
/** Selection availability */
val isEnabled: Boolean
/** Label string resource id */
val label: TextReference
/** Selected menu item */
val selectedItem: SelectorItem
/** Menu items */
val items: List<SelectorItem>
/** Lambda be invoked when menu item is been selected */
val onMenuItemClick: (Int) -> Unit
/**
* Network selector model
*
* @property selectedItem selected menu item
* @property items menu items
* @property onMenuItemClick lambda be invoked when menu item is been selected
*/
data class Network(
override val selectedItem: SelectorItem.Title,
override val items: List<SelectorItem.Title>,
override val onMenuItemClick: (Int) -> Unit,
) : AddCustomTokenSelectorField {
override val isEnabled = true
override val label = TextReference.Res(R.string.custom_token_network_input_title)
}
/**
* Derivation path selector model
*
* @property isEnabled selection availability
* @property selectedItem selected menu item
* @property items menu items
* @property onMenuItemClick lambda be invoked when menu item is been selected
*/
data class DerivationPath(
override val isEnabled: Boolean,
override val selectedItem: SelectorItem.TitleWithSubtitle,
override val items: List<SelectorItem.TitleWithSubtitle>,
override val onMenuItemClick: (Int) -> Unit,
) : AddCustomTokenSelectorField {
override val label = TextReference.Res(R.string.custom_token_derivation_path_input_title)
}
/** Base menu item model */
sealed interface SelectorItem {
/** Title */
val title: TextReference
/** Blockchain */
val blockchain: Blockchain
/**
* Menu item with title
*
* @property title title text
* @property blockchain blockchain
*/
data class Title(override val title: TextReference, override val blockchain: Blockchain) : SelectorItem
/**
* Menu item with title ans subtitle
*
* @property title title text
* @property subtitle subtitle text
* @property blockchain blockchain
*/
data class TitleWithSubtitle(
override val title: TextReference,
val subtitle: TextReference,
override val blockchain: Blockchain,
) : SelectorItem
}
}
/**
* Floating button of add custom token screen
*
* @property isEnabled button availability
* @property onClick lambda be invoked when button is been pressed
*/
internal data class AddCustomTokenFloatingButton(val isEnabled: Boolean, val onClick: () -> Unit)

View file

@ -0,0 +1,12 @@
package com.tangem.tap.features.customtoken.impl.presentation.routers
/**
* Custom token feature router
*
[REDACTED_AUTHOR]
*/
internal interface CustomTokenRouter {
/** Return to last screen */
fun popBackStack()
}

View file

@ -0,0 +1,12 @@
package com.tangem.tap.features.customtoken.impl.presentation.routers
import com.tangem.tap.common.redux.navigation.NavigationAction
import com.tangem.tap.store
/** Default implementation of custom token feature router */
internal class DefaultCustomTokenRouter : CustomTokenRouter {
override fun popBackStack() {
store.dispatch(NavigationAction.PopBackTo())
}
}

View file

@ -0,0 +1,91 @@
package com.tangem.tap.features.customtoken.impl.presentation.states
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenChooseTokenBottomSheet
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenFloatingButton
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenForm
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenTestBlock
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokensToolbar
import com.tangem.tap.features.details.ui.cardsettings.TextReference
/**
* State holder of add custom token screen
*
[REDACTED_AUTHOR]
*/
internal sealed interface AddCustomTokenStateHolder {
/** Lambda be invoked when system back action is been called */
val onBackButtonClick: () -> Unit
/** Toolbar model */
val toolbar: AddCustomTokensToolbar
/** Form model */
val form: AddCustomTokenForm
/** Warnings */
val warnings: List<TextReference>
/** Floating button model */
val floatingButton: AddCustomTokenFloatingButton
/**
* Util function that allow to make a copy
*
* @param onBackButtonClick lambda be invoked when system back action is been called
* @param toolbar toolbar model
* @param form form model
* @param warnings warnings
* @param floatingButton floating button model
*/
fun copySealed(
onBackButtonClick: () -> Unit = this.onBackButtonClick,
toolbar: AddCustomTokensToolbar = this.toolbar,
form: AddCustomTokenForm = this.form,
warnings: List<TextReference> = this.warnings,
floatingButton: AddCustomTokenFloatingButton = this.floatingButton,
): AddCustomTokenStateHolder {
return when (this) {
is Content -> copy(onBackButtonClick, toolbar, form, warnings, floatingButton)
is TestContent -> copy(onBackButtonClick, toolbar, form, warnings, floatingButton)
}
}
/**
* Content state
*
* @property onBackButtonClick lambda be invoked when system back action is been called
* @property toolbar toolbar model
* @property form form model
* @property warnings warnings
* @property floatingButton floating button model
*/
data class Content(
override val onBackButtonClick: () -> Unit,
override val toolbar: AddCustomTokensToolbar,
override val form: AddCustomTokenForm,
override val warnings: List<TextReference>,
override val floatingButton: AddCustomTokenFloatingButton,
) : AddCustomTokenStateHolder
/**
* Content state with fields for testing
*
* @property onBackButtonClick lambda be invoked when system back action is been called
* @property toolbar toolbar model
* @property form form model
* @property warnings warnings
* @property floatingButton floating button model
* @property testBlock test block model
* @property bottomSheet bottom sheet model
*/
data class TestContent(
override val onBackButtonClick: () -> Unit,
override val toolbar: AddCustomTokensToolbar,
override val form: AddCustomTokenForm,
override val warnings: List<TextReference>,
override val floatingButton: AddCustomTokenFloatingButton,
val testBlock: AddCustomTokenTestBlock,
val bottomSheet: AddCustomTokenChooseTokenBottomSheet,
) : AddCustomTokenStateHolder
}

View file

@ -0,0 +1,121 @@
package com.tangem.tap.features.customtoken.impl.presentation.ui
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.FabPosition
import androidx.compose.material.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.blockchain.common.Blockchain
import com.tangem.core.ui.res.TangemTheme
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenFloatingButton
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenInputField
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenSelectorField
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokensToolbar
import com.tangem.tap.features.customtoken.impl.presentation.states.AddCustomTokenStateHolder
import com.tangem.tap.features.customtoken.impl.presentation.ui.components.AddCustomTokenFloatingButton
import com.tangem.tap.features.customtoken.impl.presentation.ui.components.AddCustomTokenForm
import com.tangem.tap.features.customtoken.impl.presentation.ui.components.AddCustomTokenToolbar
import com.tangem.tap.features.customtoken.impl.presentation.ui.components.AddCustomTokenWarning
import com.tangem.tap.features.details.ui.cardsettings.TextReference
import com.tangem.wallet.R
/**
* Add custom token content
*
* @param state screen state
*
[REDACTED_AUTHOR]
*/
@Composable
internal fun AddCustomTokenContent(state: AddCustomTokenStateHolder.Content) {
BackHandler(onBack = state.onBackButtonClick)
Scaffold(
topBar = {
AddCustomTokenToolbar(
title = state.toolbar.title,
onBackButtonClick = state.toolbar.onBackButtonClick,
)
},
floatingActionButton = { AddCustomTokenFloatingButton(model = state.floatingButton) },
floatingActionButtonPosition = FabPosition.Center,
) {
Column(
modifier = Modifier
.padding(paddingValues = it)
.fillMaxSize()
.verticalScroll(rememberScrollState()),
) {
AddCustomTokenForm(model = state.form)
state.warnings.forEach { description ->
key(description) {
AddCustomTokenWarning(description)
}
}
}
}
}
@Preview(showSystemUi = true)
@Composable
private fun Preview_AddCustomTokenContent() {
TangemTheme {
AddCustomTokenContent(
state = AddCustomTokenStateHolder.Content(
onBackButtonClick = {},
toolbar = AddCustomTokensToolbar(
title = TextReference.Res(R.string.add_custom_token_title),
onBackButtonClick = {},
),
form = com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenForm(
contractAddressInputField = AddCustomTokenInputField.ContactAddress(
value = "",
onValueChange = {},
isError = false,
isLoading = false,
),
networkSelectorField = AddCustomTokenSelectorField.Network(
selectedItem = AddCustomTokenSelectorField.SelectorItem.Title(
title = TextReference.Str("Avalanche"),
blockchain = Blockchain.Avalanche,
),
items = listOf(),
onMenuItemClick = {},
),
tokenNameInputField = AddCustomTokenInputField.TokenName(
value = "",
onValueChange = {},
isEnabled = false,
isError = false,
),
tokenSymbolInputField = AddCustomTokenInputField.TokenSymbol(
value = "",
onValueChange = {},
isEnabled = false,
isError = false,
),
decimalsInputField = AddCustomTokenInputField.Decimals(
value = "",
onValueChange = {},
isEnabled = false,
isError = false,
),
derivationPathSelectorField = null,
),
warnings = listOf(),
floatingButton = AddCustomTokenFloatingButton(
isEnabled = false,
onClick = {},
),
),
)
}
}

View file

@ -0,0 +1,20 @@
package com.tangem.tap.features.customtoken.impl.presentation.ui
import androidx.compose.runtime.Composable
import com.tangem.tap.features.customtoken.impl.presentation.states.AddCustomTokenStateHolder
/**
* Add custom token screen
*
* @param stateHolder state holder
*
[REDACTED_AUTHOR]
*/
@Suppress("UnusedPrivateMember")
@Composable
internal fun AddCustomTokenScreen(stateHolder: AddCustomTokenStateHolder) {
when (stateHolder) {
is AddCustomTokenStateHolder.Content -> AddCustomTokenContent(state = stateHolder)
is AddCustomTokenStateHolder.TestContent -> AddCustomTokenTestContent(state = stateHolder)
}
}

View file

@ -0,0 +1,311 @@
package com.tangem.tap.features.customtoken.impl.presentation.ui
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.BottomSheetScaffold
import androidx.compose.material.BottomSheetScaffoldState
import androidx.compose.material.BottomSheetState
import androidx.compose.material.BottomSheetValue
import androidx.compose.material.Divider
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.FabPosition
import androidx.compose.material.Text
import androidx.compose.material.rememberBottomSheetScaffoldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.blockchain.common.Blockchain
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.SpacerH8
import com.tangem.core.ui.components.atoms.Hand
import com.tangem.core.ui.res.TangemTheme
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenChooseTokenBottomSheet
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenChooseTokenBottomSheet.TestTokenItem
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenFloatingButton
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenInputField
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenSelectorField
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenTestBlock
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokensToolbar
import com.tangem.tap.features.customtoken.impl.presentation.states.AddCustomTokenStateHolder
import com.tangem.tap.features.customtoken.impl.presentation.ui.components.AddCustomTokenFloatingButton
import com.tangem.tap.features.customtoken.impl.presentation.ui.components.AddCustomTokenForm
import com.tangem.tap.features.customtoken.impl.presentation.ui.components.AddCustomTokenToolbar
import com.tangem.tap.features.details.ui.cardsettings.TextReference
import com.tangem.wallet.R
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
/**
* Add custom token content for testing
*
* @param state screen state
*
[REDACTED_AUTHOR]
*/
@OptIn(ExperimentalMaterialApi::class)
@Composable
internal fun AddCustomTokenTestContent(state: AddCustomTokenStateHolder.TestContent) {
val coroutineScope = rememberCoroutineScope()
val bottomSheetScaffoldState = rememberBottomSheetScaffoldState(
bottomSheetState = BottomSheetState(initialValue = BottomSheetValue.Collapsed),
)
BackHandler(
onBack = {
onBackButtonClicked(
coroutineScope = coroutineScope,
bottomSheetScaffoldState = bottomSheetScaffoldState,
defaultAction = state.onBackButtonClick,
)
},
)
BottomSheetScaffold(
sheetContent = {
SheetContent(
coroutineScope = coroutineScope,
bottomSheetScaffoldState = bottomSheetScaffoldState,
model = state.bottomSheet,
)
},
scaffoldState = bottomSheetScaffoldState,
topBar = {
AddCustomTokenToolbar(
title = state.toolbar.title,
onBackButtonClick = {
onBackButtonClicked(
coroutineScope,
bottomSheetScaffoldState,
defaultAction = state.toolbar.onBackButtonClick,
)
},
)
},
floatingActionButton = { AddCustomTokenFloatingButton(model = state.floatingButton) },
floatingActionButtonPosition = FabPosition.Center,
sheetBackgroundColor = TangemTheme.colors.background.secondary,
sheetPeekHeight = TangemTheme.dimens.size0,
backgroundColor = TangemTheme.colors.background.secondary,
) {
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(it),
) {
TestBlock(
model = state.testBlock,
coroutineScope,
bottomSheetScaffoldState,
)
AddCustomTokenForm(model = state.form)
}
}
}
@OptIn(ExperimentalMaterialApi::class)
private fun onBackButtonClicked(
coroutineScope: CoroutineScope,
bottomSheetScaffoldState: BottomSheetScaffoldState,
defaultAction: () -> Unit,
) {
coroutineScope.launch {
if (bottomSheetScaffoldState.bottomSheetState.isExpanded) {
bottomSheetScaffoldState.bottomSheetState.collapse()
} else {
defaultAction()
}
}
}
@OptIn(ExperimentalMaterialApi::class)
@Composable
private fun SheetContent(
model: AddCustomTokenChooseTokenBottomSheet,
coroutineScope: CoroutineScope,
bottomSheetScaffoldState: BottomSheetScaffoldState,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.height(LocalConfiguration.current.screenHeightDp.dp - TangemTheme.dimens.spacing16),
) {
Hand()
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
) {
model.categoriesBlocks.forEachIndexed { index, categoryBlock ->
key(categoryBlock) {
Column {
TokensList(
title = categoryBlock.name,
tokens = categoryBlock.items,
onTestTokenClick = { address ->
model.onTestTokenClick(address)
coroutineScope.launch { bottomSheetScaffoldState.bottomSheetState.collapse() }
},
)
if (model.categoriesBlocks.lastIndex != index) {
Divider()
SpacerH8()
}
}
}
}
}
}
}
@Composable
private fun TokensList(title: String, tokens: List<TestTokenItem>, onTestTokenClick: (String) -> Unit) {
Text(
text = title,
modifier = Modifier.padding(
horizontal = TangemTheme.dimens.spacing24,
vertical = TangemTheme.dimens.spacing8,
),
maxLines = 1,
style = TangemTheme.typography.h3,
)
tokens.forEach { token ->
key(token) {
PrimaryButton(
text = token.name,
onClick = { onTestTokenClick(token.address) },
modifier = Modifier
.padding(horizontal = TangemTheme.dimens.spacing16)
.padding(bottom = TangemTheme.dimens.spacing8)
.fillMaxWidth(),
)
}
}
}
@OptIn(ExperimentalMaterialApi::class, ExperimentalComposeUiApi::class)
@Composable
private fun TestBlock(
model: AddCustomTokenTestBlock,
coroutineScope: CoroutineScope,
bottomSheetScaffoldState: BottomSheetScaffoldState,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = TangemTheme.dimens.spacing16)
.padding(top = TangemTheme.dimens.spacing16),
verticalArrangement = Arrangement.spacedBy(space = TangemTheme.dimens.spacing8),
) {
val softwareKeyboardController = LocalSoftwareKeyboardController.current
PrimaryButton(
text = model.chooseTokenButtonText,
onClick = {
softwareKeyboardController?.hide()
coroutineScope.launch { bottomSheetScaffoldState.bottomSheetState.expand() }
},
modifier = Modifier.fillMaxWidth(),
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8),
verticalAlignment = Alignment.CenterVertically,
) {
PrimaryButton(
text = model.clearButtonText,
onClick = model.onClearAddressButtonClick,
modifier = Modifier.weight(1f),
)
PrimaryButton(
text = model.resetButtonText,
onClick = model.onResetButtonClick,
modifier = Modifier.weight(1f),
)
}
}
}
@Preview(showSystemUi = true)
@Composable
private fun Preview_AddCustomTokenTestContent() {
TangemTheme {
AddCustomTokenTestContent(
state = AddCustomTokenStateHolder.TestContent(
onBackButtonClick = {},
toolbar = AddCustomTokensToolbar(
title = TextReference.Res(R.string.add_custom_token_title),
onBackButtonClick = {},
),
form = com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenForm(
contractAddressInputField = AddCustomTokenInputField.ContactAddress(
value = "",
onValueChange = {},
isError = false,
isLoading = false,
),
networkSelectorField = AddCustomTokenSelectorField.Network(
selectedItem = AddCustomTokenSelectorField.SelectorItem.Title(
title = TextReference.Str(value = "Avalanche"),
blockchain = Blockchain.Avalanche,
),
items = listOf(),
onMenuItemClick = {},
),
tokenNameInputField = AddCustomTokenInputField.TokenName(
value = "",
onValueChange = {},
isEnabled = false,
isError = false,
),
tokenSymbolInputField = AddCustomTokenInputField.TokenSymbol(
value = "",
onValueChange = {},
isEnabled = false,
isError = false,
),
decimalsInputField = AddCustomTokenInputField.Decimals(
value = "",
onValueChange = {},
isEnabled = false,
isError = false,
),
derivationPathSelectorField = null,
),
warnings = listOf(),
floatingButton = AddCustomTokenFloatingButton(
isEnabled = false,
onClick = {},
),
testBlock = AddCustomTokenTestBlock(
chooseTokenButtonText = "Choose token",
clearButtonText = "Clear address",
resetButtonText = "Reset",
onClearAddressButtonClick = {},
onResetButtonClick = {},
),
bottomSheet = AddCustomTokenChooseTokenBottomSheet(
categoriesBlocks = listOf(),
onTestTokenClick = {},
),
),
)
}
}

View file

@ -0,0 +1,51 @@
package com.tangem.tap.features.customtoken.impl.presentation.ui.components
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.components.PrimaryButtonIconLeft
import com.tangem.core.ui.res.TangemTheme
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenFloatingButton
import com.tangem.wallet.R
/**
* Add custom token floating button. Attached above the keyboard.
*
* @param model button model
*
[REDACTED_AUTHOR]
*/
@Composable
internal fun AddCustomTokenFloatingButton(model: AddCustomTokenFloatingButton) {
PrimaryButtonIconLeft(
modifier = Modifier
.imePadding()
.padding(horizontal = TangemTheme.dimens.spacing16)
.fillMaxWidth(),
text = stringResource(id = R.string.common_add),
icon = painterResource(id = R.drawable.ic_plus_24),
enabled = model.isEnabled,
onClick = model.onClick,
)
}
@Preview
@Composable
private fun Preview_AddCustomTokenFloatingButton_Enabled() {
TangemTheme {
AddCustomTokenFloatingButton(model = AddCustomTokenFloatingButton(isEnabled = true, onClick = {}))
}
}
@Preview
@Composable
private fun Preview_AddCustomTokenFloatingButton_Disabled() {
TangemTheme {
AddCustomTokenFloatingButton(model = AddCustomTokenFloatingButton(isEnabled = false, onClick = {}))
}
}

View file

@ -0,0 +1,216 @@
package com.tangem.tap.features.customtoken.impl.presentation.ui.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Card
import androidx.compose.material.DropdownMenuItem
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ExposedDropdownMenuBox
import androidx.compose.material.ExposedDropdownMenuDefaults
import androidx.compose.material.LinearProgressIndicator
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.blockchain.common.Blockchain
import com.tangem.core.ui.res.TangemTheme
import com.tangem.tap.common.compose.TangemTextFieldsDefault
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenForm
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenInputField
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenSelectorField
import com.tangem.tap.features.details.ui.cardsettings.TextReference
import com.tangem.tap.features.details.ui.cardsettings.resolveReference
/**
* Add custom token form
*
* @param model component model
*
[REDACTED_AUTHOR]
*/
@Composable
internal fun AddCustomTokenForm(model: AddCustomTokenForm) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(TangemTheme.dimens.spacing16),
shape = RoundedCornerShape(TangemTheme.dimens.radius8),
backgroundColor = TangemTheme.colors.background.primary,
elevation = TangemTheme.dimens.elevation4,
) {
Column(
modifier = Modifier.padding(TangemTheme.dimens.spacing16),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8),
) {
InputField(model = model.contractAddressInputField)
SelectorField(model = model.networkSelectorField)
InputField(model = model.tokenNameInputField)
InputField(model = model.tokenSymbolInputField)
InputField(model = model.decimalsInputField)
model.derivationPathSelectorField?.let { SelectorField(model = it) }
}
}
}
@Composable
private fun InputField(model: AddCustomTokenInputField) {
Box {
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = model.value,
onValueChange = model.onValueChange,
keyboardOptions = model.keyboardOptions,
label = {
Text(
text = model.label.resolveReference(),
style = TangemTheme.typography.caption,
color = TangemTextFieldsDefault.defaultTextFieldColors.labelColor(
enabled = model.isEnabled,
error = model.isError,
interactionSource = remember { MutableInteractionSource() },
).value,
)
},
placeholder = {
Text(
text = model.placeholder.resolveReference(),
style = TangemTheme.typography.body1,
color = TangemTextFieldsDefault.defaultTextFieldColors
.placeholderColor(enabled = model.isEnabled)
.value,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
singleLine = true,
enabled = model.isEnabled,
isError = model.isError,
colors = TangemTextFieldsDefault.defaultTextFieldColors,
)
AnimatedVisibility(
visible = model.isLoading,
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter)
.padding(horizontal = TangemTheme.dimens.spacing6)
.padding(bottom = TangemTheme.dimens.spacing6),
) {
LinearProgressIndicator(color = TangemTheme.colors.icon.primary1)
}
}
}
@OptIn(ExperimentalMaterialApi::class)
@Composable
private fun SelectorField(model: AddCustomTokenSelectorField) {
var isExpanded by remember { mutableStateOf(value = false) }
ExposedDropdownMenuBox(
expanded = isExpanded,
onExpandedChange = { isExpanded = !isExpanded },
) {
OutlinedTextField(
value = when (val item = model.selectedItem) {
is AddCustomTokenSelectorField.SelectorItem.Title -> item.title
is AddCustomTokenSelectorField.SelectorItem.TitleWithSubtitle -> item.subtitle
}.resolveReference(),
modifier = Modifier.fillMaxWidth(),
onValueChange = {},
readOnly = true,
enabled = model.isEnabled,
label = { Text(text = model.label.resolveReference()) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = isExpanded) },
colors = TangemTextFieldsDefault.defaultTextFieldColors,
)
ExposedDropdownMenu(
expanded = isExpanded && model.isEnabled,
onDismissRequest = { isExpanded = false },
) {
FocusRequester
model.items.forEachIndexed { index, item ->
DropdownMenuItem(
onClick = {
model.onMenuItemClick(index)
isExpanded = false
},
) {
Column {
Text(text = item.title.resolveReference())
val subtitle = (item as? AddCustomTokenSelectorField.SelectorItem.TitleWithSubtitle)
?.subtitle?.resolveReference()
if (!subtitle.isNullOrBlank()) {
Text(
text = subtitle,
color = TangemTheme.colors.text.secondary,
maxLines = 1,
style = TangemTheme.typography.caption,
)
}
}
}
}
}
}
}
@Preview
@Composable
private fun Preview_AddCustomTokenForm() {
TangemTheme {
AddCustomTokenForm(
AddCustomTokenForm(
contractAddressInputField = AddCustomTokenInputField.ContactAddress(
value = "",
onValueChange = {},
isError = false,
isLoading = false,
),
networkSelectorField = AddCustomTokenSelectorField.Network(
selectedItem = AddCustomTokenSelectorField.SelectorItem.Title(
title = TextReference.Str(value = "Avalanche"),
blockchain = Blockchain.Avalanche,
),
items = listOf(),
onMenuItemClick = {},
),
tokenNameInputField = AddCustomTokenInputField.TokenName(
value = "",
onValueChange = {},
isEnabled = false,
isError = false,
),
tokenSymbolInputField = AddCustomTokenInputField.TokenSymbol(
value = "",
onValueChange = {},
isEnabled = false,
isError = false,
),
decimalsInputField = AddCustomTokenInputField.Decimals(
value = "",
onValueChange = {},
isEnabled = false,
isError = false,
),
derivationPathSelectorField = null,
),
)
}
}

View file

@ -0,0 +1,54 @@
package com.tangem.tap.features.customtoken.impl.presentation.ui.components
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.width
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.R
import com.tangem.core.ui.res.TangemTheme
import com.tangem.tap.features.details.ui.cardsettings.TextReference
import com.tangem.tap.features.details.ui.cardsettings.resolveReference
/**
* Add custom token toolbar
*
* @param title title
* @param onBackButtonClick lambda be invoked when BackButton is been pressed
*
[REDACTED_AUTHOR]
*/
@Composable
internal fun AddCustomTokenToolbar(title: TextReference, onBackButtonClick: () -> Unit) {
TopAppBar(backgroundColor = TangemTheme.colors.background.secondary) {
IconButton(onClick = onBackButtonClick) {
Icon(
painter = painterResource(id = R.drawable.ic_back_24),
contentDescription = null,
tint = TangemTheme.colors.icon.secondary,
)
}
Spacer(modifier = Modifier.width(TangemTheme.dimens.spacing26))
Text(
text = title.resolveReference(),
color = TangemTheme.colors.text.primary1,
maxLines = 1,
style = TangemTheme.typography.h3,
)
}
}
@Preview
@Composable
internal fun Preview_AddCustomTokenToolbar() {
TangemTheme {
AddCustomTokenToolbar(title = TextReference.Res(R.string.add_custom_token_title), onBackButtonClick = {})
}
}

View file

@ -0,0 +1,54 @@
package com.tangem.tap.features.customtoken.impl.presentation.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Card
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import com.tangem.core.ui.res.TangemColorPalette
import com.tangem.core.ui.res.TangemTheme
import com.tangem.tap.features.details.ui.cardsettings.TextReference
import com.tangem.tap.features.details.ui.cardsettings.resolveReference
import com.tangem.wallet.R
/**
* Add custom token warning component
* FIXME("Incorrect typography. Replace with typography from design system")
*
* @param description warning description
* @param modifier modifier
*
[REDACTED_AUTHOR]
*/
@Composable
internal fun AddCustomTokenWarning(description: TextReference, modifier: Modifier = Modifier) {
Card(
modifier = modifier,
shape = RoundedCornerShape(TangemTheme.dimens.radius4),
backgroundColor = TangemColorPalette.Tangerine,
contentColor = TangemColorPalette.White,
elevation = TangemTheme.dimens.elevation4,
) {
Column(
modifier = Modifier.padding(all = TangemTheme.dimens.spacing16),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8),
) {
Text(
text = stringResource(id = R.string.common_warning),
maxLines = 1,
style = TangemTheme.typography.body2.copy(fontWeight = FontWeight.Bold),
)
Text(
text = description.resolveReference(),
fontSize = 13.sp,
lineHeight = 18.sp,
)
}
}
}

View file

@ -0,0 +1,42 @@
package com.tangem.tap.features.customtoken.impl.presentation.validators
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.address.AddressService
import com.tangem.common.card.EllipticCurve
import com.tangem.domain.AddCustomTokenError
/**
* Validator of contract address
*
[REDACTED_AUTHOR]
*/
object ContactAddressValidator {
/** Validate a [address] using [blockchain] */
fun validate(address: String, blockchain: Blockchain): ContractAddressValidatorResult {
return when {
address.isEmpty() -> ContractAddressValidatorResult.Error(type = AddCustomTokenError.FieldIsEmpty)
validateAddress(blockchain, address) -> ContractAddressValidatorResult.Success
else -> ContractAddressValidatorResult.Error(type = AddCustomTokenError.InvalidContractAddress)
}
}
private fun validateAddress(blockchain: Blockchain, address: String): Boolean {
return when (blockchain) {
Blockchain.Unknown, Blockchain.Binance, Blockchain.BinanceTestnet -> {
SuccessAddressValidator.validate(address)
}
else -> {
blockchain.validateAddress(address)
}
}
}
private object SuccessAddressValidator : AddressService() {
override fun makeAddress(walletPublicKey: ByteArray, curve: EllipticCurve?): String {
throw UnsupportedOperationException()
}
override fun validate(address: String): Boolean = true
}
}

View file

@ -0,0 +1,21 @@
package com.tangem.tap.features.customtoken.impl.presentation.validators
import com.tangem.domain.AddCustomTokenError
/**
* Result of validation contract address
*
[REDACTED_AUTHOR]
*/
sealed interface ContractAddressValidatorResult {
/** Success */
object Success : ContractAddressValidatorResult
/**
* Error
*
* @property type type of error
*/
data class Error(val type: AddCustomTokenError) : ContractAddressValidatorResult
}

View file

@ -0,0 +1,612 @@
package com.tangem.tap.features.customtoken.impl.presentation.viewmodels
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.DerivationStyle
import com.tangem.blockchain.common.Token
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.domain.AddCustomTokenError
import com.tangem.domain.common.TapWorkarounds.derivationStyle
import com.tangem.domain.common.TapWorkarounds.isTestCard
import com.tangem.domain.common.extensions.fromNetworkId
import com.tangem.domain.common.extensions.isSupportedInApp
import com.tangem.domain.common.extensions.supportedBlockchains
import com.tangem.tap.common.analytics.events.ManageTokens
import com.tangem.tap.features.customtoken.impl.domain.CustomTokenInteractor
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenChooseTokenBottomSheet
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenChooseTokenBottomSheet.TestTokenItem
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenChooseTokenBottomSheet.TokensCategoryBlock
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenFloatingButton
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenForm
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenInputField
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenSelectorField
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenTestBlock
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokensToolbar
import com.tangem.tap.features.customtoken.impl.presentation.routers.CustomTokenRouter
import com.tangem.tap.features.customtoken.impl.presentation.states.AddCustomTokenStateHolder
import com.tangem.tap.features.customtoken.impl.presentation.validators.ContactAddressValidator
import com.tangem.tap.features.customtoken.impl.presentation.validators.ContractAddressValidatorResult
import com.tangem.tap.features.details.ui.cardsettings.TextReference
import com.tangem.tap.features.wallet.models.Currency
import com.tangem.tap.proxy.AppStateHolder
import com.tangem.utils.coroutines.AppCoroutineDispatcherProvider
import com.tangem.utils.coroutines.runCatching
import com.tangem.wallet.BuildConfig
import com.tangem.wallet.R
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
/**
* ViewModel for add custom token screen
*
* @param featureRouter feature router
* @property featureInteractor feature interactor
* @property dispatchers coroutine dispatchers provider
* @property reduxStateHolder redux state holder
* @property analyticsEventHandler analytics event handler
*
[REDACTED_AUTHOR]
*/
@HiltViewModel
internal class AddCustomTokenViewModel @Inject constructor(
featureRouter: CustomTokenRouter,
private val featureInteractor: CustomTokenInteractor,
private val dispatchers: AppCoroutineDispatcherProvider,
private val reduxStateHolder: AppStateHolder,
private val analyticsEventHandler: AnalyticsEventHandler,
) : ViewModel(), DefaultLifecycleObserver {
private val actionsHandler = ActionsHandler(featureRouter)
private val testActionsHandler = TestActionsHandler()
/** Screen state */
var uiState by mutableStateOf(getInitialUiState())
private set
private var foundTokenId: String? = null
override fun onCreate(owner: LifecycleOwner) {
analyticsEventHandler.send(ManageTokens.CustomToken.ScreenOpened)
}
private fun getInitialUiState(): AddCustomTokenStateHolder {
return if (BuildConfig.TEST_ACTION_ENABLED) {
AddCustomTokenStateHolder.TestContent(
onBackButtonClick = actionsHandler::onBackButtonClick,
toolbar = createToolbar(),
form = createForm(),
warnings = listOf(),
floatingButton = createFloatingButton(),
testBlock = AddCustomTokenTestBlock(
chooseTokenButtonText = "Choose token",
clearButtonText = "Clear address",
resetButtonText = "Reset",
onClearAddressButtonClick = testActionsHandler::onClearAddressButtonClick,
onResetButtonClick = testActionsHandler::onResetButtonClick,
),
bottomSheet = AddCustomTokenChooseTokenBottomSheet(
categoriesBlocks = listOf(
TokensCategoryBlock(name = "Common", items = COMMON_TOKENS),
TokensCategoryBlock(name = "Solana", items = SOLANA_TOKENS),
),
onTestTokenClick = actionsHandler::onContactAddressValueChange,
),
)
} else {
AddCustomTokenStateHolder.Content(
onBackButtonClick = actionsHandler::onBackButtonClick,
toolbar = createToolbar(),
form = createForm(),
warnings = listOf(),
floatingButton = createFloatingButton(),
)
}
}
private fun createToolbar(): AddCustomTokensToolbar {
return AddCustomTokensToolbar(
title = TextReference.Res(R.string.add_custom_token_title),
onBackButtonClick = actionsHandler::onBackButtonClick,
)
}
private fun createForm(): AddCustomTokenForm {
return AddCustomTokenForm(
contractAddressInputField = createContractAddressInputField(),
networkSelectorField = createNetworkSelectorField(),
tokenNameInputField = createTokenNameInputField(),
tokenSymbolInputField = createTokenSymbolInputField(),
decimalsInputField = createDecimalsInputField(),
derivationPathSelectorField = createDerivationPathsSelectorField(),
)
}
private fun createContractAddressInputField(): AddCustomTokenInputField.ContactAddress {
return AddCustomTokenInputField.ContactAddress(
value = "",
onValueChange = actionsHandler::onContactAddressValueChange,
isError = false,
isLoading = false,
)
}
private fun createNetworkSelectorField(): AddCustomTokenSelectorField.Network {
val selectorItems = getNetworkSelectorItems()
return AddCustomTokenSelectorField.Network(
selectedItem = requireNotNull(selectorItems.firstOrNull()),
items = selectorItems,
onMenuItemClick = {
actionsHandler.onNetworkSelectorItemClick(
selectedItem = requireNotNull(selectorItems.getOrNull(it)),
)
},
)
}
private fun getNetworkSelectorItems(): List<AddCustomTokenSelectorField.SelectorItem.Title> {
val card = reduxStateHolder.scanResponse?.card
val evmBlockchains = Blockchain.values().filter { card?.isTestCard == it.isTestnet() && it.isEvm() }
val additionalBlockchains = listOf(
Blockchain.Binance,
Blockchain.BinanceTestnet,
Blockchain.Solana,
Blockchain.SolanaTestnet,
Blockchain.Tron,
Blockchain.TronTestnet,
)
return (evmBlockchains + additionalBlockchains)
.filter { card?.supportedBlockchains()?.contains(it) == true }
.map(::createNetworkSelectorItem)
.toMutableList()
.apply {
add(index = 0, element = createNetworkSelectorItem(blockchain = Blockchain.Unknown))
}
}
private fun createNetworkSelectorItem(blockchain: Blockchain): AddCustomTokenSelectorField.SelectorItem.Title {
return when (blockchain) {
Blockchain.Unknown -> {
AddCustomTokenSelectorField.SelectorItem.Title(
title = TextReference.Res(R.string.custom_token_network_input_not_selected),
blockchain = Blockchain.Unknown,
)
}
else -> {
AddCustomTokenSelectorField.SelectorItem.Title(
title = TextReference.Str(blockchain.fullName),
blockchain = blockchain,
)
}
}
}
private fun createTokenNameInputField(): AddCustomTokenInputField.TokenName {
return AddCustomTokenInputField.TokenName(
value = "",
onValueChange = actionsHandler::onTokenNameValueChange,
isEnabled = false,
isError = false,
)
}
private fun createTokenSymbolInputField(): AddCustomTokenInputField.TokenSymbol {
return AddCustomTokenInputField.TokenSymbol(
value = "",
onValueChange = actionsHandler::onTokenSymbolValueChange,
isEnabled = false,
isError = false,
)
}
private fun createDecimalsInputField(): AddCustomTokenInputField.Decimals {
return AddCustomTokenInputField.Decimals(
value = "",
onValueChange = actionsHandler::onDecimalsValueChange,
isEnabled = false,
isError = false,
)
}
private fun createDerivationPathsSelectorField(): AddCustomTokenSelectorField.DerivationPath? {
if (reduxStateHolder.scanResponse?.card?.settings?.isHDWalletAllowed == false) return null
val selectorItems = getDerivationPathsSelectorItems()
return AddCustomTokenSelectorField.DerivationPath(
isEnabled = true,
selectedItem = requireNotNull(selectorItems.firstOrNull()),
items = selectorItems,
onMenuItemClick = {
val field = requireNotNull(uiState.form.derivationPathSelectorField)
uiState = uiState.copySealed(
form = uiState.form.copy(
derivationPathSelectorField = field.copy(
selectedItem = requireNotNull(selectorItems.getOrNull(it)),
),
),
)
},
)
}
private fun getDerivationPathsSelectorItems(): List<AddCustomTokenSelectorField.SelectorItem.TitleWithSubtitle> {
val evmBlockchains = Blockchain.values().filter {
reduxStateHolder.scanResponse?.card?.isTestCard == it.isTestnet() && it.isEvm() && it.isSupportedInApp()
}
return evmBlockchains
.sortedBy(Blockchain::fullName)
.map(::createDerivationPathSelectorItem)
.toMutableList()
.apply {
add(index = 0, element = createDerivationPathSelectorItem(Blockchain.Unknown))
}
}
private fun createDerivationPathSelectorItem(
blockchain: Blockchain,
): AddCustomTokenSelectorField.SelectorItem.TitleWithSubtitle {
return when (blockchain) {
Blockchain.Unknown -> {
AddCustomTokenSelectorField.SelectorItem.TitleWithSubtitle(
title = TextReference.Res(R.string.custom_token_derivation_path_default),
subtitle = TextReference.Res(R.string.custom_token_derivation_path_default),
blockchain = Blockchain.Unknown,
)
}
else -> {
AddCustomTokenSelectorField.SelectorItem.TitleWithSubtitle(
title = blockchain.derivationPath(DerivationStyle.LEGACY)?.rawPath?.let(TextReference::Str)
?: TextReference.Res(R.string.custom_token_derivation_path_default),
subtitle = TextReference.Str(blockchain.fullName),
blockchain = blockchain,
)
}
}
}
private fun createFloatingButton(): AddCustomTokenFloatingButton {
return AddCustomTokenFloatingButton(isEnabled = false, onClick = actionsHandler::onAddCustomTokenClick)
}
private inner class ActionsHandler(private val featureRouter: CustomTokenRouter) {
fun onBackButtonClick() {
featureRouter.popBackStack()
}
fun onAddCustomTokenClick() {
if (uiState.form.networkSelectorField.selectedItem.blockchain != Blockchain.Unknown) {
val selectedNetwork = uiState.form.networkSelectorField.selectedItem.blockchain
val currency = if (isAnyTokenFieldsFilled() || isAllTokenFieldsFilled()) {
Currency.Token(
token = Token(
name = uiState.form.tokenNameInputField.value,
symbol = uiState.form.tokenSymbolInputField.value,
contractAddress = uiState.form.contractAddressInputField.value,
decimals = uiState.form.decimalsInputField.value.toInt(),
id = foundTokenId,
),
blockchain = selectedNetwork,
derivationPath = getDerivationPath(
mainNetwork = selectedNetwork,
derivationNetwork = uiState.form.derivationPathSelectorField?.selectedItem?.blockchain,
derivationStyle = reduxStateHolder.scanResponse?.card?.derivationStyle,
)?.rawPath,
)
} else {
Currency.Blockchain(
blockchain = selectedNetwork,
derivationPath = getDerivationPath(
mainNetwork = selectedNetwork,
derivationNetwork = uiState.form.derivationPathSelectorField?.selectedItem?.blockchain,
derivationStyle = reduxStateHolder.scanResponse?.card?.derivationStyle,
)?.rawPath,
)
}
sendOnAddTokenButtonClick(currency = currency, address = uiState.form.contractAddressInputField.value)
viewModelScope.launch(dispatchers.io) {
featureInteractor.saveToken(
currency = currency,
address = uiState.form.contractAddressInputField.value,
)
}
}
}
fun onContactAddressValueChange(enteredValue: String) {
with(uiState.form) {
val selectedNetwork = networkSelectorField.selectedItem.blockchain
val isValid = ContactAddressValidator.validate(
address = enteredValue,
blockchain = selectedNetwork,
)
when (isValid) {
is ContractAddressValidatorResult.Success -> {
uiState = uiState.copySealed(
form = uiState.form.copy(
contractAddressInputField = contractAddressInputField.copy(
isError = false,
isLoading = true,
),
),
)
updateForm(address = enteredValue, selectedNetwork = selectedNetwork)
}
is ContractAddressValidatorResult.Error -> {
handleContractAddressErrorValidation(type = isValid.type)
}
}
updateDerivationPathSelector()
// TODO("[REDACTED_TASK_KEY] Update warnings")
// TODO("[REDACTED_TASK_KEY] Update floating button")
}
}
fun onNetworkSelectorItemClick(selectedItem: AddCustomTokenSelectorField.SelectorItem.Title) {
uiState = uiState.copySealed(
form = uiState.form.copy(
networkSelectorField = uiState.form.networkSelectorField.copy(selectedItem = selectedItem),
),
)
onContactAddressValueChange(uiState.form.contractAddressInputField.value)
}
fun onTokenNameValueChange(enteredValue: String) {
uiState = uiState.copySealed(
form = uiState.form.copy(
tokenNameInputField = uiState.form.tokenNameInputField.copy(value = enteredValue),
),
)
// TODO("[REDACTED_TASK_KEY] Update floating button")
}
fun onTokenSymbolValueChange(enteredValue: String) {
uiState = uiState.copySealed(
form = uiState.form.copy(
tokenSymbolInputField = uiState.form.tokenSymbolInputField.copy(value = enteredValue),
),
)
// TODO("[REDACTED_TASK_KEY] Update floating button")
}
fun onDecimalsValueChange(enteredValue: String) {
uiState = uiState.copySealed(
form = uiState.form.copy(
decimalsInputField = uiState.form.decimalsInputField.copy(value = enteredValue),
),
)
// TODO("[REDACTED_TASK_KEY] Update floating button")
}
private fun getDerivationPath(
mainNetwork: Blockchain,
derivationNetwork: Blockchain?,
derivationStyle: DerivationStyle?,
): DerivationPath? {
val network = if (derivationNetwork == Blockchain.Unknown) mainNetwork else derivationNetwork
return network?.derivationPath(
style = if (derivationNetwork == Blockchain.Unknown) derivationStyle else DerivationStyle.LEGACY,
)
}
private fun sendOnAddTokenButtonClick(currency: Currency, address: String) {
when (currency) {
is Currency.Blockchain -> {
analyticsEventHandler.send(
ManageTokens.CustomToken.TokenWasAdded.Blockchain(
derivationPath = currency.derivationPath,
blockchain = currency.blockchain,
),
)
}
is Currency.Token -> {
analyticsEventHandler.send(
ManageTokens.CustomToken.TokenWasAdded.Token(
symbol = currency.currencySymbol,
derivationPath = currency.derivationPath,
blockchain = currency.blockchain,
contractAddress = address,
),
)
}
}
}
private fun isAnyTokenFieldsFilled(): Boolean {
return with(uiState.form) {
contractAddressInputField.value.isNotEmpty() || tokenNameInputField.value.isNotEmpty() ||
tokenSymbolInputField.value.isNotEmpty() || decimalsInputField.value.isNotEmpty()
}
}
private fun isAllTokenFieldsFilled(): Boolean {
return with(uiState.form) {
contractAddressInputField.value.isNotEmpty() && tokenNameInputField.value.isNotEmpty() &&
tokenSymbolInputField.value.isNotEmpty() && decimalsInputField.value.isNotEmpty()
}
}
private fun updateForm(address: String, selectedNetwork: Blockchain) {
viewModelScope.launch(dispatchers.main) {
runCatching(dispatchers.io) {
featureInteractor.findToken(address = address, blockchain = selectedNetwork)
}
.onSuccess { token ->
with(uiState.form) {
uiState = uiState.copySealed(
form = copy(
contractAddressInputField = contractAddressInputField.copy(isLoading = false),
networkSelectorField = networkSelectorField.copy(
selectedItem = createNetworkSelectorItem(
blockchain = Blockchain.fromNetworkId(token.network.id)
?: Blockchain.Unknown,
),
),
tokenNameInputField = tokenNameInputField.copy(
value = token.name,
isEnabled = false,
),
tokenSymbolInputField = tokenSymbolInputField.copy(
value = token.symbol,
isEnabled = false,
),
decimalsInputField = decimalsInputField.copy(
value = token.network.decimalCount,
isEnabled = false,
),
),
)
}
}
.onFailure {
foundTokenId = null
Timber.e(it)
}
}
}
private fun handleContractAddressErrorValidation(type: AddCustomTokenError) {
with(uiState.form) {
val isNetworkSelectorFilled = networkSelectorField.selectedItem.blockchain != Blockchain.Unknown
val isAnotherTokenFieldsFilled = isAnyTokenFieldsFilled()
when {
isNetworkSelectorFilled && type == AddCustomTokenError.InvalidContractAddress -> {
// TODO("[REDACTED_TASK_KEY] Add error")
uiState = uiState.copySealed(
form = uiState.form.copy(
tokenNameInputField = tokenNameInputField.copy(isEnabled = isAnotherTokenFieldsFilled),
tokenSymbolInputField = tokenSymbolInputField.copy(
isEnabled = isAnotherTokenFieldsFilled,
),
decimalsInputField = decimalsInputField.copy(isEnabled = isAnotherTokenFieldsFilled),
),
)
}
!isNetworkSelectorFilled || type == AddCustomTokenError.FieldIsEmpty -> {
uiState = uiState.copySealed(
form = uiState.form.copy(
contractAddressInputField = contractAddressInputField.copy(isError = false),
tokenNameInputField = tokenNameInputField.copy(value = "", isEnabled = false),
tokenSymbolInputField = tokenSymbolInputField.copy(value = "", isEnabled = false),
decimalsInputField = decimalsInputField.copy(value = "", isEnabled = false),
),
)
}
else -> Unit
}
}
}
private fun updateDerivationPathSelector() {
val selectedValue = uiState.form.derivationPathSelectorField?.selectedItem?.blockchain ?: return
val isSupported = selectedValue.isEvm() || selectedValue == Blockchain.Unknown
if (selectedValue != Blockchain.Unknown && !isSupported) {
uiState = uiState.copySealed(
form = uiState.form.copy(
derivationPathSelectorField = uiState.form.derivationPathSelectorField?.copy(
selectedItem = createDerivationPathSelectorItem(Blockchain.Unknown),
),
),
)
}
if (uiState.form.derivationPathSelectorField?.isEnabled != isSupported) {
uiState = uiState.copySealed(
form = uiState.form.copy(
derivationPathSelectorField = uiState.form.derivationPathSelectorField?.copy(
isEnabled = isSupported,
),
),
)
}
}
}
private inner class TestActionsHandler {
fun onClearAddressButtonClick() {
with(uiState.form) {
uiState = uiState.copySealed(
form = copy(
contractAddressInputField = contractAddressInputField.copy(value = ""),
tokenNameInputField = tokenNameInputField.copy(value = "", isEnabled = false),
tokenSymbolInputField = tokenSymbolInputField.copy(value = "", isEnabled = false),
decimalsInputField = decimalsInputField.copy(value = "", isEnabled = false),
),
)
}
}
fun onResetButtonClick() {
with(uiState.form) {
uiState = uiState.copySealed(
form = copy(
contractAddressInputField = contractAddressInputField.copy(value = ""),
networkSelectorField = networkSelectorField.copy(
selectedItem = requireNotNull(networkSelectorField.items.firstOrNull()),
),
tokenNameInputField = tokenNameInputField.copy(value = "", isEnabled = false),
tokenSymbolInputField = tokenSymbolInputField.copy(value = "", isEnabled = false),
decimalsInputField = decimalsInputField.copy(value = "", isEnabled = false),
derivationPathSelectorField = derivationPathSelectorField?.copy(
selectedItem = requireNotNull(derivationPathSelectorField.items.firstOrNull()),
),
),
)
}
}
}
private companion object {
val COMMON_TOKENS = persistentListOf(
TestTokenItem(name = "USDC on ETH", address = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"),
TestTokenItem(name = "BUSD on ETH", address = "0x4fabb145d64652a948d72533023f6e7a623c7c53"),
TestTokenItem(name = "ETH on AVALANCHE", address = "0xf20d962a6c8f70c731bd838a3a388d7d48fa6e15"),
TestTokenItem(name = "USDC on ETH (invalid - cut address)", address = "0xa0b86991c6218b36c1d1"),
TestTokenItem(name = "Custom EVM", address = "0x1111111111111111112111111111111111111113"),
TestTokenItem(
name = "Supported by several networks",
address = "0xa1faa113cbe53436df28ff0aee54275c13b40975",
),
TestTokenItem(name = "Invalid", address = "!@#_ _-%%^&&*((){P P2iOWsdfFQLA"),
)
val SOLANA_TOKENS = persistentListOf(
TestTokenItem(name = "USDT (full)", address = "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB"),
TestTokenItem(
name = "USDT (valid - 2/3 of address)",
address = "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8Ben",
),
TestTokenItem(name = "USDT (invalid - 1/3 of address)", address = "Es9vMFrzaCERmJ"),
TestTokenItem(name = "ETH (full)", address = "2FPyTwcZLUg1MDrwsyoP4D6s1tM7hAkHYRjkNb5w6Pxk"),
)
}
}

View file

@ -1,4 +1,4 @@
package com.tangem.tap.features.addCustomToken
package com.tangem.tap.features.customtoken.legacy
import android.os.Bundle
import android.view.View
@ -19,7 +19,7 @@ import com.tangem.tap.common.compose.ClosePopupTrigger
import com.tangem.tap.features.BaseStoreFragment
import com.tangem.tap.features.FragmentOnBackPressedHandler
import com.tangem.tap.features.addBackPressHandler
import com.tangem.tap.features.addCustomToken.compose.AddCustomTokenScreen
import com.tangem.tap.features.customtoken.legacy.compose.AddCustomTokenScreen
import com.tangem.wallet.R
import org.rekotlin.StoreSubscriber
@ -32,7 +32,7 @@ class AddCustomTokenFragment : BaseStoreFragment(R.layout.view_compose_fragment)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Analytics.send(ManageTokens.CustomToken.ScreenOpened())
Analytics.send(ManageTokens.CustomToken.ScreenOpened)
}
override fun subscribeToStore() {

View file

@ -1,4 +1,4 @@
package com.tangem.tap.features.addCustomToken.compose
package com.tangem.tap.features.customtoken.legacy.compose
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -52,8 +52,8 @@ 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.domain.moduleMessage.ModuleMessageConverter
import com.tangem.tap.features.addCustomToken.compose.test.TestCase
import com.tangem.tap.features.addCustomToken.compose.test.TestCasesList
import com.tangem.tap.features.customtoken.legacy.compose.test.TestCase
import com.tangem.tap.features.customtoken.legacy.compose.test.TestCasesList
import com.tangem.wallet.R
import kotlinx.coroutines.launch

View file

@ -1,4 +1,4 @@
package com.tangem.tap.features.addCustomToken.compose
package com.tangem.tap.features.customtoken.legacy.compose
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable

View file

@ -1,4 +1,4 @@
package com.tangem.tap.features.addCustomToken.compose
package com.tangem.tap.features.customtoken.legacy.compose
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope

View file

@ -1,4 +1,4 @@
package com.tangem.tap.features.addCustomToken.compose
package com.tangem.tap.features.customtoken.legacy.compose
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource

View file

@ -1,4 +1,4 @@
package com.tangem.tap.features.addCustomToken.compose.test
package com.tangem.tap.features.customtoken.legacy.compose.test
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth

View file

@ -1,4 +1,4 @@
package com.tangem.tap.features.addCustomToken.compose.test
package com.tangem.tap.features.customtoken.legacy.compose.test
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row

View file

@ -9,7 +9,7 @@ import com.tangem.blockchain.common.toBlockchainSdkError
import com.tangem.blockchain.extensions.Result
import com.tangem.blockchain.extensions.SimpleResult
import com.tangem.common.CompletionResult
import com.tangem.domain.common.ScanResponse
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.common.demo.DemoConfig
import com.tangem.tap.common.extensions.dispatchNotification
import com.tangem.tap.common.redux.AppState

View file

@ -1,7 +1,7 @@
package com.tangem.tap.features.demo
import com.tangem.common.extensions.guard
import com.tangem.domain.common.ScanResponse
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.common.demo.DemoConfig
import com.tangem.domain.common.extensions.withMainContext
import com.tangem.tap.domain.extensions.makePrimaryWalletManager

View file

@ -1,7 +1,7 @@
package com.tangem.tap.features.demo
import com.tangem.domain.common.CardDTO
import com.tangem.domain.common.ScanResponse
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.scan.ScanResponse
/**
[REDACTED_AUTHOR]

View file

@ -1,9 +1,9 @@
package com.tangem.tap.features.details.redux
import com.tangem.blockchain.common.Wallet
import com.tangem.domain.common.CardDTO
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.common.CardTypesResolver
import com.tangem.domain.common.ScanResponse
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.tap.common.entities.FiatCurrency
import org.rekotlin.Action
@ -29,6 +29,15 @@ sealed class DetailsAction : Action {
data class PrepareCardSettingsData(val card: CardDTO, val cardTypesResolver: CardTypesResolver) : DetailsAction()
object ResetCardSettingsData : DetailsAction()
sealed class AccessCodeRecovery : DetailsAction() {
object Open : AccessCodeRecovery()
data class SaveChanges(val enabled: Boolean) : AccessCodeRecovery() {
data class Success(val enabled: Boolean) : AccessCodeRecovery()
}
data class SelectOption(val enabled: Boolean) : AccessCodeRecovery()
}
sealed class ManageSecurity : DetailsAction() {
object OpenSecurity : ManageSecurity()
data class SelectOption(val option: SecurityOption) : ManageSecurity()

View file

@ -7,8 +7,9 @@ import com.tangem.common.doOnSuccess
import com.tangem.common.extensions.guard
import com.tangem.common.flatMap
import com.tangem.core.analytics.Analytics
import com.tangem.domain.common.ScanResponse
import com.tangem.domain.common.TapWorkarounds.isTangemTwins
import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.tap.common.analytics.events.AnalyticsParam
import com.tangem.tap.common.analytics.events.Settings
import com.tangem.tap.common.extensions.dispatchDialogShow
@ -51,6 +52,7 @@ class DetailsMiddleware {
private val eraseWalletMiddleware = EraseWalletMiddleware()
private val manageSecurityMiddleware = ManageSecurityMiddleware()
private val managePrivacyMiddleware = ManagePrivacyMiddleware()
private val accessCodeRecoveryMiddleware = AccessCodeRecoveryMiddleware()
val detailsMiddleware: Middleware<AppState> = { _, stateProvider ->
{ next ->
{ action ->
@ -74,6 +76,7 @@ class DetailsMiddleware {
store.dispatch(TwinCardsAction.SetMode(CreateTwinWalletMode.RecreateWallet))
store.dispatch(NavigationAction.NavigateTo(AppScreen.OnboardingTwins))
}
is DetailsAction.AccessCodeRecovery -> accessCodeRecoveryMiddleware.handle(state, action)
DetailsAction.ScanCard -> {
scope.launch {
tangemSdkManager.scanProduct(
@ -418,4 +421,34 @@ class DetailsMiddleware {
)
}
}
class AccessCodeRecoveryMiddleware {
fun handle(state: DetailsState, action: DetailsAction.AccessCodeRecovery) {
when (action) {
is DetailsAction.AccessCodeRecovery.Open -> {
Analytics.send(Settings.CardSettings.AccessCodeRecoveryButton())
store.dispatch(NavigationAction.NavigateTo(AppScreen.AccessCodeRecovery))
}
is DetailsAction.AccessCodeRecovery.SaveChanges -> {
scope.launch {
tangemSdkManager
.setAccessCodeRecoveryEnabled(state.cardSettingsState?.card?.cardId, action.enabled)
.doOnSuccess {
Analytics.send(
Settings.CardSettings.AccessCodeRecoveryChanged(
AnalyticsParam.AccessCodeRecoveryStatus.from(action.enabled),
),
)
store.dispatchOnMain(NavigationAction.PopBackTo())
store.dispatchOnMain(
DetailsAction.AccessCodeRecovery.SaveChanges.Success(action.enabled),
)
}
}
}
is DetailsAction.AccessCodeRecovery.SelectOption -> Unit
is DetailsAction.AccessCodeRecovery.SaveChanges.Success -> Unit
}
}
}
}

View file

@ -1,7 +1,8 @@
package com.tangem.tap.features.details.redux
import com.tangem.domain.common.CardDTO
import com.tangem.domain.common.CardTypesResolver
import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.domain.models.scan.CardDTO
import com.tangem.tap.common.redux.AppState
import com.tangem.tap.domain.extensions.signedHashesCount
import com.tangem.tap.preferencesStorage
@ -40,6 +41,7 @@ private fun internalReduce(action: Action, state: AppState): DetailsState {
}
is DetailsAction.ChangeAppCurrency ->
detailsState.copy(appCurrency = action.fiatCurrency)
is DetailsAction.AccessCodeRecovery -> handleAccessCodeRecoveryAction(action, detailsState)
else -> detailsState
}
}
@ -68,6 +70,15 @@ private fun handlePrepareCardSettingsScreen(
manageSecurityState = prepareSecurityOptions(card, cardTypesResolver),
card = card,
resetCardAllowed = isResetToFactoryAllowedByCard(card, cardTypesResolver),
accessCodeRecovery = if (cardTypesResolver.isWallet2()) {
val enabled = card.userSettings?.isUserCodeRecoveryAllowed ?: false
AccessCodeRecoveryState(
enabledOnCard = enabled,
enabledSelection = enabled,
)
} else {
null
},
)
return state.copy(cardSettingsState = cardSettingsState)
}
@ -189,6 +200,34 @@ private fun handlePrivacyAction(action: DetailsAction.AppSettings, state: Detail
}
}
private fun handleAccessCodeRecoveryAction(
action: DetailsAction.AccessCodeRecovery,
state: DetailsState,
): DetailsState {
return when (action) {
DetailsAction.AccessCodeRecovery.Open -> {
val accessCodeRecovery = state.cardSettingsState?.accessCodeRecovery?.copy(
enabledSelection = state.cardSettingsState.accessCodeRecovery.enabledOnCard,
)
state.copy(cardSettingsState = state.cardSettingsState?.copy(accessCodeRecovery = accessCodeRecovery))
}
is DetailsAction.AccessCodeRecovery.SaveChanges -> state
is DetailsAction.AccessCodeRecovery.SelectOption -> {
val accessCodeRecovery = state.cardSettingsState?.accessCodeRecovery?.copy(
enabledSelection = action.enabled,
)
state.copy(cardSettingsState = state.cardSettingsState?.copy(accessCodeRecovery = accessCodeRecovery))
}
is DetailsAction.AccessCodeRecovery.SaveChanges.Success -> {
val accessCodeRecovery = state.cardSettingsState?.accessCodeRecovery?.copy(
enabledOnCard = action.enabled,
enabledSelection = action.enabled,
)
state.copy(cardSettingsState = state.cardSettingsState?.copy(accessCodeRecovery = accessCodeRecovery))
}
}
}
private fun prepareAllowedSecurityOptions(
cardTypesResolver: CardTypesResolver,
currentSecurityOption: SecurityOption?,

View file

@ -1,8 +1,8 @@
package com.tangem.tap.features.details.redux
import com.tangem.blockchain.common.Wallet
import com.tangem.domain.common.CardDTO
import com.tangem.domain.common.ScanResponse
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.tap.common.entities.Button
import com.tangem.tap.common.entities.FiatCurrency
import org.rekotlin.StateType
@ -26,12 +26,22 @@ data class CardInfo(
val hasBackup: Boolean,
)
/**
* @property enabledOnCard whether access code recovery is enabled on card
* @property enabledSelection current selected option in app (not saved on card yet)
*/
data class AccessCodeRecoveryState(
val enabledOnCard: Boolean,
val enabledSelection: Boolean,
)
data class CardSettingsState(
val cardInfo: CardInfo,
val card: CardDTO,
val manageSecurityState: ManageSecurityState?,
val resetCardAllowed: Boolean,
val resetConfirmed: Boolean = false,
val accessCodeRecovery: AccessCodeRecoveryState? = null,
)
data class ManageSecurityState(

View file

@ -1,7 +1,7 @@
package com.tangem.tap.features.details.redux.walletconnect
import com.tangem.blockchain.common.Blockchain
import com.tangem.domain.common.ScanResponse
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.tap.common.redux.NotificationAction
import com.tangem.tap.domain.TapError
import com.tangem.wallet.R

View file

@ -4,10 +4,11 @@ import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.DerivationStyle
import com.tangem.blockchain.common.WalletManager
import com.tangem.common.extensions.guard
import com.tangem.domain.common.CardDTO
import com.tangem.domain.common.ScanResponse
import com.tangem.domain.common.TapWorkarounds.derivationStyle
import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.domain.common.extensions.withMainContext
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.tap.common.extensions.dispatchOnMain
import com.tangem.tap.common.redux.AppDialog
import com.tangem.tap.common.redux.AppState

View file

@ -6,7 +6,7 @@ import com.tangem.blockchain.common.DerivationStyle
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.WalletManager
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.domain.common.ScanResponse
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.tap.common.redux.StateDialog
import com.tangem.tap.features.details.ui.walletconnect.dialogs.PersonalSignDialogData
import com.tangem.tap.features.details.ui.walletconnect.dialogs.TransactionRequestDialogData

View file

@ -120,6 +120,7 @@ fun CardSettings(state: CardSettingsScreenState) {
is CardInfo.SignedHashes -> 14.dp
is CardInfo.SecurityMode -> 16.dp
is CardInfo.ChangeAccessCode -> 16.dp
is CardInfo.AccessCodeRecovery -> 16.dp
is CardInfo.ResetToFactorySettings -> 28.dp
}
val paddingTop = when (it) {
@ -128,6 +129,7 @@ fun CardSettings(state: CardSettingsScreenState) {
is CardInfo.SignedHashes -> 12.dp
is CardInfo.SecurityMode -> 14.dp
is CardInfo.ChangeAccessCode -> 16.dp
is CardInfo.AccessCodeRecovery -> 16.dp
is CardInfo.ResetToFactorySettings -> 16.dp
}
Column(

View file

@ -4,6 +4,7 @@ import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.res.stringResource
import com.tangem.tap.features.details.redux.AccessCodeRecoveryState
import com.tangem.tap.features.details.redux.SecurityOption
import com.tangem.tap.features.details.ui.securitymode.toTitleRes
import com.tangem.tap.features.details.ui.utils.toResetCardDescriptionText
@ -12,6 +13,7 @@ import com.tangem.tap.features.details.redux.CardInfo as ReduxCardInfo
data class CardSettingsScreenState(
val cardDetails: List<CardInfo>? = null,
val accessCodeRecoveryState: AccessCodeRecoveryState? = null,
val onScanCardClick: () -> Unit,
val onElementClick: (CardInfo) -> Unit,
)
@ -48,6 +50,16 @@ sealed class CardInfo(
clickable = true,
)
class AccessCodeRecovery(val enabled: Boolean) : CardInfo(
titleRes = TextReference.Res(R.string.card_settings_access_code_recovery_title),
subtitle = if (enabled) {
TextReference.Res(R.string.common_enabled)
} else {
TextReference.Res(R.string.common_disabled)
},
clickable = true,
)
class ResetToFactorySettings(cardInfo: ReduxCardInfo) : CardInfo(
titleRes = TextReference.Res(R.string.card_settings_reset_card_to_factory),
subtitle = cardInfo.toResetCardDescriptionText(),

View file

@ -16,6 +16,7 @@ class CardSettingsViewModel(private val store: Store<AppState>) {
return if (state?.manageSecurityState == null) {
CardSettingsScreenState(
cardDetails = null,
accessCodeRecoveryState = null,
onElementClick = {},
onScanCardClick = {
store.dispatch(DetailsAction.ScanCard)
@ -44,12 +45,15 @@ class CardSettingsViewModel(private val store: Store<AppState>) {
if (state.card.backupStatus?.isActive == true && state.card.isAccessCodeSet) {
cardDetails.add(CardInfo.ChangeAccessCode)
}
if (state.accessCodeRecovery != null) {
cardDetails.add(CardInfo.AccessCodeRecovery(state.accessCodeRecovery.enabledOnCard))
}
if (state.resetCardAllowed) {
cardDetails.add(CardInfo.ResetToFactorySettings(state.cardInfo))
}
CardSettingsScreenState(
cardDetails = cardDetails,
accessCodeRecoveryState = state.accessCodeRecovery,
onScanCardClick = { },
onElementClick = {
handleClickingItem(it)
@ -72,6 +76,9 @@ class CardSettingsViewModel(private val store: Store<AppState>) {
Analytics.send(Settings.CardSettings.ButtonChangeSecurityMode())
store.dispatch(DetailsAction.ManageSecurity.OpenSecurity)
}
is CardInfo.AccessCodeRecovery -> {
store.dispatch(DetailsAction.AccessCodeRecovery.Open)
}
else -> {}
}
}

View file

@ -0,0 +1,67 @@
package com.tangem.tap.features.details.ui.cardsettings.coderecovery
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.platform.ComposeView
import androidx.fragment.app.Fragment
import androidx.transition.TransitionInflater
import com.tangem.core.ui.res.TangemTheme
import com.tangem.tap.common.redux.navigation.NavigationAction
import com.tangem.tap.features.details.redux.DetailsState
import com.tangem.tap.store
import com.tangem.wallet.R
import org.rekotlin.StoreSubscriber
class AccessCodeRecoveryFragment : Fragment(), StoreSubscriber<DetailsState> {
private val viewModel = AccessCodeRecoveryViewModel(store)
private var screenState: MutableState<AccessCodeRecoveryScreenState> =
mutableStateOf(viewModel.updateState(store.state.detailsState.cardSettingsState?.accessCodeRecovery))
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val inflater = TransitionInflater.from(requireContext())
enterTransition = inflater.inflateTransition(R.transition.fade)
exitTransition = inflater.inflateTransition(R.transition.fade)
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
return ComposeView(requireContext()).apply {
setContent {
isTransitionGroup = true
TangemTheme {
AccessCodeRecoveryScreen(
state = screenState.value,
onBackClick = { store.dispatch(NavigationAction.PopBackTo()) },
)
}
}
}
}
override fun onStart() {
super.onStart()
store.subscribe(this) { state ->
state.skipRepeats { oldState, newState ->
oldState.detailsState == newState.detailsState
}.select { it.detailsState }
}
}
override fun onStop() {
super.onStop()
store.unsubscribe(this)
}
override fun newState(state: DetailsState) {
if (activity == null || view == null) return
screenState.value =
viewModel.updateState(store.state.detailsState.cardSettingsState?.accessCodeRecovery)
}
}

View file

@ -0,0 +1,64 @@
package com.tangem.tap.features.details.ui.cardsettings.coderecovery
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.tangem.core.ui.res.TangemTheme
import com.tangem.tap.features.details.ui.common.DetailsMainButton
import com.tangem.tap.features.details.ui.common.DetailsRadioButtonElement
import com.tangem.tap.features.details.ui.common.ScreenTitle
import com.tangem.tap.features.details.ui.common.SettingsScreensScaffold
import com.tangem.wallet.R
@Composable
fun AccessCodeRecoveryScreen(state: AccessCodeRecoveryScreenState, onBackClick: () -> Unit) {
SettingsScreensScaffold(
content = { AccessCodeRecoveryOptions(state = state) },
onBackClick = onBackClick,
)
}
@Composable
fun AccessCodeRecoveryOptions(state: AccessCodeRecoveryScreenState) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(bottom = TangemTheme.dimens.spacing28),
verticalArrangement = Arrangement.SpaceBetween,
) {
ScreenTitle(
titleRes = R.string.card_settings_access_code_recovery_title,
Modifier.padding(bottom = TangemTheme.dimens.spacing36),
)
DetailsRadioButtonElement(
title = stringResource(id = R.string.common_enabled),
subtitle = stringResource(id = R.string.card_settings_access_code_recovery_enabled_description),
selected = state.enabledSelection,
onClick = { state.onOptionClick(true) },
)
DetailsRadioButtonElement(
title = stringResource(id = R.string.common_disabled),
subtitle = stringResource(id = R.string.card_settings_access_code_recovery_disabled_description),
selected = !state.enabledSelection,
onClick = { state.onOptionClick(false) },
)
Spacer(modifier = Modifier.weight(1f))
DetailsMainButton(
title = stringResource(id = R.string.common_save_changes),
enabled = state.isSaveChangesEnabled,
onClick = { state.onSaveChangesClick(state.enabledSelection) },
modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing20),
)
}
}

View file

@ -0,0 +1,16 @@
package com.tangem.tap.features.details.ui.cardsettings.coderecovery
/**
* @property enabledOnCard Indicates whether access code recovery is enabled on the card
* @property enabledSelection Represents the currently selected option in the app (not yet saved on the card)
* @property isSaveChangesEnabled Determines if the user is allowed to save their selection to the card
* @property onSaveChangesClick Callback function called when the user wants to apply the selected option
* @property onOptionClick Callback function called when the user selects an option
* */
data class AccessCodeRecoveryScreenState(
val enabledOnCard: Boolean,
val enabledSelection: Boolean,
val isSaveChangesEnabled: Boolean,
val onSaveChangesClick: (Boolean) -> Unit,
val onOptionClick: (Boolean) -> Unit,
)

View file

@ -0,0 +1,30 @@
package com.tangem.tap.features.details.ui.cardsettings.coderecovery
import com.tangem.tap.common.redux.AppState
import com.tangem.tap.features.details.redux.AccessCodeRecoveryState
import com.tangem.tap.features.details.redux.DetailsAction
import org.rekotlin.Store
class AccessCodeRecoveryViewModel(val store: Store<AppState>) {
fun updateState(state: AccessCodeRecoveryState?): AccessCodeRecoveryScreenState {
// We shouldn't get to this screen here when this state is null
return if (state == null) {
AccessCodeRecoveryScreenState(
enabledOnCard = false,
enabledSelection = false,
isSaveChangesEnabled = false,
onSaveChangesClick = {},
onOptionClick = {},
)
} else {
AccessCodeRecoveryScreenState(
enabledOnCard = state.enabledOnCard,
enabledSelection = state.enabledSelection,
isSaveChangesEnabled = state.enabledOnCard != state.enabledSelection,
onSaveChangesClick = { store.dispatch(DetailsAction.AccessCodeRecovery.SaveChanges(it)) },
onOptionClick = { store.dispatch(DetailsAction.AccessCodeRecovery.SelectOption(it)) },
)
}
}
}

View file

@ -3,18 +3,18 @@ package com.tangem.tap.features.details.ui.common
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.foundation.selection.selectable
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.RadioButton
import androidx.compose.material.RadioButtonDefaults
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
@ -25,7 +25,9 @@ import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.PrimaryButtonIconRight
import com.tangem.core.ui.res.TangemTheme
import com.tangem.tap.common.compose.TangemTypography
import com.tangem.wallet.R
@Composable
@ -109,26 +111,49 @@ fun EmptyTopBarWithNavigation(
@Composable
fun DetailsMainButton(title: String, onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true) {
Button(
PrimaryButtonIconRight(
text = title,
enabled = enabled,
onClick = onClick,
modifier = modifier
.fillMaxWidth(),
icon = painterResource(id = R.drawable.ic_tangem_24),
)
}
@Composable
fun DetailsRadioButtonElement(title: String, subtitle: String, selected: Boolean, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(48.dp),
shape = RoundedCornerShape(12.dp),
enabled = enabled,
colors = ButtonDefaults.buttonColors(
backgroundColor = colorResource(R.color.button_primary),
contentColor = colorResource(R.color.text_primary_2),
disabledBackgroundColor = colorResource(R.color.button_disabled),
disabledContentColor = colorResource(R.color.text_disabled),
),
.selectable(
selected = selected,
onClick = { onClick() },
)
.padding(start = 20.dp, end = 20.dp, top = 16.dp, bottom = 16.dp),
) {
Text(text = title)
Spacer(
modifier = Modifier
.padding(start = 20.dp, end = 20.dp)
.size(8.dp),
RadioButton(
selected = selected,
onClick = null,
modifier = Modifier.padding(end = 20.dp),
colors = RadioButtonDefaults.colors(
unselectedColor = colorResource(id = R.color.icon_secondary),
selectedColor = colorResource(id = R.color.icon_accent),
),
)
Icon(painter = painterResource(id = R.drawable.ic_tangem_24), contentDescription = "")
Column {
Text(
text = title,
style = TangemTypography.subtitle1,
color = colorResource(id = R.color.text_primary_1),
)
Spacer(modifier = Modifier.size(4.dp))
Text(
text = subtitle,
style = TangemTypography.body2,
color = colorResource(id = R.color.text_secondary),
)
}
}
}

View file

@ -3,6 +3,7 @@ package com.tangem.tap.features.details.ui.details
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import com.tangem.core.analytics.Analytics
import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.tap.common.analytics.events.Settings
import com.tangem.tap.common.feedback.FeedbackEmail
import com.tangem.tap.common.feedback.SupportInfo

View file

@ -2,27 +2,19 @@ package com.tangem.tap.features.details.ui.securitymode
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.RadioButton
import androidx.compose.material.RadioButtonDefaults
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.tap.common.compose.TangemTypography
import com.tangem.tap.features.details.redux.SecurityOption
import com.tangem.tap.features.details.ui.common.DetailsMainButton
import com.tangem.tap.features.details.ui.common.DetailsRadioButtonElement
import com.tangem.tap.features.details.ui.common.ScreenTitle
import com.tangem.tap.features.details.ui.common.SettingsScreensScaffold
import com.tangem.wallet.R
@ -74,39 +66,12 @@ fun SecurityOption(option: SecurityOption, state: SecurityModeScreenState) {
SecurityOption.AccessCode -> R.string.details_manage_security_access_code_description
}
Row(
modifier = Modifier
.fillMaxWidth()
.selectable(
selected = selected,
onClick = { state.onNewModeSelected(option) },
)
.padding(start = 20.dp, end = 20.dp, top = 16.dp, bottom = 16.dp),
) {
RadioButton(
selected = selected,
onClick = null,
modifier = Modifier.padding(end = 20.dp),
colors = RadioButtonDefaults.colors(
unselectedColor = colorResource(id = R.color.icon_secondary),
selectedColor = colorResource(id = R.color.icon_accent),
),
)
Column {
Text(
text = stringResource(id = title),
style = TangemTypography.subtitle1,
color = colorResource(id = R.color.text_primary_1),
)
Spacer(modifier = Modifier.size(4.dp))
Text(
text = stringResource(id = subtitle),
style = TangemTypography.body2,
color = colorResource(id = R.color.text_secondary),
)
}
}
DetailsRadioButtonElement(
title = stringResource(id = title),
subtitle = stringResource(id = subtitle),
selected = selected,
onClick = { state.onNewModeSelected(option) },
)
}
@Preview

View file

@ -1,6 +1,6 @@
package com.tangem.tap.features.disclaimer
import com.tangem.domain.common.CardDTO
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.common.TapWorkarounds.isSaltPay
import com.tangem.domain.common.TapWorkarounds.isStart2Coin
import com.tangem.tap.persistence.DisclaimerPrefStorage

View file

@ -5,7 +5,7 @@ import com.tangem.common.doOnResult
import com.tangem.common.doOnSuccess
import com.tangem.core.analytics.Analytics
import com.tangem.core.analytics.AnalyticsEvent
import com.tangem.domain.common.ScanResponse
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.tap.common.analytics.events.Basic
import com.tangem.tap.common.analytics.events.IntroductionProcess
import com.tangem.tap.common.analytics.events.Shop

View file

@ -4,8 +4,10 @@ import com.tangem.common.doOnFailure
import com.tangem.common.doOnSuccess
import com.tangem.common.extensions.guard
import com.tangem.core.analytics.Analytics
import com.tangem.domain.common.ProductType
import com.tangem.domain.common.ScanResponse
import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.domain.common.util.twinsIsTwinned
import com.tangem.domain.models.scan.ProductType
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.tap.common.extensions.dispatchOnMain
import com.tangem.tap.common.extensions.onUserWalletSelected
import com.tangem.tap.common.extensions.removeContext

View file

@ -5,7 +5,7 @@ import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.WalletManager
import com.tangem.common.extensions.isZero
import com.tangem.common.services.Result
import com.tangem.domain.common.ScanResponse
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.operations.attestation.CardVerifyAndGetInfo
import com.tangem.operations.attestation.OnlineCardVerifier
import com.tangem.tap.common.extensions.isPositive

View file

@ -1,7 +1,7 @@
package com.tangem.tap.features.onboarding
import com.tangem.common.services.Result
import com.tangem.domain.common.ScanResponse
import com.tangem.domain.models.scan.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

View file

@ -3,6 +3,7 @@ package com.tangem.tap.features.onboarding.products.otherCards.redux
import com.tangem.blockchain.common.Blockchain
import com.tangem.common.CompletionResult
import com.tangem.core.analytics.Analytics
import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.domain.common.extensions.withMainContext
import com.tangem.tap.DELAY_SDK_DIALOG_CLOSE
import com.tangem.tap.common.analytics.events.Onboarding

View file

@ -4,7 +4,7 @@ import com.tangem.Message
import com.tangem.blockchain.common.WalletManager
import com.tangem.common.extensions.VoidCallback
import com.tangem.datasource.asset.AssetReader
import com.tangem.domain.common.ScanResponse
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.tap.domain.TapError
import com.tangem.tap.domain.twins.TwinCardsManager
import com.tangem.tap.features.onboarding.OnboardingWalletBalance

View file

@ -4,8 +4,9 @@ import com.tangem.blockchain.extensions.Result
import com.tangem.common.CompletionResult
import com.tangem.common.extensions.guard
import com.tangem.core.analytics.Analytics
import com.tangem.domain.common.ScanResponse
import com.tangem.domain.common.extensions.withMainContext
import com.tangem.domain.common.util.twinsIsTwinned
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.tap.DELAY_SDK_DIALOG_CLOSE
import com.tangem.tap.common.analytics.events.AnalyticsParam
import com.tangem.tap.common.analytics.events.Onboarding
@ -16,6 +17,7 @@ import com.tangem.tap.common.extensions.dispatchOnMain
import com.tangem.tap.common.extensions.dispatchOpenUrl
import com.tangem.tap.common.extensions.getAddressData
import com.tangem.tap.common.extensions.getTopUpUrl
import com.tangem.tap.common.extensions.onUserWalletSelected
import com.tangem.tap.common.postUi
import com.tangem.tap.common.redux.AppDialog
import com.tangem.tap.common.redux.AppState
@ -135,6 +137,12 @@ private fun handle(action: Action, dispatch: DispatchFunction) {
userWalletsListManager.delete(
listOfNotNull(UserWalletIdBuilder.scanResponse(getScanResponse()).build()),
)
userWalletsListManager.selectedUserWalletSync?.let { selectedWallet ->
store.onUserWalletSelected(
userWallet = selectedWallet,
sendAnalyticsEvent = false,
)
}
}
}
TwinCardsStep.TopUpWallet -> {

View file

@ -1,5 +1,6 @@
package com.tangem.tap.features.onboarding.products.twins.redux
import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.domain.common.getTwinCardNumber
import com.tangem.tap.common.redux.AppState
import org.rekotlin.Action

View file

@ -1,7 +1,7 @@
package com.tangem.tap.features.onboarding.products.twins.redux
import com.tangem.blockchain.common.WalletManager
import com.tangem.domain.common.ScanResponse
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.common.TwinCardNumber
import com.tangem.tap.domain.TapError
import com.tangem.tap.domain.twins.TwinCardsManager

View file

@ -16,7 +16,7 @@ import com.tangem.common.extensions.VoidCallback
import com.tangem.core.analytics.Analytics
import com.tangem.core.ui.fragments.setStatusBarColor
import com.tangem.datasource.asset.AssetReader
import com.tangem.domain.common.ScanResponse
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.common.TwinCardNumber
import com.tangem.sdk.ui.widget.leapfrogWidget.LeapfrogWidget
import com.tangem.tap.common.analytics.events.Onboarding

View file

@ -5,10 +5,11 @@ import com.tangem.blockchain.common.Blockchain
import com.tangem.common.CompletionResult
import com.tangem.common.extensions.ifNotNull
import com.tangem.core.analytics.Analytics
import com.tangem.domain.common.CardDTO
import com.tangem.domain.common.ScanResponse
import com.tangem.domain.common.TapWorkarounds.isSaltPay
import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.domain.common.extensions.withMainContext
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.operations.backup.BackupService
import com.tangem.tap.backupService
import com.tangem.tap.common.analytics.events.Onboarding

View file

@ -1,5 +1,6 @@
package com.tangem.tap.features.onboarding.products.wallet.redux
import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.tap.backupService
import com.tangem.tap.common.redux.AppState
import com.tangem.tap.common.redux.global.GlobalAction

View file

@ -31,7 +31,7 @@ import com.tangem.datasource.api.paymentology.models.response.RegistrationRespon
import com.tangem.datasource.api.paymentology.models.response.tryExtractError
import com.tangem.datasource.config.models.KYCProvider
import com.tangem.datasource.config.models.SaltPayConfig
import com.tangem.domain.common.CardDTO
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.common.SaltPayWorkaround
import com.tangem.domain.common.extensions.successOr
import com.tangem.operations.attestation.AttestWalletKeyResponse

View file

@ -1,7 +1,7 @@
package com.tangem.tap.features.saveWallet.redux
import com.tangem.common.core.TangemError
import com.tangem.domain.common.ScanResponse
import com.tangem.domain.models.scan.ScanResponse
import org.rekotlin.Action
internal sealed interface SaveWalletAction : Action {

View file

@ -1,7 +1,7 @@
package com.tangem.tap.features.saveWallet.redux
import com.tangem.common.core.TangemError
import com.tangem.domain.common.ScanResponse
import com.tangem.domain.models.scan.ScanResponse
import org.rekotlin.StateType
data class SaveWalletState(

View file

@ -16,7 +16,7 @@ import com.tangem.common.core.TangemSdkError
import com.tangem.common.extensions.guard
import com.tangem.common.services.Result
import com.tangem.core.analytics.Analytics
import com.tangem.domain.common.CardDTO
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.common.TapWorkarounds.isStart2Coin
import com.tangem.domain.common.extensions.withMainContext
import com.tangem.tap.DELAY_SDK_DIALOG_CLOSE

View file

@ -320,7 +320,11 @@ class SendStateSubscriber(fragment: BaseStoreFragment) : FragmentStateSubscriber
totalTokenLayout.show(false)
tvReceiptAmountValue.update("${receipt.amountFiat} ${receipt.symbols.fiat}")
tvReceiptFeeValue.update("${receipt.feeFiat} ${receipt.symbols.fiat}")
llTotalContainer.tvTotalValue.update("${roughOrEmpty(receipt.totalFiat)} ${receipt.symbols.fiat}")
llTotalContainer.tvTotalValue.post {
llTotalContainer.tvTotalValue.update(
"${roughOrEmpty(receipt.totalFiat)} ${receipt.symbols.fiat}",
)
}
val willSent = getString(
R.string.send_total_subtitle_format,
@ -335,7 +339,9 @@ class SendStateSubscriber(fragment: BaseStoreFragment) : FragmentStateSubscriber
totalTokenLayout.show(false)
tvReceiptAmountValue.update("${receipt.amountCrypto} ${receipt.symbols.crypto}")
tvReceiptFeeValue.update("${receipt.feeCrypto} ${receipt.symbols.crypto}")
llTotalContainer.tvTotalValue.update("${receipt.totalCrypto} ${receipt.symbols.crypto}")
llTotalContainer.tvTotalValue.post {
llTotalContainer.tvTotalValue.update("${receipt.totalCrypto} ${receipt.symbols.crypto}")
}
if (receipt.willSentFiat == UNKNOWN_AMOUNT_SIGN) {
llTotalContainer.tvWillBeSentValue.hide()
@ -357,7 +363,11 @@ class SendStateSubscriber(fragment: BaseStoreFragment) : FragmentStateSubscriber
totalTokenLayout.show(false)
tvReceiptAmountValue.update("${receipt.amountFiat} ${receipt.symbols.fiat}")
tvReceiptFeeValue.update("${receipt.feeFiat} ${receipt.symbols.fiat}")
llTotalContainer.tvTotalValue.update("${roughOrEmpty(receipt.totalFiat)} ${receipt.symbols.fiat}")
llTotalContainer.tvTotalValue.post {
llTotalContainer.tvTotalValue.update(
"${roughOrEmpty(receipt.totalFiat)} ${receipt.symbols.fiat}",
)
}
val willSent = getString(
R.string.send_total_subtitle_asset_format,

View file

@ -9,8 +9,9 @@ import com.tangem.common.extensions.guard
import com.tangem.common.extensions.toMapKey
import com.tangem.common.flatMap
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.domain.common.ScanResponse
import com.tangem.domain.common.TapWorkarounds.derivationStyle
import com.tangem.domain.common.util.supportsHdWallet
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.operations.derivation.ExtendedPublicKeysMap
import com.tangem.tap.DELAY_SDK_DIALOG_CLOSE
import com.tangem.tap.common.extensions.dispatchDebugErrorNotification

View file

@ -19,9 +19,15 @@ import androidx.compose.material.FabPosition
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
@ -29,9 +35,10 @@ import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.ExperimentalUnitApi
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.TextUnitType
import androidx.compose.ui.unit.dp
import androidx.paging.PagingData
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
@ -44,27 +51,46 @@ import com.tangem.tap.features.tokens.impl.presentation.states.TokensListStateHo
import com.tangem.tap.features.tokens.impl.presentation.states.TokensListToolbarState
import com.tangem.wallet.R
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
/**
* Tokens list screen
*
* @param stateHolder state holder
*
[REDACTED_AUTHOR]
*/
@Composable
internal fun TokensListScreen(stateHolder: TokensListStateHolder) {
BackHandler(onBack = stateHolder.toolbarState.onBackButtonClick)
var floatingButtonHeight by remember { mutableStateOf(value = 0.dp) }
Scaffold(
topBar = { TokensListToolbar(state = stateHolder.toolbarState) },
floatingActionButton = {
if (stateHolder is TokensListStateHolder.ManageContent) {
SaveChangesButton(onClick = stateHolder.onSaveButtonClick)
val density = LocalDensity.current
val verticalPadding = TangemTheme.dimens.spacing32
SaveChangesButton(
onClick = stateHolder.onSaveButtonClick,
modifier = Modifier.onSizeChanged {
with(density) { floatingButtonHeight = it.height.toDp() + verticalPadding }
},
)
}
},
floatingActionButtonPosition = FabPosition.Center,
) { scaffoldPadding ->
val tokens = stateHolder.tokens.collectAsLazyPagingItems()
TokensListContent(stateHolder.isDifferentAddressesBlockVisible, tokens, scaffoldPadding)
TokensListContent(
isDifferentAddressesBlockVisible = stateHolder.isDifferentAddressesBlockVisible,
tokens = tokens,
scaffoldPadding = scaffoldPadding,
bottomMarginDp = floatingButtonHeight,
)
stateHolder.onTokensLoadStateChanged(tokens.loadState.refresh)
@ -96,6 +122,7 @@ private fun TokensListContent(
isDifferentAddressesBlockVisible: Boolean,
tokens: LazyPagingItems<TokenItemState>,
scaffoldPadding: PaddingValues,
bottomMarginDp: Dp,
) {
val state = rememberLazyListState()
@ -108,6 +135,7 @@ private fun TokensListContent(
.fillMaxSize()
.padding(scaffoldPadding),
state = state,
contentPadding = PaddingValues(bottom = bottomMarginDp),
) {
if (isDifferentAddressesBlockVisible) {
item { DifferentAddressesWarning() }
@ -119,7 +147,6 @@ private fun TokensListContent(
}
}
@OptIn(ExperimentalUnitApi::class)
@Composable
private fun DifferentAddressesWarning() {
Box(
@ -159,9 +186,9 @@ private fun DifferentAddressesWarning() {
}
@Composable
private fun SaveChangesButton(onClick: () -> Unit) {
private fun SaveChangesButton(onClick: () -> Unit, modifier: Modifier = Modifier) {
PrimaryButton(
modifier = Modifier
modifier = modifier
.imePadding()
.padding(horizontal = TangemTheme.dimens.spacing16)
.fillMaxWidth(),
@ -205,16 +232,11 @@ private fun Preview_TokensListScreen_Manage() {
),
isLoading = false,
isDifferentAddressesBlockVisible = true,
tokens = flow {
emit(
PagingData.from(
listOf(
TokenListPreviewData.createManageToken(),
TokenListPreviewData.createManageToken(),
),
),
)
},
tokens = flowOf(
PagingData.from(
listOf(TokenListPreviewData.createManageToken()),
),
),
onSaveButtonClick = {},
onTokensLoadStateChanged = {},
),
@ -235,16 +257,11 @@ private fun Preview_TokensListScreen_Read() {
),
isLoading = false,
isDifferentAddressesBlockVisible = false,
tokens = flow {
emit(
PagingData.from(
listOf(
TokenListPreviewData.createManageToken(),
TokenListPreviewData.createManageToken(),
),
),
)
},
tokens = flowOf(
PagingData.from(
listOf(TokenListPreviewData.createManageToken()),
),
),
onTokensLoadStateChanged = {},
),
)

View file

@ -2,7 +2,7 @@ package com.tangem.tap.features.tokens.legacy.redux
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.DerivationStyle
import com.tangem.domain.common.ScanResponse
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.tap.domain.model.WalletDataModel
import com.tangem.tap.domain.tokens.Currency
import org.rekotlin.Action

View file

@ -12,12 +12,14 @@ import com.tangem.common.services.Result
import com.tangem.core.analytics.Analytics
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.domain.DomainWrapped
import com.tangem.domain.common.ScanResponse
import com.tangem.domain.common.TapWorkarounds.derivationStyle
import com.tangem.domain.common.TapWorkarounds.isTestCard
import com.tangem.domain.common.extensions.supportedBlockchains
import com.tangem.domain.common.util.hasDerivation
import com.tangem.domain.common.util.supportsHdWallet
import com.tangem.domain.features.addCustomToken.CustomCurrency
import com.tangem.domain.features.addCustomToken.redux.AddCustomTokenAction
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.redux.domainStore
import com.tangem.operations.derivation.ExtendedPublicKeysMap
import com.tangem.tap.DELAY_SDK_DIALOG_CLOSE

View file

@ -3,7 +3,7 @@ package com.tangem.tap.features.tokens.legacy.redux
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.DerivationStyle
import com.tangem.blockchain.common.Token
import com.tangem.domain.common.ScanResponse
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.common.extensions.canHandleToken
import com.tangem.domain.common.extensions.fromNetworkId
import com.tangem.tap.domain.model.WalletDataModel

View file

@ -105,9 +105,8 @@ sealed class WalletAction : Action {
sealed class TradeCryptoAction : WalletAction() {
object Sell : TradeCryptoAction()
data class Buy(
val checkUserLocation: Boolean = true,
) : TradeCryptoAction()
data class Buy(val checkUserLocation: Boolean = true) : TradeCryptoAction()
data class FinishSelling(val transactionId: String) : TradeCryptoAction()
data class SendCrypto(
@ -134,5 +133,5 @@ sealed class WalletAction : Action {
data class UpdateUserWalletArtwork(val walletId: UserWalletId) : WalletAction()
data class SetArtworkUrl(val url: String) : WalletAction()
data class SetArtworkUrl(val userWalletId: UserWalletId, val url: String) : WalletAction()
}

View file

@ -3,6 +3,7 @@ package com.tangem.tap.features.wallet.redux
import android.graphics.Bitmap
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.WalletManager
import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.tap.common.entities.Button
import com.tangem.tap.common.redux.global.CryptoCurrencyName
import com.tangem.tap.common.toggleWidget.WidgetState

View file

@ -224,7 +224,11 @@ class WalletMiddleware {
)
},
)
.doOnSuccess { store.dispatch(WalletAction.SetArtworkUrl(it.artworkUrl)) }
.doOnSuccess {
store.dispatch(
WalletAction.SetArtworkUrl(userWalletId = action.walletId, url = it.artworkUrl),
)
}
}
}
}

View file

@ -4,9 +4,10 @@ import com.tangem.blockchain.common.BlockchainSdkError
import com.tangem.blockchain.common.SignatureCountValidator
import com.tangem.blockchain.extensions.SimpleResult
import com.tangem.common.card.FirmwareVersion
import com.tangem.domain.common.CardDTO
import com.tangem.domain.common.ScanResponse
import com.tangem.domain.common.TapWorkarounds.isTestCard
import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.tap.common.extensions.dispatchOnMain
import com.tangem.tap.common.redux.global.GlobalState
import com.tangem.tap.domain.configurable.warningMessage.WarningMessage

View file

@ -1,8 +1,8 @@
package com.tangem.tap.features.wallet.redux.reducers
import com.tangem.blockchain.common.Wallet
import com.tangem.domain.common.CardDTO
import com.tangem.domain.common.TapWorkarounds.isTestCard
import com.tangem.domain.models.scan.CardDTO
import com.tangem.tap.common.redux.AppState
import com.tangem.tap.domain.TapError
import com.tangem.tap.domain.model.WalletDataModel
@ -14,6 +14,7 @@ import com.tangem.tap.features.wallet.redux.ProgressState
import com.tangem.tap.features.wallet.redux.WalletAction
import com.tangem.tap.features.wallet.redux.WalletState
import com.tangem.tap.proxy.AppStateHolder
import com.tangem.tap.userWalletsListManager
import org.rekotlin.Action
object WalletReducer {
@ -115,9 +116,13 @@ private fun internalReduce(action: Action, state: AppState, appStateHolder: AppS
newState = newState.copy(canSaveUserWallets = action.canSaveUserWallets)
}
is WalletAction.SetArtworkUrl -> {
newState = newState.copy(
cardImage = Artwork(artworkId = action.url, artwork = newState.cardImage?.artwork),
)
val selectedUserWallet = userWalletsListManager.selectedUserWalletSync?.walletId
if (selectedUserWallet == action.userWalletId) {
newState = newState.copy(
cardImage = Artwork(artworkId = action.url, artwork = newState.cardImage?.artwork),
)
}
}
else -> Unit
}

View file

@ -7,8 +7,12 @@ import android.view.MenuItem
import android.view.View
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.mutableStateOf
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.transition.TransitionInflater
@ -19,6 +23,8 @@ import com.badoo.mvicore.modelWatcher
import com.tangem.core.analytics.Analytics
import com.tangem.core.ui.fragments.setStatusBarColor
import com.tangem.core.ui.utils.OneTouchClickListener
import com.tangem.datasource.connection.NetworkConnectionManager
import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.feature.swap.api.SwapFeatureToggleManager
import com.tangem.feature.swap.domain.SwapInteractor
import com.tangem.tap.MainActivity
@ -47,6 +53,8 @@ import com.tangem.wallet.BuildConfig
import com.tangem.wallet.R
import com.tangem.wallet.databinding.FragmentWalletBinding
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import javax.inject.Inject
@AndroidEntryPoint
@ -58,6 +66,9 @@ class WalletFragment : Fragment(R.layout.fragment_wallet), SafeStoreSubscriber<W
@Inject
lateinit var swapFeatureToggleManager: SwapFeatureToggleManager
@Inject
lateinit var networkConnectionManager: NetworkConnectionManager
private lateinit var warningsAdapter: WarningMessagesAdapter
private val binding: FragmentWalletBinding by viewBinding(FragmentWalletBinding::bind)
@ -75,6 +86,8 @@ class WalletFragment : Fragment(R.layout.fragment_wallet), SafeStoreSubscriber<W
}
}
private val isNetworkConnectionError = mutableStateOf(false)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
@ -98,6 +111,8 @@ class WalletFragment : Fragment(R.layout.fragment_wallet), SafeStoreSubscriber<W
setStatusBarColor(R.color.background_secondary)
subscribeOnNetworkStateChanging()
store.subscribe(this) { state ->
state.select { it.walletState }
}
@ -194,8 +209,7 @@ class WalletFragment : Fragment(R.layout.fragment_wallet), SafeStoreSubscriber<W
if (state.state != ProgressState.Loading &&
state.state != ProgressState.Refreshing
) {
Analytics.send(Portfolio.Refreshed())
store.dispatch(WalletAction.LoadData.Refresh)
refreshWalletData()
}
}
@ -207,6 +221,11 @@ class WalletFragment : Fragment(R.layout.fragment_wallet), SafeStoreSubscriber<W
binding.toolbar.setNavigationIcon(navigationIconRes)
}
private fun refreshWalletData() {
Analytics.send(Portfolio.Refreshed())
store.dispatch(WalletAction.LoadData.Refresh)
}
private fun showWarningsIfPresent(warnings: List<WarningMessage>) {
warningsAdapter.submitList(warnings)
binding.rvWarningMessages.show(warnings.isNotEmpty())
@ -215,13 +234,17 @@ class WalletFragment : Fragment(R.layout.fragment_wallet), SafeStoreSubscriber<W
private fun setupNoInternetHandling(state: WalletState) {
if (state.state == ProgressState.Error) {
if (state.error == ErrorType.NoInternetConnection) {
isNetworkConnectionError.value = true
binding.srlWallet.isRefreshing = false
(activity as? MainActivity)?.showSnackbar(
text = R.string.wallet_notification_no_internet,
buttonTitle = R.string.common_retry,
) { store.dispatch(WalletAction.LoadData) }
} else {
isNetworkConnectionError.value = false
}
} else {
isNetworkConnectionError.value = false
(activity as? MainActivity)?.dismissSnackbar()
}
}
@ -247,6 +270,19 @@ class WalletFragment : Fragment(R.layout.fragment_wallet), SafeStoreSubscriber<W
}
}
private fun subscribeOnNetworkStateChanging() {
viewLifecycleOwner.lifecycleScope.launch {
networkConnectionManager.isOnlineFlow
.flowWithLifecycle(viewLifecycleOwner.lifecycle, Lifecycle.State.STARTED)
.distinctUntilChanged()
.collect { isOnline ->
if (isOnline && isNetworkConnectionError.value) {
refreshWalletData()
}
}
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.details_menu -> {

View file

@ -5,6 +5,7 @@ import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.tap.common.analytics.converters.ParamCardCurrencyConverter
import com.tangem.tap.common.analytics.events.Basic
import com.tangem.tap.common.analytics.events.MainScreen
@ -80,14 +81,6 @@ internal class WalletViewModel @Inject constructor(
analyticsEventHandler.send(MainScreen.ScreenOpened())
}
private fun launch() {
val manager = store.state.globalState.userWalletsListManager
if (manager != null) {
bootstrapSelectedWalletStoresChanges(manager)
}
bootstrapShowSaveWalletIfNeeded()
}
fun onBalanceLoaded(totalBalance: TotalFiatBalance?) {
if (totalBalance != null) {
walletAnalyticsEventsMapper.convert(totalBalance)?.let { balanceParam ->
@ -100,6 +93,14 @@ internal class WalletViewModel @Inject constructor(
}
}
private fun launch() {
val manager = store.state.globalState.userWalletsListManager
if (manager != null) {
bootstrapSelectedWalletStoresChanges(manager)
}
bootstrapShowSaveWalletIfNeeded()
}
@OptIn(FlowPreview::class)
private fun bootstrapSelectedWalletStoresChanges(manager: UserWalletsListManager) {
observeWalletStoresUpdatesJob = manager.selectedUserWallet

View file

@ -7,7 +7,7 @@ import com.tangem.common.doOnSuccess
import com.tangem.common.flatMap
import com.tangem.common.map
import com.tangem.core.analytics.Analytics
import com.tangem.domain.common.ScanResponse
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.common.util.UserWalletId
import com.tangem.tap.common.analytics.events.AnalyticsParam
import com.tangem.tap.common.analytics.events.Basic

View file

@ -1,6 +1,7 @@
package com.tangem.tap.features.walletSelector.redux
import com.tangem.domain.common.CardDTO
import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.domain.models.scan.CardDTO
import com.tangem.tap.common.redux.AppState
import com.tangem.tap.domain.model.TotalFiatBalance
import com.tangem.tap.domain.model.UserWallet

View file

@ -196,7 +196,7 @@ internal class WalletSelectorViewModel : ViewModel(), StoreSubscriber<WalletSele
isPermanent = error.isPermanent,
onDismiss = this::dismissWarningDialog,
)
is UserWalletsListError.InvalidEncryptionKey -> WarningModel.KeyInvalidatedWarning(
is UserWalletsListError.EncryptionKeyInvalidated -> WarningModel.KeyInvalidatedWarning(
onDismiss = this::dismissWarningDialog,
)
else -> currentDialog

View file

@ -4,7 +4,7 @@ import android.content.Intent
import com.tangem.common.core.TangemSdkError
import com.tangem.common.doOnFailure
import com.tangem.common.doOnSuccess
import com.tangem.domain.common.ScanResponse
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.tap.common.analytics.events.AnalyticsParam
import com.tangem.tap.common.analytics.events.Basic
import com.tangem.tap.common.extensions.dispatchOnMain

View file

@ -68,7 +68,7 @@ internal class WelcomeViewModel : ViewModel(), StoreSubscriber<WelcomeState> {
isPermanent = error.isPermanent,
onDismiss = this::dismissWarning,
)
is UserWalletsListError.InvalidEncryptionKey -> WarningModel.KeyInvalidatedWarning(
is UserWalletsListError.EncryptionKeyInvalidated -> WarningModel.KeyInvalidatedWarning(
onDismiss = this::dismissWarning,
)
else -> null