Updated on 2026-08-14

This commit is contained in:
Tangem 2024-10-10 15:56:02 +04:00
commit 369ed25ac6
515 changed files with 6100 additions and 12756 deletions

View file

@ -1,11 +0,0 @@
package com.tangem.tap.features.customtoken.api.featuretoggles
/**
* Add custom token feature toggles
*
[REDACTED_AUTHOR]
*/
interface CustomTokenFeatureToggles {
val isNewCardScanningEnabled: Boolean
}

View file

@ -1,61 +0,0 @@
package com.tangem.tap.features.customtoken.impl.data
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.domain.common.extensions.supportedBlockchains
import com.tangem.domain.common.util.cardTypesResolver
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 scanResponse = requireNotNull(reduxStateHolder.scanResponse)
val supportedTokenNetworkIds = requireNotNull(scanResponse.card)
.supportedBlockchains(scanResponse.cardTypesResolver)
.filter(Blockchain::canHandleTokens)
.map(Blockchain::toNetworkId)
return withContext(dispatchers.io) {
val foundCoin = tangemTechApi.getCoins(
contractAddress = address,
networkIds = networkId ?: supportedTokenNetworkIds.joinToString(separator = ","),
)
.getOrThrow()
.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

@ -1,29 +0,0 @@
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,
isActive = value.active,
network = value.networks.firstOrNull()?.let { network ->
FoundToken.Network(
id = network.networkId,
contractAddress = requireNotNull(network.contractAddress),
decimalCount = requireNotNull(network.decimalCount).toString(),
)
} ?: error("Found token networks is empty"),
)
}
}

View file

@ -1,24 +0,0 @@
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

@ -1,43 +0,0 @@
package com.tangem.tap.features.customtoken.impl.di
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.domain.card.DerivePublicKeysUseCase
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
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,
getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase,
derivePublicKeysUseCase: DerivePublicKeysUseCase,
): CustomTokenInteractor {
return DefaultCustomTokenInteractor(
featureRepository = DefaultCustomTokenRepository(
tangemTechApi = tangemTechApi,
dispatchers = appCoroutineDispatcherProvider,
reduxStateHolder = reduxStateHolder,
),
getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase,
derivePublicKeysUseCase = derivePublicKeysUseCase,
)
}
}

View file

@ -1,21 +0,0 @@
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

@ -1,21 +0,0 @@
package com.tangem.tap.features.customtoken.impl.domain
import com.tangem.blockchain.common.Blockchain
import com.tangem.common.core.TangemError
import com.tangem.domain.features.addCustomToken.CustomCurrency
import com.tangem.tap.features.customtoken.impl.domain.models.FoundToken
/**
* Custom token interactor
*
[REDACTED_AUTHOR]
*/
interface CustomTokenInteractor {
/** Find token by [address] and [blockchain] */
suspend fun findToken(address: String, blockchain: Blockchain): FoundToken
/** Save token [customCurrency] */
@Throws(TangemError::class)
suspend fun saveToken(customCurrency: CustomCurrency): Result<Unit>
}

View file

@ -1,18 +0,0 @@
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]
*
* @throws com.tangem.datasource.api.common.response.ApiResponseError
* */
suspend fun findToken(address: String, networkId: String?): FoundToken
}

View file

@ -1,84 +0,0 @@
package com.tangem.tap.features.customtoken.impl.domain
import arrow.core.getOrElse
import arrow.core.raise.result
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.data.common.currency.CryptoCurrencyFactory
import com.tangem.domain.card.DerivePublicKeysUseCase
import com.tangem.domain.common.util.derivationStyleProvider
import com.tangem.domain.features.addCustomToken.CustomCurrency
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.tokens.AddCryptoCurrenciesUseCase
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.tap.common.extensions.inject
import com.tangem.tap.domain.model.Currency
import com.tangem.tap.features.customtoken.impl.domain.models.FoundToken
import com.tangem.tap.proxy.redux.DaggerGraphState
import com.tangem.tap.store
/**
* Default implementation of custom token interactor
*
* @property featureRepository feature repository
*
[REDACTED_AUTHOR]
*/
class DefaultCustomTokenInteractor(
private val featureRepository: CustomTokenRepository,
private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase,
private val derivePublicKeysUseCase: DerivePublicKeysUseCase,
) : CustomTokenInteractor {
// TODO: Move to DI
private val addCryptoCurrenciesUseCase by lazy(LazyThreadSafetyMode.NONE) {
val currenciesRepository = store.inject(DaggerGraphState::currenciesRepository)
val networksRepository = store.inject(DaggerGraphState::networksRepository)
AddCryptoCurrenciesUseCase(currenciesRepository, networksRepository)
}
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(customCurrency: CustomCurrency): Result<Unit> {
return result {
val userWallet = getSelectedWalletSyncUseCase().getOrElse {
error("Failed to get selected wallet: $it")
}
val currency = Currency.fromCustomCurrency(customCurrency)
val currencies = listOfNotNull(element = currency.toCryptoCurrency(userWallet.scanResponse))
derivePublicKeysUseCase(userWalletId = userWallet.walletId, currencies = currencies).bind()
addCryptoCurrenciesUseCase(userWalletId = userWallet.walletId, currencies = currencies).bind()
}
}
private fun Currency.toCryptoCurrency(scanResponse: ScanResponse): CryptoCurrency? {
val cryptoCurrencyFactory = CryptoCurrencyFactory()
return when (this) {
is Currency.Blockchain -> {
cryptoCurrencyFactory.createCoin(
blockchain = blockchain,
extraDerivationPath = derivationPath,
derivationStyleProvider = scanResponse.derivationStyleProvider,
)
}
is Currency.Token -> {
cryptoCurrencyFactory.createToken(
sdkToken = token,
blockchain = blockchain,
extraDerivationPath = derivationPath,
derivationStyleProvider = scanResponse.derivationStyleProvider,
)
}
}
}
}

View file

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

View file

@ -1,19 +0,0 @@
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 isNewCardScanningEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(name = "NEW_CARD_SCANNING_ENABLED")
}

View file

@ -1,44 +0,0 @@
package com.tangem.tap.features.customtoken.impl.presentation
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.ui.UiDependencies
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.screen.ComposeFragment
import com.tangem.tap.features.customtoken.impl.presentation.ui.AddCustomTokenScreen
import com.tangem.tap.features.customtoken.impl.presentation.viewmodels.AddCustomTokenViewModel
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
/**
* Add custom token screen
*
[REDACTED_AUTHOR]
*/
@AndroidEntryPoint
internal class AddCustomTokenFragment : ComposeFragment() {
@Inject
override lateinit var uiDependencies: UiDependencies
@Composable
override fun ScreenContent(modifier: Modifier) {
val viewModel = hiltViewModel<AddCustomTokenViewModel>().apply {
LocalLifecycleOwner.current.lifecycle.addObserver(this)
}
val state by viewModel.uiState.collectAsStateWithLifecycle()
AddCustomTokenScreen(
modifier = Modifier
.background(TangemTheme.colors.background.primary)
.systemBarsPadding(),
stateHolder = state,
)
}
}

View file

@ -1,324 +0,0 @@
package com.tangem.tap.features.customtoken.impl.presentation.models
import androidx.compose.foundation.text.KeyboardOptions
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
* @property derivationPathInputField input field for a custom derivation path
* @property showTokenFields if token fields should be shown
*/
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?,
val derivationPathInputField: AddCustomTokenInputField.DerivationPath?,
val showTokenFields: Boolean = false,
)
/** 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
/** Placeholder (hint) */
val placeholder: TextReference
/**
* Input field model to enter the contract address
*
* @property value current value
* @property onValueChange lambda be invoked when value is been changed
* @property keyboardOptions keyboard options
* @property label label
* @property placeholder placeholder (hint)
* @property isLoading flag that determine the processing of current value
* @property isError flag that determine if current value has error
* @property error error description
*/
data class ContactAddress(
override val value: String,
override val onValueChange: (String) -> Unit,
override val keyboardOptions: KeyboardOptions,
override val label: TextReference,
override val placeholder: TextReference,
val isLoading: Boolean,
val isError: Boolean,
val error: TextReference? = null,
) : AddCustomTokenInputField
/**
* Input field model to enter the token name
*
* @property value current value
* @property onValueChange lambda be invoked when value is been changed
* @property keyboardOptions keyboard options
* @property label label
* @property placeholder placeholder (hint)
* @property isEnabled input availability
*/
data class TokenName(
override val value: String,
override val onValueChange: (String) -> Unit,
override val keyboardOptions: KeyboardOptions,
override val label: TextReference,
override val placeholder: TextReference,
val isEnabled: Boolean,
) : AddCustomTokenInputField
/**
* Input field model to enter the token symbol
*
* @property value current value
* @property onValueChange lambda be invoked when value is been changed
* @property keyboardOptions keyboard options
* @property label label
* @property placeholder placeholder (hint)
* @property isEnabled input availability
*/
data class TokenSymbol(
override val value: String,
override val onValueChange: (String) -> Unit,
override val keyboardOptions: KeyboardOptions,
override val label: TextReference,
override val placeholder: TextReference,
val isEnabled: Boolean,
) : AddCustomTokenInputField
/**
* Input field model to enter the token decimals
*
* @property value current value
* @property onValueChange lambda be invoked when value is been changed
* @property keyboardOptions keyboard options
* @property label label
* @property placeholder placeholder (hint)
* @property isEnabled input availability
*/
data class Decimals(
override val value: String,
override val onValueChange: (String) -> Unit,
override val keyboardOptions: KeyboardOptions,
override val label: TextReference,
override val placeholder: TextReference,
val isEnabled: Boolean,
) : AddCustomTokenInputField
/**
* Input field model to enter a custom derivation path
*
* @property value current value
* @property onValueChange lambda be invoked when value is been changed
* @property keyboardOptions keyboard options
* @property label label
* @property placeholder placeholder (hint)
* @property showField whether the field should be shown
*/
data class DerivationPath(
override val value: String,
override val onValueChange: (String) -> Unit,
override val keyboardOptions: KeyboardOptions,
override val label: TextReference,
override val placeholder: TextReference,
val showField: Boolean = false,
) : AddCustomTokenInputField
}
/** Base selector field model of add custom token screen */
internal sealed interface AddCustomTokenSelectorField {
/** Label */
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 label label
* @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 label: TextReference,
override val selectedItem: SelectorItem.Title,
override val items: List<SelectorItem.Title>,
override val onMenuItemClick: (Int) -> Unit,
) : AddCustomTokenSelectorField
/**
* Derivation path selector model
*
* @property label label
* @property selectedItem selected menu item
* @property items menu items
* @property onMenuItemClick lambda be invoked when menu item is been selected
* @property isEnabled selection availability
*/
data class DerivationPath(
override val label: TextReference,
override val selectedItem: SelectorItem.TitleWithSubtitle,
override val items: List<SelectorItem.TitleWithSubtitle>,
override val onMenuItemClick: (Int) -> Unit,
val isEnabled: Boolean,
) : AddCustomTokenSelectorField
/** 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 blockchain blockchain
* @property subtitle subtitle text
*/
data class TitleWithSubtitle(
override val title: TextReference,
override val blockchain: Blockchain,
val subtitle: TextReference,
val type: DerivationPathSelectorType = DerivationPathSelectorType.BLOCKCHAIN,
) : SelectorItem
}
}
enum class DerivationPathSelectorType {
DEFAULT, CUSTOM, BLOCKCHAIN
}
/**
* Warning model of add custom token screen
*
* @property description warning description
*/
internal sealed class AddCustomTokenWarning(val description: TextReference) {
/** Potential scam warning */
data object PotentialScamToken : AddCustomTokenWarning(
description = TextReference.Res(R.string.custom_token_validation_error_not_found),
)
/** Token already added warning */
data object TokenAlreadyAdded : AddCustomTokenWarning(
description = TextReference.Res(R.string.custom_token_validation_error_already_added),
)
/** Unsupported token warning */
data class UnsupportedToken(val networkName: String) : AddCustomTokenWarning(
description = TextReference.Res(R.string.alert_manage_tokens_unsupported_message, networkName),
)
data object WrongDerivationPath : AddCustomTokenWarning(
description = TextReference.Res(R.string.custom_token_invalid_derivation_path),
)
}
/**
* Floating button of add custom token screen
*
* @property isEnabled button availability
* @property showProgress whether circle progress indication is enabled
* @property onClick lambda be invoked when button is been pressed
*/
internal data class AddCustomTokenFloatingButton(
val isEnabled: Boolean,
val showProgress: Boolean,
val onClick: () -> Unit,
)

View file

@ -1,4 +0,0 @@
package com.tangem.tap.features.customtoken.impl.presentation.models
/** Custom token type */
enum class CustomTokenType { TOKEN, BLOCKCHAIN }

View file

@ -1,6 +0,0 @@
package com.tangem.tap.features.customtoken.impl.presentation.models
internal enum class SupportBlockchainType {
SUPPORTED, UNSUPPORTED, UNABLE_TO_DETERMINE
}

View file

@ -1,25 +0,0 @@
package com.tangem.tap.features.customtoken.impl.presentation.routers
import com.tangem.blockchain.common.Blockchain
/**
* Custom token feature router
*
[REDACTED_AUTHOR]
*/
internal interface CustomTokenRouter {
/** Return to last screen */
fun popBackStack()
/** Open wallet (main) screen */
fun openWalletScreen()
/** Open alert if solana network is unsupported
*
* @param blockchain blockchain to show alert
*/
fun openUnsupportedNetworkAlert(blockchain: Blockchain)
fun showGenericErrorAlertAndPopBack()
}

View file

@ -1,41 +0,0 @@
package com.tangem.tap.features.customtoken.impl.presentation.routers
import com.tangem.blockchain.common.Blockchain
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.common.routing.utils.popTo
import com.tangem.tap.common.extensions.dispatchDialogShow
import com.tangem.tap.common.extensions.dispatchNavigationAction
import com.tangem.tap.common.redux.AppDialog
import com.tangem.tap.store
import com.tangem.wallet.R
/** Default implementation of custom token feature router */
internal class DefaultCustomTokenRouter : CustomTokenRouter {
override fun popBackStack() {
store.dispatchNavigationAction(AppRouter::pop)
}
override fun openWalletScreen() {
store.dispatchNavigationAction { popTo<AppRoute.Wallet>() }
}
override fun openUnsupportedNetworkAlert(blockchain: Blockchain) {
val alert = AppDialog.SimpleOkDialogRes(
headerId = R.string.common_warning,
messageId = R.string.alert_manage_tokens_unsupported_curve_message,
args = listOf(blockchain.getNetworkName()),
)
store.dispatchDialogShow(alert)
}
override fun showGenericErrorAlertAndPopBack() {
val alert = AppDialog.SimpleOkDialogRes(
headerId = R.string.common_error,
messageId = R.string.common_unknown_error,
onOk = { store.dispatchNavigationAction(AppRouter::pop) },
)
store.dispatchDialogShow(alert)
}
}

View file

@ -1,91 +0,0 @@
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.AddCustomTokenWarning
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokensToolbar
/**
* 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: Set<AddCustomTokenWarning>
/** 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: Set<AddCustomTokenWarning> = 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: Set<AddCustomTokenWarning>,
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: Set<AddCustomTokenWarning>,
override val floatingButton: AddCustomTokenFloatingButton,
val testBlock: AddCustomTokenTestBlock,
val bottomSheet: AddCustomTokenChooseTokenBottomSheet,
) : AddCustomTokenStateHolder
}

View file

@ -1,77 +0,0 @@
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.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
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.AddCustomTokenWarnings
/**
* Add custom token content
*
* @param state screen state
*
[REDACTED_AUTHOR]
*/
@Composable
internal fun AddCustomTokenContent(state: AddCustomTokenStateHolder.Content, modifier: Modifier = Modifier) {
BackHandler(onBack = state.onBackButtonClick)
var floatingButtonHeight by remember { mutableStateOf(0.dp) }
Scaffold(
modifier = modifier,
topBar = {
AddCustomTokenToolbar(
title = state.toolbar.title,
onBackButtonClick = state.toolbar.onBackButtonClick,
)
},
floatingActionButton = {
val density = LocalDensity.current
val verticalPadding = TangemTheme.dimens.spacing32
AddCustomTokenFloatingButton(
model = state.floatingButton,
modifier = Modifier.onSizeChanged {
floatingButtonHeight = with(density) { it.height.toDp() + verticalPadding }
},
)
},
floatingActionButtonPosition = FabPosition.Center,
) {
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(paddingValues = it)
.padding(bottom = floatingButtonHeight)
.fillMaxSize(),
) {
AddCustomTokenForm(model = state.form)
AddCustomTokenWarnings(warnings = state.warnings)
}
}
}
@Preview
@Composable
private fun Preview_AddCustomTokenContent() {
TangemThemePreview {
AddCustomTokenContent(state = AddCustomTokenPreviewData.createContent())
}
}

View file

@ -1,126 +0,0 @@
package com.tangem.tap.features.customtoken.impl.presentation.ui
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.customtoken.impl.presentation.models.*
import com.tangem.tap.features.customtoken.impl.presentation.states.AddCustomTokenStateHolder
import com.tangem.tap.features.details.ui.cardsettings.TextReference
import com.tangem.wallet.R
/**
[REDACTED_AUTHOR]
*/
internal object AddCustomTokenPreviewData {
fun createWarnings(): Set<AddCustomTokenWarning> {
return setOf(
AddCustomTokenWarning.PotentialScamToken,
AddCustomTokenWarning.TokenAlreadyAdded,
AddCustomTokenWarning.UnsupportedToken(networkName = "Solana"),
)
}
fun createDefaultForm(): AddCustomTokenForm {
return AddCustomTokenForm(
contractAddressInputField = AddCustomTokenInputField.ContactAddress(
value = "",
onValueChange = {},
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
label = TextReference.Res(R.string.custom_token_contract_address_input_title),
placeholder = TextReference.Str(value = "0x0000000000000000000000000000000000000000"),
isLoading = false,
isError = false,
error = null,
),
networkSelectorField = AddCustomTokenSelectorField.Network(
label = TextReference.Res(R.string.custom_token_network_input_title),
selectedItem = AddCustomTokenSelectorField.SelectorItem.Title(
title = TextReference.Res(R.string.custom_token_network_input_not_selected),
blockchain = Blockchain.Unknown,
),
items = emptyList(),
onMenuItemClick = {},
),
tokenNameInputField = AddCustomTokenInputField.TokenName(
value = "",
onValueChange = {},
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
label = TextReference.Res(R.string.custom_token_name_input_title),
placeholder = TextReference.Res(id = R.string.custom_token_name_input_placeholder),
isEnabled = false,
),
tokenSymbolInputField = AddCustomTokenInputField.TokenSymbol(
value = "",
onValueChange = {},
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
label = TextReference.Res(R.string.custom_token_token_symbol_input_title_old),
placeholder = TextReference.Res(id = R.string.custom_token_token_symbol_input_placeholder),
isEnabled = false,
),
decimalsInputField = AddCustomTokenInputField.Decimals(
value = "",
onValueChange = {},
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Next),
label = TextReference.Res(R.string.custom_token_decimals_input_title),
placeholder = TextReference.Str(value = "8"),
isEnabled = false,
),
derivationPathSelectorField = AddCustomTokenSelectorField.DerivationPath(
label = TextReference.Res(R.string.custom_token_derivation_path_input_title),
selectedItem = 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,
),
items = emptyList(),
onMenuItemClick = {},
isEnabled = true,
),
derivationPathInputField = null,
)
}
fun createTestContent(): AddCustomTokenStateHolder.TestContent {
return AddCustomTokenStateHolder.TestContent(
onBackButtonClick = {},
toolbar = AddCustomTokensToolbar(
title = TextReference.Res(R.string.add_custom_token_title),
onBackButtonClick = {},
),
form = createDefaultForm(),
warnings = createWarnings(),
floatingButton = AddCustomTokenFloatingButton(
isEnabled = false,
showProgress = false,
onClick = {},
),
testBlock = AddCustomTokenTestBlock(
chooseTokenButtonText = "Choose token",
clearButtonText = "Clear address",
resetButtonText = "Reset",
onClearAddressButtonClick = {},
onResetButtonClick = {},
),
bottomSheet = AddCustomTokenChooseTokenBottomSheet(categoriesBlocks = emptyList(), onTestTokenClick = {}),
)
}
fun createContent(): AddCustomTokenStateHolder.Content {
return AddCustomTokenStateHolder.Content(
onBackButtonClick = {},
toolbar = AddCustomTokensToolbar(
title = TextReference.Res(R.string.add_custom_token_title),
onBackButtonClick = {},
),
form = createDefaultForm(),
warnings = createWarnings(),
floatingButton = AddCustomTokenFloatingButton(
isEnabled = false,
showProgress = false,
onClick = {},
),
)
}
}

View file

@ -1,43 +0,0 @@
package com.tangem.tap.features.customtoken.impl.presentation.ui
import android.content.res.Configuration
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.tap.features.customtoken.impl.presentation.states.AddCustomTokenStateHolder
/**
* Add custom token screen
*
* @param stateHolder state holder
*
[REDACTED_AUTHOR]
*/
@Composable
internal fun AddCustomTokenScreen(stateHolder: AddCustomTokenStateHolder, modifier: Modifier = Modifier) {
when (stateHolder) {
is AddCustomTokenStateHolder.Content -> AddCustomTokenContent(stateHolder, modifier)
is AddCustomTokenStateHolder.TestContent -> AddCustomTokenTestContent(stateHolder, modifier)
}
}
@Preview(showSystemUi = true)
@Preview(showSystemUi = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_AddCustomTokenScreen(
@PreviewParameter(AddCustomTokenScreenProvider::class) stateHolder: AddCustomTokenStateHolder,
) {
TangemThemePreview {
AddCustomTokenScreen(stateHolder)
}
}
private class AddCustomTokenScreenProvider : CollectionPreviewParameterProvider<AddCustomTokenStateHolder>(
collection = listOf(
AddCustomTokenPreviewData.createContent(),
AddCustomTokenPreviewData.createTestContent(),
),
)

View file

@ -1,249 +0,0 @@
package com.tangem.tap.features.customtoken.impl.presentation.ui
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.runtime.*
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.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
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.core.ui.res.TangemThemePreview
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.AddCustomTokenTestBlock
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.AddCustomTokenWarnings
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, modifier: Modifier = Modifier) {
val coroutineScope = rememberCoroutineScope()
val bottomSheetScaffoldState = rememberBottomSheetScaffoldState(
bottomSheetState = BottomSheetState(initialValue = BottomSheetValue.Collapsed, LocalDensity.current),
)
BackHandler(
onBack = {
onBackButtonClicked(
coroutineScope = coroutineScope,
bottomSheetScaffoldState = bottomSheetScaffoldState,
defaultAction = state.onBackButtonClick,
)
},
)
var floatingButtonHeight by remember { mutableStateOf(0.dp) }
BottomSheetScaffold(
modifier = modifier,
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 = {
val density = LocalDensity.current
val verticalPadding = TangemTheme.dimens.spacing32
AddCustomTokenFloatingButton(
model = state.floatingButton,
modifier = Modifier.onSizeChanged {
floatingButtonHeight = with(density) { it.height.toDp() + verticalPadding }
},
)
},
floatingActionButtonPosition = FabPosition.Center,
sheetBackgroundColor = TangemTheme.colors.background.secondary,
sheetPeekHeight = TangemTheme.dimens.size0,
backgroundColor = TangemTheme.colors.background.primary,
) {
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(paddingValues = it)
.padding(bottom = floatingButtonHeight)
.fillMaxSize(),
) {
TestBlock(
state.testBlock,
coroutineScope,
bottomSheetScaffoldState,
)
AddCustomTokenForm(model = state.form)
AddCustomTokenWarnings(warnings = state.warnings)
}
}
}
@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
@Composable
private fun Preview_AddCustomTokenTestContent() {
TangemThemePreview {
AddCustomTokenTestContent(state = AddCustomTokenPreviewData.createTestContent())
}
}

View file

@ -1,56 +0,0 @@
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.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import com.tangem.core.ui.components.PrimaryButtonIconStart
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
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
* @param modifier modifier
*
[REDACTED_AUTHOR]
*/
@Composable
internal fun AddCustomTokenFloatingButton(model: AddCustomTokenFloatingButton, modifier: Modifier = Modifier) {
PrimaryButtonIconStart(
modifier = modifier
.imePadding()
.padding(horizontal = TangemTheme.dimens.spacing16)
.fillMaxWidth(),
text = stringResource(id = R.string.custom_token_add_token),
iconResId = R.drawable.ic_plus_24,
enabled = model.isEnabled,
showProgress = model.showProgress,
onClick = model.onClick,
)
}
@Preview
@Composable
private fun Preview_AddCustomTokenFloatingButton(
@PreviewParameter(AddCustomTokenFloatingButtonProvider::class) model: AddCustomTokenFloatingButton,
) {
TangemThemePreview {
AddCustomTokenFloatingButton(model)
}
}
private class AddCustomTokenFloatingButtonProvider : CollectionPreviewParameterProvider<AddCustomTokenFloatingButton>(
listOf(
AddCustomTokenFloatingButton(isEnabled = true, showProgress = false, onClick = {}),
AddCustomTokenFloatingButton(isEnabled = false, showProgress = false, onClick = {}),
),
)

View file

@ -1,212 +0,0 @@
package com.tangem.tap.features.customtoken.impl.presentation.ui.components
import androidx.compose.animation.*
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
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.customtoken.impl.presentation.ui.AddCustomTokenPreviewData
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),
) {
if (model.showTokenFields) InputField(model = model.contractAddressInputField)
SelectorField(model = model.networkSelectorField)
if (model.showTokenFields) InputField(model = model.tokenNameInputField)
if (model.showTokenFields) InputField(model = model.tokenSymbolInputField)
if (model.showTokenFields) InputField(model = model.decimalsInputField)
model.derivationPathSelectorField?.let { SelectorField(model = it) }
if (model.derivationPathInputField?.showField == true) InputField(model = model.derivationPathInputField)
}
}
}
@Composable
private fun InputField(model: AddCustomTokenInputField) {
Column {
val isError = (model as? AddCustomTokenInputField.ContactAddress)?.isError ?: false
TextField(model, isError)
(model as? AddCustomTokenInputField.ContactAddress)?.error?.resolveReference()?.let {
AnimatedVisibility(
visible = isError,
enter = fadeIn() + slideInVertically(),
exit = slideOutVertically() + fadeOut(),
) {
Text(
text = it,
color = MaterialTheme.colors.error,
style = TangemTheme.typography.body2,
)
}
}
}
}
@Composable
private fun TextField(model: AddCustomTokenInputField, isError: Boolean) {
Box {
val isEnabled = when (model) {
is AddCustomTokenInputField.ContactAddress -> true
is AddCustomTokenInputField.Decimals -> model.isEnabled
is AddCustomTokenInputField.TokenName -> model.isEnabled
is AddCustomTokenInputField.TokenSymbol -> model.isEnabled
is AddCustomTokenInputField.DerivationPath -> true
}
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = model.value,
onValueChange = model.onValueChange,
keyboardOptions = model.keyboardOptions,
label = {
Text(
text = model.label.resolveReference(),
style = TangemTheme.typography.caption2,
color = TangemTextFieldsDefault.defaultTextFieldColors.labelColor(
enabled = isEnabled,
error = isError,
interactionSource = remember { MutableInteractionSource() },
).value,
)
},
placeholder = {
Text(
text = model.placeholder.resolveReference(),
style = TangemTheme.typography.body1,
color = TangemTextFieldsDefault.defaultTextFieldColors
.placeholderColor(enabled = isEnabled)
.value,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
singleLine = true,
enabled = isEnabled,
isError = isError,
colors = TangemTextFieldsDefault.defaultTextFieldColors,
)
AnimatedVisibility(
visible = (model as? AddCustomTokenInputField.ContactAddress)?.isLoading ?: false,
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter)
.padding(horizontal = TangemTheme.dimens.spacing6)
.padding(bottom = TangemTheme.dimens.spacing6),
) {
LinearProgressIndicator(color = TangemTheme.colors.icon.primary1)
}
}
}
/**
* Be careful with this function ExposedDropdownMenuBox can crash the app if it is open and user clicks system back
* button. It was fixed in compose-material 1.6.4.
*/
@OptIn(ExperimentalMaterialApi::class)
@Composable
private fun SelectorField(model: AddCustomTokenSelectorField) {
var isExpanded by remember { mutableStateOf(value = false) }
ExposedDropdownMenuBox(
expanded = isExpanded,
onExpandedChange = { isExpanded = !isExpanded },
) {
val isEnabled = (model as? AddCustomTokenSelectorField.DerivationPath)?.isEnabled ?: true
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 = isEnabled,
label = { Text(text = model.label.resolveReference()) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = isExpanded) },
colors = TangemTextFieldsDefault.defaultTextFieldColors,
)
ExposedDropdownMenu(
expanded = isExpanded && isEnabled,
onDismissRequest = { isExpanded = false },
) {
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.caption2,
)
}
}
}
}
}
}
}
@Preview
@Composable
private fun Preview_AddCustomTokenForm(@PreviewParameter(AddCustomTokenFormProvider::class) model: AddCustomTokenForm) {
TangemThemePreview {
AddCustomTokenForm(model)
}
}
private class AddCustomTokenFormProvider : CollectionPreviewParameterProvider<AddCustomTokenForm>(
collection = listOf(
AddCustomTokenPreviewData.createDefaultForm(),
AddCustomTokenPreviewData.createDefaultForm().copy(derivationPathSelectorField = null),
AddCustomTokenPreviewData.createDefaultForm().let { form ->
form.copy(contractAddressInputField = form.contractAddressInputField.copy(isLoading = true))
},
),
)

View file

@ -1,61 +0,0 @@
package com.tangem.tap.features.customtoken.impl.presentation.ui.components
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.width
import androidx.compose.material.*
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.core.ui.res.TangemThemePreview
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) {
val toolbarElevation = if (isSystemInDarkTheme()) {
TangemTheme.dimens.elevation0
} else {
AppBarDefaults.TopAppBarElevation
}
TopAppBar(
backgroundColor = TangemTheme.colors.background.primary,
elevation = toolbarElevation,
) {
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() {
TangemThemePreview {
AddCustomTokenToolbar(title = TextReference.Res(R.string.add_custom_token_title), onBackButtonClick = {})
}
}

View file

@ -1,82 +0,0 @@
package com.tangem.tap.features.customtoken.impl.presentation.ui.components
import android.content.res.Configuration
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Card
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.sp
import com.tangem.core.ui.res.TangemColorPalette
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.res.TangemTheme
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenWarning
import com.tangem.tap.features.customtoken.impl.presentation.ui.AddCustomTokenPreviewData
import com.tangem.tap.features.details.ui.cardsettings.resolveReference
import com.tangem.wallet.R
/**
* Add custom token warnings
*
* @param warnings warnings descriptions set
*
[REDACTED_AUTHOR]
*/
@Composable
internal fun AddCustomTokenWarnings(warnings: Set<AddCustomTokenWarning>) {
Column(
modifier = Modifier
.padding(horizontal = TangemTheme.dimens.spacing16)
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8),
) {
warnings.forEach { warning ->
key(warning) { AddCustomTokenWarning(warning) }
}
}
}
@Composable
private fun AddCustomTokenWarning(warning: AddCustomTokenWarning) {
Card(
modifier = Modifier.fillMaxSize(),
shape = TangemTheme.shapes.roundedCornersSmall2,
backgroundColor = TangemColorPalette.Tangerine,
contentColor = TangemColorPalette.White,
elevation = TangemTheme.dimens.elevation4,
) {
// FIXME("Incorrect typography. Replace with typography from design system")
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 = warning.description.resolveReference(),
fontSize = 13.sp,
lineHeight = 18.sp,
)
}
}
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_AddCustomTokenWarnings() {
TangemThemePreview {
AddCustomTokenWarnings(warnings = AddCustomTokenPreviewData.createWarnings())
}
}

View file

@ -1,42 +0,0 @@
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.tokens.error.AddCustomTokenError
/**
* Validator of contract address
*
[REDACTED_AUTHOR]
*/
object ContractAddressValidator {
/** Validate a [address] using [blockchain] */
fun validate(address: String, blockchain: Blockchain): ContractAddressValidatorResult {
return when {
address.isEmpty() -> ContractAddressValidatorResult.Error(type = AddCustomTokenError.FIELD_IS_EMPTY)
validateAddress(blockchain, address) -> ContractAddressValidatorResult.Success
else -> ContractAddressValidatorResult.Error(type = AddCustomTokenError.INVALID_CONTRACT_ADDRESS)
}
}
private fun validateAddress(blockchain: Blockchain, address: String): Boolean {
return when (blockchain) {
Blockchain.Unknown,
Blockchain.Binance,
Blockchain.BinanceTestnet,
-> SuccessAddressValidator.validate(address)
Blockchain.Cardano -> blockchain.validateContractAddress(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

@ -1,21 +0,0 @@
package com.tangem.tap.features.customtoken.impl.presentation.validators
import com.tangem.domain.tokens.error.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

@ -1,17 +0,0 @@
package com.tangem.tap.features.customtoken.impl.presentation.viewmodels
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.domain.features.addCustomToken.CustomCurrency
import com.tangem.tap.common.analytics.events.ManageTokens
/** Analytics sender for tokens list screen */
class AddCustomTokenAnalyticsSender(private val analyticsEventHandler: AnalyticsEventHandler) {
fun sendWhenScreenOpened() {
analyticsEventHandler.send(ManageTokens.CustomToken.ScreenOpened)
}
fun sendWhenAddTokenButtonClicked(customCurrency: CustomCurrency) {
analyticsEventHandler.send(ManageTokens.CustomToken.TokenWasAdded(customCurrency))
}
}

View file

@ -1,7 +1,6 @@
package com.tangem.tap.features.details.redux
import androidx.lifecycle.LifecycleCoroutineScope
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.apptheme.model.AppThemeMode
import com.tangem.domain.models.scan.ScanResponse
@ -14,15 +13,6 @@ sealed class DetailsAction : Action {
val shouldSaveUserWallets: Boolean,
) : DetailsAction()
data object ScanAndSaveUserWallet : DetailsAction() {
data object Success : DetailsAction()
data class Error(val error: TextReference?) : DetailsAction()
}
data object DismissError : DetailsAction()
sealed class AppSettings : DetailsAction() {
data class SwitchPrivacySetting(
val enable: Boolean,

View file

@ -2,36 +2,24 @@ package com.tangem.tap.features.details.redux
import androidx.lifecycle.LifecycleCoroutineScope
import com.tangem.common.CompletionResult
import com.tangem.common.core.TangemError
import com.tangem.common.core.TangemSdkError
import com.tangem.common.doOnFailure
import com.tangem.common.doOnSuccess
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.common.routing.utils.popTo
import com.tangem.core.analytics.Analytics
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.apptheme.model.AppThemeMode
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.wallets.builder.UserWalletBuilder
import com.tangem.domain.wallets.legacy.UserWalletsListError
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.tap.*
import com.tangem.tap.common.analytics.events.AnalyticsParam
import com.tangem.tap.common.analytics.events.Settings
import com.tangem.tap.common.extensions.dispatchNavigationAction
import com.tangem.tap.common.extensions.dispatchWithMain
import com.tangem.tap.common.extensions.inject
import com.tangem.tap.common.extensions.onUserWalletSelected
import com.tangem.tap.common.redux.AppState
import com.tangem.tap.common.redux.global.GlobalAction
import com.tangem.tap.features.demo.DemoHelper
import com.tangem.tap.proxy.redux.DaggerGraphState
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flow
@ -41,7 +29,6 @@ import kotlinx.coroutines.launch
import org.rekotlin.Action
import org.rekotlin.Middleware
import timber.log.Timber
import com.tangem.core.analytics.models.AnalyticsParam as CoreAnalyticsParam
class DetailsMiddleware {
private val appSettingsMiddleware = AppSettingsMiddleware()
@ -62,7 +49,6 @@ class DetailsMiddleware {
private fun handleAction(state: DetailsState, action: Action) {
when (action) {
is DetailsAction.AppSettings -> appSettingsMiddleware.handle(state, action)
is DetailsAction.ScanAndSaveUserWallet -> scanAndSaveUserWallet()
}
}
@ -278,95 +264,4 @@ class DetailsMiddleware {
}
}
}
private fun scanAndSaveUserWallet() = scope.launch(Dispatchers.IO) {
val cardSdkConfigRepository = store.inject(DaggerGraphState::cardSdkConfigRepository)
val prevUseBiometricsForAccessCode = cardSdkConfigRepository.isBiometricsRequestPolicy()
// Update access code policy for access code saving when a card was scanned
val shouldSaveAccessCodes = store.inject(DaggerGraphState::settingsRepository).shouldSaveAccessCodes()
cardSdkConfigRepository.setAccessCodeRequestPolicy(isBiometricsRequestPolicy = shouldSaveAccessCodes)
store.inject(DaggerGraphState::scanCardProcessor).scan(
analyticsSource = CoreAnalyticsParam.ScreensSources.Settings,
onWalletNotCreated = {
// No need to rollback policy, continue with the policy set before the card scan
store.dispatchWithMain(DetailsAction.ScanAndSaveUserWallet.Success)
},
disclaimerWillShow = {
store.dispatchNavigationAction(AppRouter::pop)
},
onSuccess = { scanResponse ->
createUserWallet(scanResponse)
.doOnSuccess {
saveUserWalletAndPopBackToWalletScreen(
userWallet = it,
prevUseBiometricsForAccessCode = prevUseBiometricsForAccessCode,
)
}
.doOnFailure { error ->
Timber.e(error, "Unable to create user wallet")
handleError(error = error, prevUseBiometricsForAccessCode = prevUseBiometricsForAccessCode)
}
},
onFailure = { error ->
Timber.e(error, "Unable to scan card")
handleError(error = error, prevUseBiometricsForAccessCode = prevUseBiometricsForAccessCode)
},
)
}
private suspend fun createUserWallet(scanResponse: ScanResponse): CompletionResult<UserWallet> {
val walletNameGenerateUseCase = store.inject(DaggerGraphState::generateWalletNameUseCase)
val userWallet = UserWalletBuilder(scanResponse, walletNameGenerateUseCase).build()
return if (userWallet != null) {
CompletionResult.Success(userWallet)
} else {
CompletionResult.Failure(TangemSdkError.WalletIsNotCreated())
}
}
private suspend fun saveUserWalletAndPopBackToWalletScreen(
userWallet: UserWallet,
prevUseBiometricsForAccessCode: Boolean,
) {
val userWalletsListManager = store.inject(DaggerGraphState::generalUserWalletsListManager)
userWalletsListManager.save(userWallet)
.doOnSuccess {
store.onUserWalletSelected(userWallet)
store.dispatchWithMain(DetailsAction.ScanAndSaveUserWallet.Success)
store.dispatchNavigationAction { popTo<AppRoute.Wallet>() }
}
.doOnFailure { error ->
if (error is UserWalletsListError.WalletAlreadySaved) {
userWalletsListManager.select(userWallet.walletId)
store.onUserWalletSelected(userWallet)
store.dispatchWithMain(DetailsAction.ScanAndSaveUserWallet.Success)
store.dispatchNavigationAction { popTo<AppRoute.Wallet>() }
} else {
Timber.e(error, "Unable to create user wallet")
handleError(error, prevUseBiometricsForAccessCode)
}
}
}
private suspend fun handleError(error: TangemError, prevUseBiometricsForAccessCode: Boolean) {
val cardSdkConfigRepository = store.inject(DaggerGraphState::cardSdkConfigRepository)
// Rollback policy if card saving was failed
cardSdkConfigRepository.setAccessCodeRequestPolicy(prevUseBiometricsForAccessCode)
store.dispatchWithMain(DetailsAction.ScanAndSaveUserWallet.Error(error.toTextReference()))
}
private fun TangemError.toTextReference(): TextReference? {
if (silent) return null
return messageResId?.let(::resourceReference) ?: stringReference(customMessage)
}
}

View file

@ -1,7 +1,6 @@
package com.tangem.tap.features.details.redux
import com.tangem.domain.apptheme.model.AppThemeMode
import com.tangem.domain.models.scan.CardDTO
import com.tangem.tap.common.extensions.inject
import com.tangem.tap.common.redux.AppState
import com.tangem.tap.proxy.redux.DaggerGraphState
@ -21,7 +20,7 @@ private fun internalReduce(action: Action, state: AppState): DetailsState {
val detailsState = state.detailsState
return when (action) {
is DetailsAction.PrepareScreen -> {
handlePrepareScreen(action, state)
handlePrepareScreen(action)
}
is DetailsAction.AppSettings -> {
handlePrivacyAction(action, detailsState)
@ -31,26 +30,12 @@ private fun internalReduce(action: Action, state: AppState): DetailsState {
selectedAppCurrency = action.currency,
),
)
is DetailsAction.ScanAndSaveUserWallet -> detailsState.copy(
isScanningInProgress = true,
)
is DetailsAction.ScanAndSaveUserWallet.Error -> detailsState.copy(
isScanningInProgress = false,
error = action.error,
)
is DetailsAction.ScanAndSaveUserWallet.Success -> detailsState.copy(
isScanningInProgress = false,
)
is DetailsAction.DismissError -> detailsState.copy(
error = null,
)
}
}
private fun handlePrepareScreen(action: DetailsAction.PrepareScreen, state: AppState): DetailsState {
private fun handlePrepareScreen(action: DetailsAction.PrepareScreen): DetailsState {
return DetailsState(
scanResponse = action.scanResponse,
createBackupAllowed = action.scanResponse.card.backupStatus == CardDTO.BackupStatus.NoBackup,
appSettingsState = AppSettingsState(
isBiometricsAvailable = runBlocking {
tangemSdkManager.checkCanUseBiometry()

View file

@ -1,27 +1,16 @@
package com.tangem.tap.features.details.redux
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.apptheme.model.AppThemeMode
import com.tangem.domain.models.scan.ScanResponse
import org.rekotlin.StateType
data class DetailsState(
@Deprecated("Delete after onboarding refactoring")
val scanResponse: ScanResponse? = null,
val privacyPolicyUrl: String? = null,
val createBackupAllowed: Boolean = false,
val isScanningInProgress: Boolean = false,
val error: TextReference? = null,
val appSettingsState: AppSettingsState = AppSettingsState(),
) : StateType
sealed class ResetCardDialog {
data object StartResetDialog : ResetCardDialog()
data object ContinueResetDialog : ResetCardDialog()
data object InterruptedResetDialog : ResetCardDialog()
data object CompletedResetDialog : ResetCardDialog()
}
data class AppSettingsState(
val saveWallets: Boolean = false,
val saveAccessCodes: Boolean = false,

View file

@ -11,7 +11,6 @@ import com.tangem.domain.balancehiding.repositories.BalanceHidingRepository
import com.tangem.domain.settings.CanUseBiometryUseCase
import com.tangem.domain.settings.repositories.SettingsRepository
import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.features.details.DetailsFeatureToggles
import com.tangem.tap.common.analytics.events.AnalyticsParam
import com.tangem.tap.common.analytics.events.Settings
import com.tangem.tap.common.extensions.dispatchNavigationAction
@ -45,7 +44,6 @@ internal class AppSettingsViewModel @Inject constructor(
private val appThemeModeRepository: AppThemeModeRepository,
private val settingsRepository: SettingsRepository,
private val appSettingsItemsAnalyticsSender: AppSettingsItemsAnalyticsSender,
private val detailsFeatureToggles: DetailsFeatureToggles,
) : ViewModel(),
StoreSubscriber<DetailsState>,
DefaultLifecycleObserver {
@ -62,9 +60,7 @@ internal class AppSettingsViewModel @Inject constructor(
init {
bootstrapAppCurrencyUpdates()
if (detailsFeatureToggles.isRedesignEnabled) {
bootstrapBiometricsUpdates()
}
bootstrapBiometricsUpdates()
subscribeToStoreChanges()
sendItemsAnalytics()

View file

@ -1,71 +0,0 @@
package com.tangem.tap.features.details.ui.details
import android.os.Bundle
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.common.routing.AppRouter
import com.tangem.core.analytics.Analytics
import com.tangem.core.ui.UiDependencies
import com.tangem.core.ui.screen.ComposeFragment
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.tap.common.analytics.events.Settings
import com.tangem.tap.common.extensions.dispatchNavigationAction
import com.tangem.tap.features.details.redux.DetailsState
import com.tangem.tap.store
import dagger.hilt.android.AndroidEntryPoint
import org.rekotlin.StoreSubscriber
import javax.inject.Inject
@AndroidEntryPoint
internal class DetailsFragment : ComposeFragment(), StoreSubscriber<DetailsState> {
@Inject
override lateinit var uiDependencies: UiDependencies
@Inject
lateinit var walletsRepository: WalletsRepository
@Inject
lateinit var userWalletsListManager: UserWalletsListManager
private lateinit var detailsViewModel: DetailsViewModel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
detailsViewModel = DetailsViewModel(
store = store,
walletsRepository = walletsRepository,
userWalletsListManager = userWalletsListManager,
)
Analytics.send(Settings.ScreenOpened())
}
@Composable
override fun ScreenContent(modifier: Modifier) {
DetailsScreen(
modifier = modifier,
state = detailsViewModel.detailsScreenState.value,
onBackClick = { store.dispatchNavigationAction(AppRouter::pop) },
)
}
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
detailsViewModel.detailsScreenState.value = detailsViewModel.updateState(state)
}
}

View file

@ -1,288 +0,0 @@
package com.tangem.tap.features.details.ui.details
import android.content.res.Configuration
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
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 androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.SpacerHMax
import com.tangem.core.ui.event.EventEffect
import com.tangem.core.ui.event.StateEvent
import com.tangem.core.ui.event.consumedEvent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemColorPalette
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.res.TangemTheme
import com.tangem.tap.features.details.ui.common.ScreenTitle
import com.tangem.tap.features.details.ui.common.SettingsScreensScaffold
import com.tangem.wallet.R
import kotlinx.collections.immutable.toImmutableList
@Composable
internal fun DetailsScreen(state: DetailsScreenState, onBackClick: () -> Unit, modifier: Modifier = Modifier) {
val snackbarHostState = remember { SnackbarHostState() }
SettingsScreensScaffold(
modifier = modifier,
snackbarHostState = snackbarHostState,
content = { Content(state = state) },
onBackClick = onBackClick,
)
ShowSnackbarIfNeeded(
snackbarHostState = snackbarHostState,
messageEvent = state.showSnackbar,
)
}
@Composable
private fun Content(state: DetailsScreenState, modifier: Modifier = Modifier) {
Box(modifier = modifier) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState()),
) {
ScreenTitle(titleRes = R.string.details_title)
SpacerH(height = TangemTheme.dimens.spacing36)
SettingsItems(
items = state.elements,
)
SpacerHMax()
TangemSocialAccounts(
links = state.tangemLinks,
onSocialNetworkClick = state.onSocialNetworkClick,
)
SpacerH(height = TangemTheme.dimens.spacing12)
TangemAppVersion(
appNameRes = state.appNameRes,
version = state.tangemVersion,
)
SpacerH(height = TangemTheme.dimens.spacing16)
}
}
}
@Composable
private fun SettingsItems(items: List<SettingsItem>) {
items.forEach { item ->
if (item.isLarge) {
LargeDetailsItem(item)
} else {
DetailsItem(item)
}
}
}
@Composable
private fun LargeDetailsItem(item: SettingsItem) {
Row(
modifier = Modifier
.clickable(onClick = item.onClick)
.padding(horizontal = TangemTheme.dimens.spacing20)
.heightIn(min = TangemTheme.dimens.size84)
.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing20),
verticalAlignment = Alignment.CenterVertically,
) {
if (item.showProgress) {
CircularProgressIndicator(
modifier = Modifier.size(TangemTheme.dimens.size24),
color = TangemTheme.colors.icon.informative,
)
} else {
Icon(
modifier = Modifier.size(TangemTheme.dimens.size24),
painter = painterResource(id = item.iconResId),
contentDescription = item.title.resolveReference(),
tint = TangemColorPalette.Azure,
)
}
Column(
modifier = Modifier.heightIn(min = TangemTheme.dimens.size56),
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(
space = TangemTheme.dimens.spacing4,
alignment = Alignment.CenterVertically,
),
) {
Text(
text = item.title.resolveReference(),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
)
if (item.subtitle != null) {
Text(
text = item.subtitle.resolveReference(),
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.secondary,
)
}
}
}
}
@Composable
private fun DetailsItem(item: SettingsItem) {
Row(
modifier = Modifier
.clickable(enabled = !item.showProgress, onClick = item.onClick)
.padding(horizontal = TangemTheme.dimens.spacing20)
.heightIn(min = TangemTheme.dimens.size56)
.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing20),
verticalAlignment = Alignment.CenterVertically,
) {
if (item.showProgress) {
CircularProgressIndicator(
modifier = Modifier.size(TangemTheme.dimens.size24),
color = TangemTheme.colors.icon.informative,
)
} else {
Icon(
modifier = Modifier.size(TangemTheme.dimens.size24),
painter = painterResource(id = item.iconResId),
contentDescription = item.title.resolveReference(),
tint = TangemTheme.colors.icon.secondary,
)
}
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.SpaceAround,
) {
Text(
text = item.title.resolveReference(),
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.primary1,
)
if (item.subtitle != null) {
Text(
text = item.subtitle.resolveReference(),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.secondary,
)
}
}
}
}
@Composable
private fun TangemSocialAccounts(links: List<SocialNetworkLink>, onSocialNetworkClick: (SocialNetworkLink) -> Unit) {
LazyRow(
verticalAlignment = Alignment.CenterVertically,
contentPadding = PaddingValues(horizontal = TangemTheme.dimens.spacing8),
) {
items(links) {
val onClick = remember(it) {
{ onSocialNetworkClick(it) }
}
IconButton(
modifier = Modifier
.padding(horizontal = TangemTheme.dimens.spacing4)
.size(TangemTheme.dimens.size32),
onClick = onClick,
) {
Icon(
modifier = Modifier.size(TangemTheme.dimens.size24),
painter = painterResource(id = it.network.iconRes),
tint = TangemTheme.colors.icon.informative,
contentDescription = null,
)
}
}
}
}
@Composable
private fun ShowSnackbarIfNeeded(snackbarHostState: SnackbarHostState, messageEvent: StateEvent<TextReference>) {
var message: TextReference? by remember { mutableStateOf(value = null) }
val resolvedMessage by rememberUpdatedState(newValue = message?.resolveReference())
LaunchedEffect(resolvedMessage) {
resolvedMessage?.let {
snackbarHostState.showSnackbar(it)
}
}
EventEffect(messageEvent) {
message = it
}
}
@Composable
private fun TangemAppVersion(appNameRes: Int, version: String, modifier: Modifier = Modifier) {
Text(
modifier = modifier.padding(horizontal = TangemTheme.dimens.spacing16),
text = "${stringResource(id = appNameRes)} $version",
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
}
// region Preview
@Preview(showBackground = true, widthDp = 360, heightDp = 900)
@Preview(showBackground = true, widthDp = 360, heightDp = 900, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun DetailsScreenPreview(@PreviewParameter(DetailsScreenStateProvider::class) param: DetailsScreenState) {
TangemThemePreview {
DetailsScreen(param, onBackClick = {})
}
}
private class DetailsScreenStateProvider : CollectionPreviewParameterProvider<DetailsScreenState>(
collection = buildList {
DetailsScreenState(
elements = buildList {
SettingsItem.WalletConnect({}).let(::add)
SettingsItem.AddWallet(showProgress = false, {}).let(::add)
SettingsItem.LinkMoreCards({}).let(::add)
SettingsItem.CardSettings({}).let(::add)
SettingsItem.AppSettings({}).let(::add)
// removed chat in task [REDACTED_TASK_KEY]
// SettingsItem.Chat({}).let(::add)
SettingsItem.SendFeedback({}).let(::add)
SettingsItem.ReferralProgram({}).let(::add)
SettingsItem.TermsOfService({}).let(::add)
}.toImmutableList(),
tangemLinks = TangemSocialAccounts.accountsEn,
tangemVersion = "Tangem 2.14.12 (343)",
showSnackbar = consumedEvent(),
onSocialNetworkClick = {},
).let(::add)
DetailsScreenState(
elements = buildList {
SettingsItem.WalletConnect({}).let(::add)
SettingsItem.AddWallet(showProgress = true, {}).let(::add)
SettingsItem.CardSettings({}).let(::add)
SettingsItem.AppSettings({}).let(::add)
// removed chat in task [REDACTED_TASK_KEY]
// SettingsItem.Chat({}).let(::add)
SettingsItem.SendFeedback({}).let(::add)
SettingsItem.TermsOfService({}).let(::add)
}.toImmutableList(),
tangemLinks = TangemSocialAccounts.accountsRu,
tangemVersion = "Tangem 2.14.12 (343)",
showSnackbar = consumedEvent(),
onSocialNetworkClick = {},
).let(::add)
},
)
// endregion Preview

View file

@ -1,162 +0,0 @@
package com.tangem.tap.features.details.ui.details
import androidx.annotation.DrawableRes
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.event.StateEvent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.wallet.R
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@Immutable
internal data class DetailsScreenState(
val elements: ImmutableList<SettingsItem>,
val tangemLinks: ImmutableList<SocialNetworkLink>,
val tangemVersion: String,
val showSnackbar: StateEvent<TextReference>,
val onSocialNetworkClick: (SocialNetworkLink) -> Unit,
) {
val appNameRes: Int = R.string.tangem_app_name
}
@Immutable
internal sealed class SettingsItem(
@DrawableRes val iconResId: Int,
val title: TextReference,
val subtitle: TextReference? = null,
val isLarge: Boolean = false,
) {
abstract val onClick: () -> Unit
open val showProgress: Boolean = false
data class WalletConnect(
override val onClick: () -> Unit,
) : SettingsItem(
iconResId = R.drawable.ic_wallet_connect_24,
title = resourceReference(R.string.wallet_connect_title),
subtitle = resourceReference(R.string.wallet_connect_subtitle),
isLarge = true,
)
data class AddWallet(
override val showProgress: Boolean,
override val onClick: () -> Unit,
) : SettingsItem(
iconResId = R.drawable.ic_plus_24,
title = resourceReference(R.string.user_wallet_list_add_button),
)
data class ScanWallet(
override val showProgress: Boolean,
override val onClick: () -> Unit,
) : SettingsItem(
iconResId = R.drawable.ic_plus_24,
title = resourceReference(R.string.scan_card_settings_button),
)
data class LinkMoreCards(
override val onClick: () -> Unit,
) : SettingsItem(
iconResId = R.drawable.ic_more_cards_24,
title = resourceReference(R.string.details_row_title_create_backup),
)
data class CardSettings(
override val onClick: () -> Unit,
) : SettingsItem(
iconResId = R.drawable.ic_card_settings_24,
title = resourceReference(R.string.card_settings_title),
)
data class AppSettings(
override val onClick: () -> Unit,
) : SettingsItem(
iconResId = R.drawable.ic_settings,
title = resourceReference(R.string.app_settings_title),
)
data class Chat(
override val onClick: () -> Unit,
) : SettingsItem(
iconResId = R.drawable.ic_chat_24,
title = resourceReference(R.string.details_chat),
)
data class SendFeedback(
override val onClick: () -> Unit,
) : SettingsItem(
iconResId = R.drawable.ic_comment_24,
title = resourceReference(R.string.details_row_title_contact_to_support),
)
data class ReferralProgram(
override val onClick: () -> Unit,
) : SettingsItem(
iconResId = R.drawable.ic_add_friends_24,
title = resourceReference(R.string.details_referral_title),
)
data class TermsOfService(
override val onClick: () -> Unit,
) : SettingsItem(
iconResId = R.drawable.ic_text_24,
title = resourceReference(R.string.disclaimer_title),
)
data class TesterMenu(
override val onClick: () -> Unit,
) : SettingsItem(
iconResId = R.drawable.ic_alert_24,
title = resourceReference(R.string.tester_menu),
)
}
@Immutable
internal data class SocialNetworkLink(
val network: SocialNetwork,
val url: String,
)
internal sealed class EventError {
object Empty : EventError()
}
sealed class SocialNetwork(val id: String, val iconRes: Int) {
object Twitter : SocialNetwork("Twitter", R.drawable.ic_twitter_24)
object Telegram : SocialNetwork("Telegram", R.drawable.ic_telegram_24)
object Discord : SocialNetwork("Discord", R.drawable.ic_discord_24)
object Reddit : SocialNetwork("Reddit", R.drawable.ic_reddit_24)
object Instagram : SocialNetwork("Instagram", R.drawable.ic_instagram_24)
object GitHub : SocialNetwork("GitHub", R.drawable.ic_github_24)
object Facebook : SocialNetwork("Facebook", R.drawable.ic_facebook_24)
object LinkedIn : SocialNetwork("LinkedIn", R.drawable.ic_linkedin_24)
object YouTube : SocialNetwork("YouTube", R.drawable.ic_youtube_24)
}
internal object TangemSocialAccounts {
val accountsEn: ImmutableList<SocialNetworkLink> = persistentListOf(
SocialNetworkLink(SocialNetwork.Twitter, "https://x.com/tangem"),
SocialNetworkLink(SocialNetwork.Telegram, "https://t.me/tangem_chat"),
SocialNetworkLink(SocialNetwork.Discord, "https://discord.gg/tangem"),
SocialNetworkLink(SocialNetwork.Reddit, "https://www.reddit.com/r/Tangem/"),
SocialNetworkLink(SocialNetwork.Instagram, "https://www.instagram.com/tangemwallet"),
SocialNetworkLink(SocialNetwork.GitHub, "https://github.com/tangem"),
SocialNetworkLink(SocialNetwork.Facebook, "https://www.facebook.com/tangemwallet"),
SocialNetworkLink(SocialNetwork.LinkedIn, "https://www.linkedin.com/company/tangem"),
SocialNetworkLink(SocialNetwork.YouTube, "https://youtube.com/@tangem_official"),
)
val accountsRu: ImmutableList<SocialNetworkLink> = persistentListOf(
SocialNetworkLink(SocialNetwork.Twitter, "https://x.com/tangem"),
SocialNetworkLink(SocialNetwork.Telegram, "https://t.me/tangem_chat_ru"),
SocialNetworkLink(SocialNetwork.Discord, "https://discord.gg/tangem"),
SocialNetworkLink(SocialNetwork.Reddit, "https://www.reddit.com/r/Tangem/"),
SocialNetworkLink(SocialNetwork.Instagram, "https://www.instagram.com/tangemwallet"),
SocialNetworkLink(SocialNetwork.GitHub, "https://github.com/tangem"),
SocialNetworkLink(SocialNetwork.Facebook, "https://www.facebook.com/tangemwallet"),
SocialNetworkLink(SocialNetwork.LinkedIn, "https://www.linkedin.com/company/tangem"),
SocialNetworkLink(SocialNetwork.YouTube, "https://youtube.com/@tangem_official"),
)
}

View file

@ -1,215 +0,0 @@
package com.tangem.tap.features.details.ui.details
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import com.tangem.common.extensions.guard
import com.tangem.common.routing.AppRoute
import com.tangem.core.analytics.Analytics
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.Basic
import com.tangem.core.ui.event.StateEvent
import com.tangem.core.ui.event.consumedEvent
import com.tangem.core.ui.event.triggeredEvent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.tap.common.analytics.events.Settings
import com.tangem.tap.common.extensions.*
import com.tangem.tap.common.feedback.FeedbackEmail
import com.tangem.tap.common.redux.AppState
import com.tangem.tap.common.redux.global.GlobalAction
import com.tangem.tap.features.details.redux.DetailsAction
import com.tangem.tap.features.details.redux.DetailsState
import com.tangem.tap.features.disclaimer.redux.DisclaimerAction
import com.tangem.tap.features.disclaimer.redux.DisclaimerSource
import com.tangem.tap.features.home.LocaleRegionProvider
import com.tangem.tap.features.home.RUSSIA_COUNTRY_CODE
import com.tangem.tap.scope
import com.tangem.wallet.BuildConfig
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.rekotlin.Store
import timber.log.Timber
internal class DetailsViewModel(
private val store: Store<AppState>,
private val walletsRepository: WalletsRepository,
private val userWalletsListManager: UserWalletsListManager,
) {
var detailsScreenState: MutableState<DetailsScreenState> = mutableStateOf(updateState(store.state.detailsState))
private set
init {
bootstrapScreenState()
}
fun updateState(state: DetailsState): DetailsScreenState {
return DetailsScreenState(
elements = createSettingsItems(state),
tangemLinks = getSocialLinks(),
tangemVersion = getTangemAppVersion(),
showSnackbar = triggerErrorSnackbarIfNeeded(state.error),
onSocialNetworkClick = ::handleSocialNetworkClick,
)
}
private fun createSettingsItems(state: DetailsState): ImmutableList<SettingsItem> {
val scanResponse = state.scanResponse ?: return persistentListOf()
val cardTypesResolver = scanResponse.cardTypesResolver
return buildList {
SettingsItem.WalletConnect(::navigateToWalletConnect)
.takeIf { cardTypesResolver.isMultiwalletAllowed() }
?.let(::add)
SettingsItem.AddWallet(showProgress = state.isScanningInProgress, ::scanAndSaveUserWallet)
.takeIf { state.appSettingsState.saveWallets }
?.let(::add)
SettingsItem.ScanWallet(showProgress = state.isScanningInProgress, ::scanAndSaveUserWallet)
.takeUnless { state.appSettingsState.saveWallets }
?.let(::add)
SettingsItem.LinkMoreCards(::linkMoreCards)
.takeIf { state.createBackupAllowed }
?.let(::add)
SettingsItem.CardSettings(::navigateToCardSettings)
.let(::add)
SettingsItem.AppSettings(::navigateToAppSettings)
.let(::add)
SettingsItem.SendFeedback(::sendFeedback)
.let(::add)
SettingsItem.ReferralProgram(::navigateToReferralProgram)
.takeIf { cardTypesResolver.isTangemWallet() }
?.let(::add)
SettingsItem.TermsOfService(::navigateToToS)
.let(::add)
SettingsItem.TesterMenu(::navigateToTesterMenu)
.takeIf { BuildConfig.TESTER_MENU_ENABLED }
?.let(::add)
}.toImmutableList()
}
private fun triggerErrorSnackbarIfNeeded(text: TextReference?): StateEvent<TextReference> {
return if (text == null) {
consumedEvent()
} else {
triggeredEvent(text) {
store.dispatch(DetailsAction.DismissError)
}
}
}
private fun getTangemAppVersion(): String {
val versionCode: Int = BuildConfig.VERSION_CODE
val versionName: String = BuildConfig.VERSION_NAME
return "$versionName ($versionCode)"
}
private fun navigateToTesterMenu() {
store.dispatchNavigationAction {
push(AppRoute.TesterMenu)
}
}
private fun navigateToToS() {
store.dispatchOnMain(DisclaimerAction.Show(DisclaimerSource.Details))
}
private fun navigateToReferralProgram() {
val userWallet = userWalletsListManager.selectedUserWalletSync
?: error("Selected wallet must be not null")
store.dispatchNavigationAction { push(AppRoute.ReferralProgram(userWallet.walletId)) }
}
private fun sendFeedback() {
Analytics.send(Basic.ButtonSupport(AnalyticsParam.ScreensSources.Settings))
store.dispatchOnMain(
GlobalAction.SendEmail(
feedbackData = FeedbackEmail(),
scanResponse = userWalletsListManager.selectedUserWalletSync?.scanResponse
?: error("ScanResponse must be not null"),
),
)
}
private fun navigateToAppSettings() {
Analytics.send(Settings.ButtonAppSettings())
store.dispatchNavigationAction { push(AppRoute.AppSettings) }
}
private fun navigateToCardSettings() {
val userWalletId = userWalletsListManager.selectedUserWalletSync?.walletId
?: error("UserWalletId must be not null")
Analytics.send(Settings.ButtonCardSettings())
store.dispatchNavigationAction { push(AppRoute.CardSettings(userWalletId)) }
}
private fun linkMoreCards() {
Analytics.send(Settings.ButtonCreateBackup())
val selectedUserWallet = userWalletsListManager.selectedUserWalletSync.guard {
Timber.e("Unable to backup wallet, no user wallet selected")
return
}
val scanResponse = selectedUserWallet.scanResponse
Analytics.addContext(scanResponse)
store.dispatch(GlobalAction.Onboarding.Start(scanResponse, canSkipBackup = false))
store.dispatchNavigationAction { push(AppRoute.OnboardingWallet()) }
}
private fun scanAndSaveUserWallet() {
Analytics.send(Settings.ScanNewCard)
store.dispatchOnMain(DetailsAction.ScanAndSaveUserWallet)
}
private fun navigateToWalletConnect() {
Analytics.send(Settings.ButtonWalletConnect())
store.dispatchNavigationAction { push(AppRoute.WalletConnectSessions) }
}
private fun handleSocialNetworkClick(link: SocialNetworkLink) {
Analytics.send(Settings.ButtonSocialNetwork(link.network))
store.dispatchOpenUrl(link.url)
}
private fun getSocialLinks(): ImmutableList<SocialNetworkLink> {
val locale = LocaleRegionProvider().getRegion()
return if (locale.lowercase() == RUSSIA_COUNTRY_CODE) {
TangemSocialAccounts.accountsRu
} else {
TangemSocialAccounts.accountsEn
}
}
private fun bootstrapScreenState() {
userWalletsListManager.selectedUserWallet
.distinctUntilChanged()
.onEach { selectedUserWallet ->
store.dispatchWithMain(
DetailsAction.PrepareScreen(
scanResponse = selectedUserWallet.scanResponse,
shouldSaveUserWallets = walletsRepository.shouldSaveUserWalletsSync(),
),
)
}
.flowOn(Dispatchers.IO)
.launchIn(scope)
}
}

View file

@ -0,0 +1,8 @@
package com.tangem.tap.features.details.ui.resetcard
internal sealed class ResetCardDialog {
data object StartResetDialog : ResetCardDialog()
data object ContinueResetDialog : ResetCardDialog()
data object InterruptedResetDialog : ResetCardDialog()
data object CompletedResetDialog : ResetCardDialog()
}

View file

@ -22,7 +22,6 @@ import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.tap.common.analytics.events.Settings
import com.tangem.tap.common.extensions.dispatchNavigationAction
import com.tangem.tap.common.extensions.onUserWalletSelected
import com.tangem.tap.features.details.redux.ResetCardDialog
import com.tangem.tap.features.details.ui.cardsettings.domain.CardSettingsInteractor
import com.tangem.tap.features.details.ui.common.utils.getResetToFactoryDescription
import com.tangem.tap.store

View file

@ -24,12 +24,6 @@ abstract class BaseDisclaimer(
override suspend fun isAccepted(): Boolean = dataProvider.isAccepted()
}
class DummyDisclaimer : Disclaimer {
override fun getUri(): Uri = Uri.parse("https://tangem.com/tangem_tos.html")
override suspend fun accept() {}
override suspend fun isAccepted(): Boolean = false
}
class TangemDisclaimer(dataProvider: DisclaimerDataProvider) : BaseDisclaimer(dataProvider) {
override fun getUri(): Uri = Uri.parse("$baseUrl/tangem_tos.html")
}

View file

@ -1,20 +0,0 @@
package com.tangem.tap.features.disclaimer.redux
import com.tangem.tap.common.entities.ProgressState
import com.tangem.tap.features.disclaimer.Disclaimer
import org.rekotlin.Action
sealed class DisclaimerAction : Action {
data class SetDisclaimer(val disclaimer: Disclaimer) : DisclaimerAction()
data class Show(
val from: DisclaimerSource,
val callback: DisclaimerCallback? = null,
) : DisclaimerAction()
object AcceptDisclaimer : DisclaimerAction()
object OnBackPressed : DisclaimerAction()
data class OnProgressStateChanged(val state: ProgressState?) : DisclaimerAction()
}

View file

@ -1,45 +0,0 @@
package com.tangem.tap.features.disclaimer.redux
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.tap.common.extensions.dispatchNavigationAction
import com.tangem.tap.common.redux.AppState
import com.tangem.tap.mainScope
import com.tangem.tap.store
import kotlinx.coroutines.launch
import org.rekotlin.Action
import org.rekotlin.Middleware
class DisclaimerMiddleware {
val disclaimerMiddleware: Middleware<AppState> = { dispatch, state ->
{ next ->
{ action ->
state()?.let { handleDisclaimerMiddleware(action, it) }
next(action)
}
}
}
}
private fun handleDisclaimerMiddleware(action: Action, appState: AppState) {
val state = appState.disclaimerState
when (action) {
is DisclaimerAction.Show -> {
store.dispatchNavigationAction {
push(AppRoute.Disclaimer(isTosAccepted = action.from == DisclaimerSource.Details))
}
}
is DisclaimerAction.AcceptDisclaimer -> {
mainScope.launch {
state.disclaimer.accept()
store.dispatchNavigationAction(AppRouter::pop)
state.callback?.onAccept?.invoke()
}
}
is DisclaimerAction.OnBackPressed -> {
store.dispatchNavigationAction(AppRouter::pop)
state.callback?.onDismiss?.invoke()
}
}
}

View file

@ -1,31 +0,0 @@
package com.tangem.tap.features.disclaimer.redux
import com.tangem.tap.common.redux.AppState
import org.rekotlin.Action
object DisclaimerReducer {
fun reduce(action: Action, state: AppState): DisclaimerState = internalReduce(action, state)
}
private fun internalReduce(action: Action, state: AppState): DisclaimerState {
if (action !is DisclaimerAction) return state.disclaimerState
val disclaimerState = state.disclaimerState
return when (action) {
is DisclaimerAction.SetDisclaimer -> disclaimerState.copy(
disclaimer = action.disclaimer,
)
is DisclaimerAction.Show -> disclaimerState.copy(
showedFrom = action.from,
callback = action.callback,
)
is DisclaimerAction.AcceptDisclaimer, is DisclaimerAction.OnBackPressed -> disclaimerState.copy(
callback = null,
progressState = null,
)
is DisclaimerAction.OnProgressStateChanged -> disclaimerState.copy(
progressState = action.state,
)
}
}

View file

@ -1,5 +0,0 @@
package com.tangem.tap.features.disclaimer.redux
enum class DisclaimerSource {
Home, Details
}

View file

@ -1,20 +0,0 @@
package com.tangem.tap.features.disclaimer.redux
import com.tangem.common.extensions.VoidCallback
import com.tangem.tap.common.entities.ProgressState
import com.tangem.tap.features.disclaimer.Disclaimer
import com.tangem.tap.features.disclaimer.DummyDisclaimer
import org.rekotlin.StateType
data class DisclaimerState(
val disclaimer: Disclaimer = DummyDisclaimer(),
val showedFrom: DisclaimerSource = DisclaimerSource.Home,
val callback: DisclaimerCallback? = null,
val progressState: ProgressState? = null,
) : StateType
data class DisclaimerCallback(
val onAccept: VoidCallback? = null,
val onDismiss: VoidCallback? = null,
)

View file

@ -1,139 +0,0 @@
package com.tangem.tap.features.disclaimer.ui
import android.os.Bundle
import android.view.View
import android.view.View.OVER_SCROLL_NEVER
import android.webkit.WebView
import androidx.lifecycle.lifecycleScope
import androidx.transition.TransitionInflater
import by.kirich1409.viewbindingdelegate.viewBinding
import com.tangem.core.ui.extensions.setStatusBarColor
import com.tangem.tap.common.entities.ProgressState
import com.tangem.tap.common.extensions.beginDelayedTransition
import com.tangem.tap.common.extensions.hide
import com.tangem.tap.common.extensions.show
import com.tangem.tap.common.redux.AppState
import com.tangem.tap.features.BaseFragment
import com.tangem.tap.features.addBackPressHandler
import com.tangem.tap.features.disclaimer.Disclaimer
import com.tangem.tap.features.disclaimer.redux.DisclaimerAction
import com.tangem.tap.features.disclaimer.redux.DisclaimerSource
import com.tangem.tap.features.disclaimer.redux.DisclaimerState
import com.tangem.tap.store
import com.tangem.wallet.R
import com.tangem.wallet.databinding.FragmentDisclaimerBinding
import kotlinx.coroutines.launch
import org.rekotlin.StoreSubscriber
class DisclaimerFragment : BaseFragment(R.layout.fragment_disclaimer), StoreSubscriber<DisclaimerState> {
private val binding: FragmentDisclaimerBinding by viewBinding(FragmentDisclaimerBinding::bind)
private val webViewClient = DisclaimerWebViewClient()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
addBackPressHandler(handler = this)
binding.apply {
toolbar.setNavigationOnClickListener { handleOnBackPressed() }
webView.apply {
settings.allowFileAccess = false
settings.javaScriptEnabled = false
overScrollMode = OVER_SCROLL_NEVER
webViewClient = this@DisclaimerFragment.webViewClient
}
webView.hide()
groupError.hide()
groupAccept.hide()
groupLoading.hide()
btnAccept.setOnClickListener {
store.dispatch(DisclaimerAction.AcceptDisclaimer)
}
btnRepeat.setOnClickListener {
webViewClient.reset()
}
}
}
override fun onStart() {
super.onStart()
setStatusBarColor(R.color.background_secondary)
webViewClient.onProgressStateChanged = { store.dispatch(DisclaimerAction.OnProgressStateChanged(it)) }
store.subscribe(subscriber = this) { state ->
state
.skipRepeats { oldState, newState -> oldState.disclaimerState == newState.disclaimerState }
.select(AppState::disclaimerState)
}
}
override fun onStop() {
webViewClient.onProgressStateChanged = null
store.unsubscribe(this)
super.onStop()
}
override fun configureTransitions() {
val inflater = TransitionInflater.from(requireContext())
when (store.state.disclaimerState.showedFrom) {
DisclaimerSource.Home -> {
enterTransition = inflater.inflateTransition(android.R.transition.slide_bottom)
exitTransition = inflater.inflateTransition(android.R.transition.slide_top)
}
DisclaimerSource.Details -> {
super.configureTransitions()
}
}
}
override fun handleOnBackPressed() {
store.dispatch(DisclaimerAction.OnBackPressed)
}
override fun newState(state: DisclaimerState) {
return with(binding) {
if (activity == null || view == null) return
updateUiVisibility(state.disclaimer, state.progressState)
}
}
private fun updateUiVisibility(disclaimer: Disclaimer, progressState: ProgressState?) = with(binding) {
when (progressState) {
ProgressState.Loading -> {
root.beginDelayedTransition()
webView.hide()
groupError.hide()
groupAccept.hide()
groupLoading.show()
}
ProgressState.Done -> {
root.beginDelayedTransition()
groupError.hide()
groupLoading.hide()
webView.show()
lifecycleScope.launch {
groupAccept.show(!disclaimer.isAccepted())
}
}
ProgressState.Error -> {
root.beginDelayedTransition()
webView.show()
groupAccept.show()
groupLoading.hide()
groupError.hide()
webView.loadLocalTermsOfServices()
}
else -> {
webView.setBackgroundColor(resources.getColor(R.color.transparent, null))
webView.loadUrl(disclaimer.getUri().toString())
}
}
}
private fun WebView.loadLocalTermsOfServices() {
loadData(localTermsOfServices, "text/html", "UTF-8")
}
}

View file

@ -1,64 +0,0 @@
package com.tangem.tap.features.disclaimer.ui
import android.graphics.Bitmap
import android.webkit.*
import com.tangem.common.extensions.ifNotNull
import com.tangem.tap.common.entities.ProgressState
class DisclaimerWebViewClient : WebViewClient() {
var onProgressStateChanged: ((ProgressState) -> Unit)? = null
private var loadingUrl: String? = null
private var loadedUrl: String? = null
private var progressState: ProgressState = ProgressState.Loading
set(value) {
field = value
onProgressStateChanged?.invoke(value)
}
fun reset() {
loadingUrl = null
loadedUrl = null
progressState = ProgressState.Loading
}
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
super.onPageStarted(view, url, favicon)
if (loadingUrl != url) progressState = ProgressState.Loading
loadingUrl = url
}
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
if (loadedUrl != url) progressState = ProgressState.Done
loadedUrl = url
}
override fun onReceivedError(view: WebView?, resourceRequest: WebResourceRequest?, error: WebResourceError?) {
super.onReceivedError(view, resourceRequest, error)
error?.let { progressState = ProgressState.Error }
}
override fun onReceivedHttpError(
view: WebView?,
resourceRequest: WebResourceRequest?,
errorResponse: WebResourceResponse?,
) {
super.onReceivedHttpError(view, resourceRequest, errorResponse)
ifNotNull(resourceRequest, errorResponse) { request, response ->
if (request.url?.toString() != loadingUrl || response.statusCode < RESPONSE_USER_ERROR_STATUS_CODE) return
if (progressState != ProgressState.Done) return
progressState = ProgressState.Error
}
}
companion object {
private const val RESPONSE_USER_ERROR_STATUS_CODE = 400
}
}

View file

@ -1,66 +0,0 @@
package com.tangem.tap.features.disclaimer.ui
internal val localTermsOfServices = """
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>Legal Disclaimer</title>
<style>
@font-face{font-family:"SF Pro Display";src:url(/fonts/sf-pro/SFProDisplay-Light.eot) format("embedded-opentype"),url(/fonts/sf-pro/SFProDisplay-Light.woff2) format("woff2"),url(/fonts/sf-pro/SFProDisplay-Light.woff) format("woff"),url(/fonts/sf-pro/SFProDisplay-Light.ttf) format("truetype");font-weight:300;font-style:normal;font-display:swap}@font-face{font-family:"SF Pro Display";src:url(/fonts/sf-pro/SFProDisplay-Regular.eot) format("embedded-opentype"),url(/fonts/sf-pro/SFProDisplay-Regular.woff2) format("woff2"),url(/fonts/sf-pro/SFProDisplay-Regular.woff) format("woff"),url(/fonts/sf-pro/SFProDisplay-Regular.ttf) format("truetype");font-weight:400;font-style:normal;font-display:swap}@font-face{font-family:"SF Pro Display";src:url(/fonts/sf-pro/SFProDisplay-Medium.eot) format("embedded-opentype"),url(/fonts/sf-pro/SFProDisplay-Medium.woff2) format("woff2"),url(/fonts/sf-pro/SFProDisplay-Medium.woff) format("woff"),url(/fonts/sf-pro/SFProDisplay-Medium.ttf) format("truetype");font-weight:500;font-style:normal;font-display:swap}@font-face{font-family:"SF Pro Display";src:url(/fonts/sf-pro/SFProDisplay-Semibold.eot) format("embedded-opentype"),url(/fonts/sf-pro/SFProDisplay-Semibold.woff2) format("woff2"),url(/fonts/sf-pro/SFProDisplay-Semibold.woff) format("woff"),url(/fonts/sf-pro/SFProDisplay-Semibold.ttf) format("truetype");font-weight:600;font-style:normal;font-display:swap}@font-face{font-family:"SF Pro Display";src:url(/fonts/sf-pro/SFProDisplay-Bold.eot) format("embedded-opentype"),url(/fonts/sf-pro/SFProDisplay-Bold.woff2) format("woff2"),url(/fonts/sf-pro/SFProDisplay-Bold.woff) format("woff"),url(/fonts/sf-pro/SFProDisplay-Bold.ttf) format("truetype");font-weight:700;font-style:normal;font-display:swap}*{margin:0;padding:0}body{padding:0;margin:1rem;color:rgb(0 0 0);font-family:'SF Pro Display',-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen,Ubuntu,Cantarell,Fira Sans,Droid Sans,Helvetica Neue,sans-serif}h1{font-weight:700;font-size:30px;line-height:1.2;margin-bottom:1rem}p{font-size:16px;line-height:20px;letter-spacing:-.24px;margin-bottom:1rem}
</style>
</head>
<body><h1>Legal Disclaimer</h1>
<p>&nbsp; &nbsp; 1. Tangem application (Software)</p>
<p>&nbsp; &nbsp; The Software is intended for usage only with Tangem hardware wallets (Cards) via NFC interface. The
Software DOES NOT:</p>
<p>&nbsp; &nbsp; a) Generate, store, transmit, or have access to private (secret) cryptographic keys to blockchain
wallets holding digital assets, including crypto-currency.</p>
<p>&nbsp; &nbsp; b) Generate, store, transmit, or have access to secret keys, passwords, passphrases, recovery phrases
that can be used to restore or to copy private (secret) keys to blockchain wallets holding digital assets, including
crypto-currency.</p>
<p>&nbsp; &nbsp; c) Provide exchange, trading, investment services on behalf of Tangem AG.</p>
<p>&nbsp; &nbsp; 2. Risks related to the use of Software</p>
<p>&nbsp; &nbsp; Tangem will not be responsible for any losses, damages or claims arising from events falling within the
scope of the following five categories:</p>
<p>&nbsp; &nbsp; a) Mistakes made by the user of any cryptocurrency-related software or service, e.g., forgotten
passwords, payments sent to wrong addresses, and accidental deletion of blockchain wallets on Cards.</p>
<p>&nbsp; &nbsp; b) Problems of Software and/or any blockchain- or cryptocurrency- related software or service, e.g.,
corrupted files, incorrectly constructed transactions, unsafe cryptographic libraries, malware.</p>
<p>&nbsp; &nbsp; c) Technical failures in the hardware of the user, including Cards, of any cryptocurrency-related
software or service, e.g., data loss due to a faulty or damaged storage device.</p>
<p>&nbsp; &nbsp; d) Security problems experienced by the user of any cryptocurrency-related software or service, e.g.,
unauthorized access to users&apos; wallets and/or accounts.</p>
<p>&nbsp; &nbsp; e) Actions or inactions of third parties and/or events experienced by third parties, e.g., bankruptcy
of service providers, information security attacks on service providers, and fraud conducted by third parties.</p>
<p>&nbsp; &nbsp; 3. Trading and Investment risks</p>
<p>&nbsp; &nbsp; There is considerable exposure to risk in any crypto-currency or other digital asset exchange
transaction. Any transaction involving currencies involves risks including, but not limited to, the potential for
changing economic conditions that may substantially affect the price or liquidity of a currency. Investments in
crypto-currency exchange speculation may also be susceptible to sharp rises and falls as the relevant market values
fluctuate. It is for this reason that when speculating in such markets it is advisable to use only risk capital.</p>
<p>&nbsp; &nbsp; 4. Electronic Trading Risks</p>
<p>&nbsp; &nbsp; Before you engage in transactions using an electronic system, you should carefully review the rules and
regulations of the exchanges offering the system and/or listing the instruments you intend to trade. Online trading
has inherent risk due to system response and access times that may vary due to market conditions, system
performance, and other factors. You should understand these and additional risks before trading.</p>
<p>&nbsp; &nbsp; 5. Compliance with tax obligations &nbsp; &nbsp; &nbsp; &nbsp;</p>
<p>&nbsp; &nbsp; The users of the Software are solely responsible to determinate what, if any, taxes apply to their
crypto-currency transactions. The owners of, or contributors to, the Software are NOT responsible for determining
the taxes that apply to crypto-currency transactions. &nbsp; &nbsp; &nbsp; &nbsp;</p>
<p>&nbsp; &nbsp; 6. No warranties &nbsp; &nbsp; &nbsp; &nbsp;</p>
<p>&nbsp; &nbsp; The Software is provided on an &quot;as is&quot; basis without any warranties of any kind regarding the
Software and/or any content, data, materials and/or services provided on the Software. &nbsp; &nbsp; &nbsp;
&nbsp;</p>
<p>&nbsp; &nbsp; 7. Limitation of liability</p>
<p>&nbsp; &nbsp; Unless otherwise required by law, in no event shall the owners of, or contributors to, the Software be
liable for any damages of any kind, including, but not limited to, loss of use, loss of profits, or loss of data
arising out of or in any way connected with the use of the Software. In no way are the owners of, or contributors
to, the Software responsible for the actions, decisions, or other behavior taken or not taken by you in reliance
upon the Software.</p>
<p>&nbsp; &nbsp; 8. Last amendment</p>
<p>&nbsp; &nbsp; This disclaimer was amended for the last time on October 1st, 2020.</p></body>
</html>
""".trimIndent()

View file

@ -58,7 +58,9 @@ internal fun StoriesScreen(
}
StoriesScreenContent(
modifier = Modifier.fillMaxSize().testTag(TestTags.STORIES_SCREEN),
modifier = Modifier
.fillMaxSize()
.testTag(TestTags.STORIES_SCREEN),
config = StoriesScreenContentConfig(
storiesSize = state.stories.lastIndex,
currentStoryIndex = currentStoryIndex,
@ -157,7 +159,7 @@ private fun StoriesScreenContent(config: StoriesScreenContentConfig, modifier: M
duration = currentStoryDuration,
)
Stories.RevolutionaryWallet -> StoriesRevolutionaryWallet()
is Stories.UltraSecureBackup -> StoriesUltraSecureBackup(
Stories.UltraSecureBackup -> StoriesUltraSecureBackup(
isPaused = isPaused,
stepDuration = currentStoryDuration,
)

View file

@ -0,0 +1,12 @@
package com.tangem.tap.features.home.featuretoggles
import com.tangem.core.featuretoggle.manager.FeatureTogglesManager
import javax.inject.Inject
class HomeFeatureToggles @Inject constructor(
private val featureTogglesManager: FeatureTogglesManager,
) {
val isMigrateUserCountryCodeEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(name = "MIGRATE_USER_COUNTRY_CODE_ENABLED")
}

View file

@ -15,6 +15,4 @@ sealed class HomeAction : Action {
data class ReadCard(val scope: CoroutineScope) : HomeAction()
data class ScanInProgress(val scanInProgress: Boolean) : HomeAction()
data class UpdateCountryCode(val userCountryCode: String) : HomeAction()
}

View file

@ -10,16 +10,10 @@ object HomeReducer {
private fun internalReduce(action: Action, appState: AppState): HomeState {
if (action !is HomeAction) return appState.homeState
var state = appState.homeState
when (action) {
return when (action) {
is HomeAction.ScanInProgress -> {
state = state.copy(scanInProgress = action.scanInProgress)
appState.homeState.copy(scanInProgress = action.scanInProgress)
}
is HomeAction.UpdateCountryCode -> {
state.onCountryCodeUpdate(state, action.userCountryCode)
}
else -> {}
else -> appState.homeState
}
return state
}

View file

@ -1,56 +1,24 @@
package com.tangem.tap.features.home.redux
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import org.rekotlin.StateType
import java.util.Locale
@Immutable
data class HomeState(
val scanInProgress: Boolean = false,
val stories: List<Stories> = initDefaultStories(),
val stories: ImmutableList<Stories> = Stories.entries.toImmutableList(),
) : StateType {
val firstStory: Stories
get() = stories[0]
val firstStory: Stories get() = stories[0]
fun stepOf(story: Stories): Int = stories.indexOf(story)
fun onCountryCodeUpdate(homeState: HomeState, countryCode: String) {
val isNewWalletAvailable = !(countryCode == RUSSIA_COUNTRY_CODE || countryCode == BELARUS_COUNTRY_CODE)
homeState.stories.forEach {
it.isNewWalletAvailable.value = isNewWalletAvailable
}
}
companion object {
private const val RUSSIA_COUNTRY_CODE = "ru"
private const val BELARUS_COUNTRY_CODE = "by"
fun initDefaultStories(): List<Stories> = listOf(
Stories.TangemIntro,
Stories.RevolutionaryWallet,
Stories.UltraSecureBackup,
Stories.Currencies,
Stories.Web3,
Stories.WalletForEveryone,
)
fun isNewWalletAvailableInit(): Boolean {
val locale = Locale.getDefault().language
return !(locale == RUSSIA_COUNTRY_CODE || locale == BELARUS_COUNTRY_CODE)
}
}
}
sealed class Stories(
val duration: Int,
val isNewWalletAvailable: MutableState<Boolean> = mutableStateOf(HomeState.isNewWalletAvailableInit()),
) {
object TangemIntro : Stories(duration = 6000)
object RevolutionaryWallet : Stories(duration = 6000)
object UltraSecureBackup : Stories(duration = 6000)
object Currencies : Stories(duration = 6000)
object Web3 : Stories(duration = 6000)
object WalletForEveryone : Stories(duration = 6000)
enum class Stories(val duration: Int = 6000) {
TangemIntro,
RevolutionaryWallet,
UltraSecureBackup,
Currencies,
Web3,
WalletForEveryone,
}

View file

@ -13,16 +13,15 @@ import com.tangem.domain.balancehiding.BalanceHidingSettings
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.balancehiding.ListenToFlipsUseCase
import com.tangem.domain.balancehiding.UpdateBalanceHidingSettingsUseCase
import com.tangem.domain.feedback.FeedbackManagerFeatureToggles
import com.tangem.domain.settings.DeleteDeprecatedLogsUseCase
import com.tangem.domain.settings.IncrementAppLaunchCounterUseCase
import com.tangem.domain.settings.usercountry.FetchUserCountryUseCase
import com.tangem.domain.staking.FetchStakingTokensUseCase
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.features.staking.api.featuretoggles.StakingFeatureToggles
import com.tangem.tap.common.extensions.setContext
import com.tangem.tap.features.home.featuretoggles.HomeFeatureToggles
import com.tangem.tap.features.main.model.MainScreenState
import com.tangem.tap.store
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
@ -41,12 +40,12 @@ internal class MainViewModel @Inject constructor(
private val incrementAppLaunchCounterUseCase: IncrementAppLaunchCounterUseCase,
private val blockchainSDKFactory: BlockchainSDKFactory,
private val userWalletsListManager: UserWalletsListManager,
private val walletManagersFacade: WalletManagersFacade,
private val feedbackManagerFeatureToggles: FeedbackManagerFeatureToggles,
private val dispatchers: CoroutineDispatcherProvider,
stakingFeatureToggles: StakingFeatureToggles,
private val fetchStakingTokensUseCase: FetchStakingTokensUseCase,
private val apiConfigsManager: ApiConfigsManager,
homeFeatureToggles: HomeFeatureToggles,
private val fetchUserCountryUseCase: FetchUserCountryUseCase,
getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase,
) : ViewModel(), MainIntents {
@ -67,6 +66,14 @@ internal class MainViewModel @Inject constructor(
viewModelScope.launch(dispatchers.main) { incrementAppLaunchCounterUseCase() }
if (homeFeatureToggles.isMigrateUserCountryCodeEnabled) {
viewModelScope.launch {
fetchUserCountryUseCase().onLeft {
Timber.e("Unable to fetch the user country code $it")
}
}
}
updateAppCurrencies()
observeFlips()
displayBalancesHidingStatusToast()
@ -97,19 +104,6 @@ internal class MainViewModel @Inject constructor(
.onEach { userWallet ->
Analytics.setContext(userWallet.scanResponse)
Analytics.send(Basic.WalletOpened())
if (!feedbackManagerFeatureToggles.isLocalLogsEnabled) {
store.state.globalState.feedbackManager?.infoHolder?.let { infoHolder ->
infoHolder.setCardInfo(userWallet.scanResponse)
walletManagersFacade
.getAll(userWallet.walletId)
.distinctUntilChanged()
.onEach(infoHolder::setWalletsInfo)
.catch { Timber.e(it) }
.launchIn(viewModelScope)
}
}
}
.flowOn(dispatchers.io)
.launchIn(viewModelScope)

View file

@ -1,22 +1,30 @@
package com.tangem.tap.features.onboarding
import com.tangem.blockchain.common.WalletManager
import com.tangem.common.doOnFailure
import com.tangem.common.doOnSuccess
import com.tangem.common.extensions.guard
import com.tangem.common.routing.AppRoute
import com.tangem.core.analytics.Analytics
import com.tangem.core.analytics.models.Basic
import com.tangem.data.common.currency.CryptoCurrencyFactory
import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.domain.common.util.twinsIsTwinned
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.scan.ProductType
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.settings.usercountry.models.UserCountry
import com.tangem.domain.wallets.builder.UserWalletBuilder
import com.tangem.domain.wallets.builder.UserWalletIdBuilder
import com.tangem.tap.common.analytics.converters.ParamCardCurrencyConverter
import com.tangem.tap.common.analytics.events.AnalyticsParam
import com.tangem.tap.common.analytics.events.Onboarding
import com.tangem.tap.common.extensions.*
import com.tangem.tap.common.redux.AppDialog
import com.tangem.tap.common.redux.global.GlobalState
import com.tangem.tap.features.demo.DemoHelper
import com.tangem.tap.features.onboarding.products.wallet.redux.OnboardingWalletAction
import com.tangem.tap.features.home.RUSSIA_COUNTRY_CODE
import com.tangem.tap.features.saveWallet.redux.SaveWalletAction
import com.tangem.tap.mainScope
import com.tangem.tap.proxy.redux.DaggerGraphState
@ -75,6 +83,56 @@ object OnboardingHelper {
}
}
fun saveWallet(
scanResponse: ScanResponse,
accessCode: String? = null,
backupCardsIds: List<String>? = null,
hasBackupError: Boolean = false,
) {
Analytics.setContext(scanResponse)
scope.launch {
val settingsRepository = store.inject(DaggerGraphState::settingsRepository)
when {
// When should save user wallets, then save card without navigate to save wallet screen
store.inject(DaggerGraphState::walletsRepository).shouldSaveUserWalletsSync() -> {
store.dispatchWithMain(
SaveWalletAction.ProvideBackupInfo(
scanResponse = scanResponse,
accessCode = accessCode,
backupCardsIds = backupCardsIds?.toSet(),
),
)
store.dispatchWithMain(
SaveWalletAction.SaveWalletAfterBackup(
hasBackupError = hasBackupError,
shouldNavigateToWallet = false,
),
)
}
// When should not save user wallets but device has biometry and save wallet screen has not been shown,
// then open save wallet screen
tangemSdkManager.checkCanUseBiometry() && settingsRepository.shouldShowSaveUserWalletScreen() -> {
proceedWithScanResponse(scanResponse, backupCardsIds, hasBackupError)
delay(timeMillis = 1_200)
store.dispatchOnMain(
SaveWalletAction.ProvideBackupInfo(
scanResponse = scanResponse,
accessCode = accessCode,
backupCardsIds = backupCardsIds?.toSet(),
),
)
}
// If device has no biometry and save wallet screen has been shown, then go through old scenario
else -> {
proceedWithScanResponse(scanResponse, backupCardsIds, hasBackupError)
}
}
}
}
fun trySaveWalletAndNavigateToWalletScreen(
scanResponse: ScanResponse,
accessCode: String? = null,
@ -96,7 +154,12 @@ object OnboardingHelper {
),
)
store.dispatchWithMain(SaveWalletAction.SaveWalletAfterBackup(hasBackupError))
store.dispatchWithMain(
SaveWalletAction.SaveWalletAfterBackup(
hasBackupError = hasBackupError,
shouldNavigateToWallet = true,
),
)
}
// When should not save user wallets but device has biometry and save wallet screen has not been shown,
// then open save wallet screen
@ -138,6 +201,39 @@ object OnboardingHelper {
}
}
fun handleTopUpAction(walletManager: WalletManager, scanResponse: ScanResponse, globalState: GlobalState) {
val blockchain = walletManager.wallet.blockchain
val cryptoCurrency = CryptoCurrencyFactory().createCoin(
blockchain = blockchain,
extraDerivationPath = null,
scanResponse = scanResponse,
) ?: return
val topUpUrl = walletManager.getTopUpUrl(cryptoCurrency) ?: return
val currencyType = AnalyticsParam.CurrencyType.Blockchain(blockchain)
Analytics.send(Onboarding.Topup.ButtonBuyCrypto(currencyType))
scope.launch {
val homeFeatureToggles = store.inject(DaggerGraphState::homeFeatureToggles)
val isRussia = if (homeFeatureToggles.isMigrateUserCountryCodeEnabled) {
val getUserCountryCodeUseCase = store.inject(DaggerGraphState::getUserCountryUseCase)
getUserCountryCodeUseCase().isRight { it is UserCountry.Russia }
} else {
globalState.userCountryCode == RUSSIA_COUNTRY_CODE
}
if (isRussia) {
val dialogData = AppDialog.RussianCardholdersWarningDialog.Data(topUpUrl)
store.dispatchDialogShow(AppDialog.RussianCardholdersWarningDialog(dialogData))
} else {
store.dispatchOpenUrl(topUpUrl)
}
}
}
private suspend fun proceedWithScanResponse(
scanResponse: ScanResponse,
backupCardsIds: List<String>?,
@ -160,6 +256,7 @@ object OnboardingHelper {
}
.doOnSuccess {
mainScope.launch { store.onUserWalletSelected(userWallet) }
store.dispatch(OnboardingWalletAction.WalletSaved(userWallet.walletId))
}
}
}

View file

@ -11,7 +11,6 @@ import com.tangem.operations.attestation.CardVerifyAndGetInfo
import com.tangem.operations.attestation.OnlineCardVerifier
import com.tangem.tap.common.entities.ProgressState
import com.tangem.tap.common.extensions.inject
import com.tangem.tap.common.extensions.isPositive
import com.tangem.tap.common.extensions.safeUpdate
import com.tangem.tap.domain.TapError
import com.tangem.tap.domain.extensions.getOrLoadCardArtworkUrl
@ -20,6 +19,7 @@ import com.tangem.tap.domain.model.hasPendingTransactions
import com.tangem.tap.features.demo.isDemoCard
import com.tangem.tap.proxy.redux.DaggerGraphState
import com.tangem.tap.store
import com.tangem.utils.extensions.isPositive
import timber.log.Timber
import java.math.BigDecimal

View file

@ -7,12 +7,15 @@ import androidx.core.view.MenuProvider
import com.tangem.core.analytics.Analytics
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.Basic
import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.tap.common.feedback.SupportInfo
import com.tangem.tap.common.redux.global.GlobalAction
import com.tangem.tap.common.extensions.inject
import com.tangem.tap.proxy.redux.DaggerGraphState
import com.tangem.tap.scope
import com.tangem.tap.store
import com.tangem.utils.Provider
import com.tangem.wallet.R
import kotlinx.coroutines.launch
/**
[REDACTED_AUTHOR]
@ -28,13 +31,15 @@ class OnboardingMenuProvider(
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
R.id.menu_item_chat_support -> {
Analytics.send(Basic.ButtonSupport(AnalyticsParam.ScreensSources.Intro))
// changed on email support [REDACTED_TASK_KEY]
store.dispatch(
GlobalAction.SendEmail(
feedbackData = SupportInfo(),
scanResponse = scanResponseProvider(),
),
)
val cardInfo = store.inject(DaggerGraphState::getCardInfoUseCase).invoke(scanResponseProvider()).getOrNull()
?: error("CardInfo must be not null")
scope.launch {
store.inject(DaggerGraphState::sendFeedbackEmailUseCase)
.invoke(type = FeedbackEmailType.DirectUserRequest(cardInfo))
}
true
}
else -> false

View file

@ -14,7 +14,6 @@ import com.tangem.core.navigation.ShareElement
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.tap.common.analytics.events.Onboarding
import com.tangem.tap.common.extensions.getDrawableCompat
import com.tangem.tap.common.extensions.stripZeroPlainString
import com.tangem.tap.common.toggleWidget.RefreshBalanceWidget
import com.tangem.tap.common.transitions.InternalNoteLayoutTransition
import com.tangem.tap.features.addBackPressHandler
@ -24,6 +23,7 @@ import com.tangem.tap.features.onboarding.products.note.redux.OnboardingNoteActi
import com.tangem.tap.features.onboarding.products.note.redux.OnboardingNoteState
import com.tangem.tap.features.onboarding.products.note.redux.OnboardingNoteStep
import com.tangem.tap.store
import com.tangem.utils.extensions.stripZeroPlainString
import com.tangem.wallet.R
/**

View file

@ -4,12 +4,8 @@ import com.tangem.common.CompletionResult
import com.tangem.common.extensions.guard
import com.tangem.common.routing.AppRouter
import com.tangem.core.analytics.Analytics
import com.tangem.data.common.currency.CryptoCurrencyFactory
import com.tangem.domain.common.extensions.makePrimaryWalletManager
import com.tangem.domain.common.extensions.withMainContext
import com.tangem.domain.common.util.derivationStyleProvider
import com.tangem.tap.common.analytics.events.AnalyticsParam
import com.tangem.tap.common.analytics.events.Onboarding
import com.tangem.tap.common.entities.ProgressState
import com.tangem.tap.common.extensions.*
@ -20,7 +16,6 @@ import com.tangem.tap.common.redux.global.GlobalAction
import com.tangem.tap.domain.TapError
import com.tangem.tap.domain.model.Currency
import com.tangem.tap.features.demo.DemoHelper
import com.tangem.tap.features.home.RUSSIA_COUNTRY_CODE
import com.tangem.tap.features.onboarding.OnboardingDialog
import com.tangem.tap.features.onboarding.OnboardingHelper
import com.tangem.tap.mainScope
@ -178,23 +173,11 @@ private fun handleNoteAction(appState: () -> AppState?, action: Action, dispatch
return
}
val blockchain = walletManager.wallet.blockchain
val cryptoCurrency = CryptoCurrencyFactory().createCoin(
blockchain,
null,
scanResponse.derivationStyleProvider,
) ?: return
val topUpUrl = walletManager.getTopUpUrl(cryptoCurrency) ?: return
val currencyType = AnalyticsParam.CurrencyType.Blockchain(blockchain)
Analytics.send(Onboarding.Topup.ButtonBuyCrypto(currencyType))
if (globalState.userCountryCode == RUSSIA_COUNTRY_CODE) {
val dialogData = AppDialog.RussianCardholdersWarningDialog.Data(topUpUrl)
store.dispatchDialogShow(AppDialog.RussianCardholdersWarningDialog(dialogData))
} else {
store.dispatchOpenUrl(topUpUrl)
}
OnboardingHelper.handleTopUpAction(
walletManager = walletManager,
scanResponse = scanResponse,
globalState = globalState,
)
}
is OnboardingNoteAction.Done -> {
store.dispatch(GlobalAction.Onboarding.Stop)

View file

@ -6,16 +6,12 @@ import com.tangem.common.extensions.guard
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.utils.popTo
import com.tangem.core.analytics.Analytics
import com.tangem.data.common.currency.CryptoCurrencyFactory
import com.tangem.domain.common.extensions.makePrimaryWalletManager
import com.tangem.domain.common.extensions.withMainContext
import com.tangem.domain.common.util.derivationStyleProvider
import com.tangem.domain.common.util.twinsIsTwinned
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.wallets.builder.UserWalletIdBuilder
import com.tangem.domain.wallets.legacy.asLockable
import com.tangem.tap.common.analytics.events.AnalyticsParam
import com.tangem.tap.common.analytics.events.Onboarding
import com.tangem.tap.common.entities.ProgressState
import com.tangem.tap.common.extensions.*
@ -26,7 +22,6 @@ import com.tangem.tap.common.redux.global.GlobalAction
import com.tangem.tap.domain.TapError
import com.tangem.tap.domain.model.Currency
import com.tangem.tap.domain.twins.TwinCardsManager
import com.tangem.tap.features.home.RUSSIA_COUNTRY_CODE
import com.tangem.tap.features.onboarding.OnboardingDialog
import com.tangem.tap.features.onboarding.OnboardingHelper
import com.tangem.tap.mainScope
@ -301,24 +296,11 @@ private fun handle(action: Action, dispatch: DispatchFunction) {
return
}
val scanResponse = onboardingManager?.scanResponse ?: return
val blockchain = walletManager.wallet.blockchain
val cryptoCurrency = CryptoCurrencyFactory().createCoin(
blockchain,
null,
scanResponse.derivationStyleProvider,
) ?: return
val topUpUrl = walletManager.getTopUpUrl(cryptoCurrency) ?: return
val currencyType = AnalyticsParam.CurrencyType.Blockchain(blockchain)
Analytics.send(Onboarding.Topup.ButtonBuyCrypto(currencyType))
if (globalState.userCountryCode == RUSSIA_COUNTRY_CODE) {
val dialogData = AppDialog.RussianCardholdersWarningDialog.Data(topUpUrl)
store.dispatchDialogShow(AppDialog.RussianCardholdersWarningDialog(dialogData))
} else {
store.dispatchOpenUrl(topUpUrl)
}
OnboardingHelper.handleTopUpAction(
walletManager = walletManager,
scanResponse = onboardingManager?.scanResponse ?: return,
globalState = globalState,
)
}
TwinCardsAction.Done -> {
val scanResponse = getScanResponse()

View file

@ -21,7 +21,10 @@ import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.wallets.models.Artwork
import com.tangem.sdk.ui.widget.leapfrogWidget.LeapfrogWidget
import com.tangem.tap.common.analytics.events.Onboarding
import com.tangem.tap.common.extensions.*
import com.tangem.tap.common.extensions.beginDelayedTransition
import com.tangem.tap.common.extensions.getDrawableCompat
import com.tangem.tap.common.extensions.hide
import com.tangem.tap.common.extensions.show
import com.tangem.tap.common.toggleWidget.RefreshBalanceWidget
import com.tangem.tap.common.transitions.InternalNoteLayoutTransition
import com.tangem.tap.domain.twins.TwinsCardWidget
@ -35,6 +38,7 @@ import com.tangem.tap.features.onboarding.products.twins.redux.TwinCardsState
import com.tangem.tap.features.onboarding.products.twins.redux.TwinCardsStep
import com.tangem.tap.store
import com.tangem.utils.Provider
import com.tangem.utils.extensions.stripZeroPlainString
import com.tangem.wallet.R
import com.tangem.wallet.databinding.LayoutOnboardingContainerTopBinding
import dagger.hilt.android.AndroidEntryPoint

View file

@ -2,6 +2,7 @@ package com.tangem.tap.features.onboarding.products.wallet.redux
import android.net.Uri
import com.tangem.common.CompletionResult
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.common.card.Card
import com.tangem.feature.onboarding.data.model.CreateWalletResponse
import com.tangem.feature.onboarding.presentation.wallet2.analytics.SeedPhraseSource
@ -29,6 +30,8 @@ sealed class OnboardingWalletAction : Action {
class SetThirdCardArtworkUrl(val artworkUri: Uri?) : OnboardingWalletAction()
data object OnBackPressed : OnboardingWalletAction()
data class WalletSaved(val userWalletId: UserWalletId) : OnboardingWalletAction()
}
sealed class OnboardingWallet2Action : OnboardingWalletAction() {
@ -91,6 +94,8 @@ sealed class BackupAction : Action {
data class FinishBackup(val withAnalytics: Boolean = true) : BackupAction()
data class BackupFinished(val userWalletId: UserWalletId?) : BackupAction()
data object DiscardBackup : BackupAction()
data object DiscardSavedBackup : BackupAction()
data object ResumeFoundUnfinishedBackup : BackupAction()

View file

@ -5,7 +5,6 @@ import com.google.firebase.crashlytics.FirebaseCrashlytics
import com.tangem.common.CompletionResult
import com.tangem.common.card.Card
import com.tangem.common.core.TangemSdkError
import com.tangem.common.extensions.guard
import com.tangem.common.extensions.ifNotNull
import com.tangem.common.extensions.toHexString
import com.tangem.common.routing.AppRoute
@ -20,8 +19,10 @@ import com.tangem.domain.models.scan.CardDTO.Companion.RING_BATCH_IDS
import com.tangem.domain.models.scan.CardDTO.Companion.RING_BATCH_PREFIX
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.wallets.builder.UserWalletBuilder
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.wallets.builder.UserWalletIdBuilder
import com.tangem.domain.wallets.models.Artwork
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.feature.onboarding.data.model.CreateWalletResponse
import com.tangem.feature.onboarding.presentation.wallet2.analytics.SeedPhraseSource
import com.tangem.feature.wallet.presentation.wallet.domain.BackupValidator
@ -167,16 +168,7 @@ private fun handleWalletAction(action: Action) {
}
is OnboardingWalletAction.FinishOnboarding -> {
store.dispatch(GlobalAction.Onboarding.Stop)
if (scanResponse == null) {
action.scope.launch {
readCard { newScanResponse ->
handleFinishOnboardind(newScanResponse)
}
}
} else {
handleFinishOnboardind(scanResponse)
}
navigateToWalletScreen()
}
is OnboardingWalletAction.ResumeBackup -> {
val newAction = when (val backupState = backupService.currentState) {
@ -203,7 +195,7 @@ private fun handleWalletAction(action: Action) {
}
}
private fun handleFinishOnboardind(scanResponse: ScanResponse) {
private fun handleFinishBackup(scanResponse: ScanResponse) {
val backupState = store.state.onboardingWalletState.backupState
val updatedScanResponse = updateScanResponseAfterBackup(scanResponse, backupState)
@ -214,7 +206,7 @@ private fun handleFinishOnboardind(scanResponse: ScanResponse) {
}
}
OnboardingHelper.trySaveWalletAndNavigateToWalletScreen(
OnboardingHelper.saveWallet(
scanResponse = updatedScanResponse,
accessCode = backupState.accessCode,
backupCardsIds = backupState.backupCardIds,
@ -222,7 +214,18 @@ private fun handleFinishOnboardind(scanResponse: ScanResponse) {
)
}
private suspend fun readCard(onSuccess: (ScanResponse) -> Unit) {
private fun navigateToWalletScreen() {
mainScope.launch {
val settingsRepository = store.inject(DaggerGraphState::settingsRepository)
store.dispatchNavigationAction { replaceAll(AppRoute.Wallet) }
if (tangemSdkManager.checkCanUseBiometry() && settingsRepository.shouldShowSaveUserWalletScreen()) {
delay(timeMillis = 1_800)
store.dispatchNavigationAction { push(AppRoute.SaveWallet) }
}
}
}
private suspend fun readCard(onSuccess: suspend (ScanResponse) -> Unit) {
val shouldSaveAccessCodes = store.inject(DaggerGraphState::settingsRepository).shouldSaveAccessCodes()
store.inject(DaggerGraphState::cardSdkConfigRepository).setAccessCodeRequestPolicy(
@ -417,7 +420,7 @@ private fun handleBackupAction(appState: () -> AppState?, action: BackupAction)
val onboardingWalletState = appState()?.onboardingWalletState ?: return
val backupState = onboardingWalletState.backupState
val scanResponse = globalState.onboardingState.onboardingManager?.scanResponse
var scanResponse = globalState.onboardingState.onboardingManager?.scanResponse
val card = scanResponse?.card
when (action) {
@ -599,6 +602,15 @@ private fun handleBackupAction(appState: () -> AppState?, action: BackupAction)
cardIds = gatherCardIds(backupState, card),
)
}
if (scanResponse == null) {
scope.launch {
readCard { newScanResponse ->
handleFinishBackup(newScanResponse)
}
}
} else {
handleFinishBackup(scanResponse)
}
}
is BackupAction.FinishBackup -> {
scope.launch {
@ -606,28 +618,32 @@ private fun handleBackupAction(appState: () -> AppState?, action: BackupAction)
Analytics.send(Onboarding.Backup.Finished(backupState.backupCardsNumber))
}
var userWallet: UserWallet? = null
if (scanResponse != null) {
val walletNameGenerateUseCase = store.inject(DaggerGraphState::generateWalletNameUseCase)
val userWallet = UserWalletBuilder(scanResponse, walletNameGenerateUseCase)
.backupCardsIds(backupState.backupCardIds.toSet())
.build()
.guard {
Timber.e("User wallet not created")
return@launch
}
userWallet = createUserWallet(
scanResponse = requireNotNull(value = scanResponse, lazyMessage = { "ScanResponse is null" }),
backupState = backupState,
)
val userWalletsListManager = store.inject(DaggerGraphState::generalUserWalletsListManager)
userWalletsListManager.update(
userWalletId = userWallet.walletId,
update = { wallet ->
wallet.copy(
scanResponse = updateScanResponseAfterBackup(
scanResponse = wallet.scanResponse,
backupState = backupState,
),
)
},
)
when (backupState.startedSource) {
BackupStartedSource.Onboarding -> saveWallet(
userWalletsListManager = userWalletsListManager,
userWallet = userWallet,
scanResponse = scanResponse,
backupState = backupState,
)
BackupStartedSource.CreateBackup -> updateWallet(
userWalletsListManager = userWalletsListManager,
userWallet = userWallet,
backupState = backupState,
)
}
} else {
readCard { newScanResponse ->
scanResponse = newScanResponse
userWallet = createUserWallet(newScanResponse, backupState)
}
}
val notActivatedCardIds = gatherCardIds(backupState, card).mapNotNull {
@ -639,11 +655,18 @@ private fun handleBackupAction(appState: () -> AppState?, action: BackupAction)
}
// All cardIds may already be activated if the backup was skipped before.
if (notActivatedCardIds.isEmpty()) return@launch
if (notActivatedCardIds.isEmpty()) {
delay(1000)
store.dispatch(BackupAction.BackupFinished(userWallet?.walletId))
return@launch
}
Analytics.send(Onboarding.Finished())
store.state.globalState.onboardingState.onboardingManager?.finishActivation(notActivatedCardIds)
handleFinishBackup(requireNotNull(scanResponse))
delay(1000)
store.dispatch(BackupAction.BackupFinished(userWalletId = userWallet?.walletId))
}
}
@ -659,6 +682,54 @@ private fun Card?.isRing(): Boolean {
return this?.let { RING_BATCH_IDS.contains(batchId) || batchId.startsWith(RING_BATCH_PREFIX) } ?: false
}
private suspend fun saveWallet(
userWalletsListManager: UserWalletsListManager,
userWallet: UserWallet,
scanResponse: ScanResponse?,
backupState: BackupState,
) {
userWalletsListManager.save(
userWallet = userWallet.copy(
scanResponse = updateScanResponseAfterBackup(
scanResponse = requireNotNull(
value = scanResponse,
lazyMessage = { "ScanResponse is null" },
),
backupState = backupState,
),
),
canOverride = true,
)
}
private suspend fun updateWallet(
userWalletsListManager: UserWalletsListManager,
userWallet: UserWallet,
backupState: BackupState,
) {
userWalletsListManager.update(
userWalletId = userWallet.walletId,
update = { wallet ->
wallet.copy(
scanResponse = updateScanResponseAfterBackup(
scanResponse = wallet.scanResponse,
backupState = backupState,
),
)
},
)
}
private suspend fun createUserWallet(scanResponse: ScanResponse, backupState: BackupState): UserWallet {
val walletNameGenerateUseCase = store.inject(DaggerGraphState::generateWalletNameUseCase)
return requireNotNull(
value = UserWalletBuilder(scanResponse, walletNameGenerateUseCase)
.backupCardsIds(backupState.backupCardIds.toSet())
.build(),
lazyMessage = { "User wallet not created" },
)
}
fun updateArtworks(addedBackupCardsCount: Int, card: Card) {
mainScope.launch {
withIOContext {

View file

@ -1,5 +1,7 @@
package com.tangem.tap.features.onboarding.products.wallet.redux
import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.domain.redux.OnboardingManageTokensAction
import com.tangem.tap.backupService
import com.tangem.tap.common.redux.AppState
import com.tangem.tap.common.redux.global.GlobalAction
@ -9,12 +11,24 @@ object OnboardingWalletReducer {
fun reduce(action: Action, state: AppState): OnboardingWalletState = internalReduce(action, state)
}
@Suppress("CyclomaticComplexMethod")
private fun internalReduce(action: Action, appState: AppState): OnboardingWalletState {
val state = appState.onboardingWalletState
val backupState = state.backupState
return when (action) {
is GlobalAction.Onboarding -> ReducerForGlobalAction.reduce(action, state)
is BackupAction -> state.copy(backupState = BackupReducer.reduce(action, state.backupState))
is BackupAction.BackupFinished -> {
state.copy(
step = if (action.userWalletId != null && backupState.startedSource == BackupStartedSource.Onboarding) {
OnboardingWalletStep.ManageTokens
} else {
OnboardingWalletStep.Done
},
userWalletId = action.userWalletId,
)
}
is BackupAction -> state.copy(backupState = BackupReducer.reduce(action = action, state = state.backupState))
is OnboardingWallet2Action -> OnboardingWallet2Reducer.reduce(action, state)
is OnboardingWalletAction.GetToCreateWalletStep -> state.copy(
step = OnboardingWalletStep.CreateWallet,
@ -35,6 +49,14 @@ private fun internalReduce(action: Action, appState: AppState): OnboardingWallet
walletImages = state.walletImages.copy(thirdCardImage = action.artworkUri),
)
}
is OnboardingManageTokensAction.CurrenciesSaved -> state.copy(step = OnboardingWalletStep.Done)
is OnboardingWalletAction.WalletSaved -> state.copy(
step = when (backupState.startedSource) {
BackupStartedSource.Onboarding -> OnboardingWalletStep.ManageTokens
BackupStartedSource.CreateBackup -> OnboardingWalletStep.Done
},
userWalletId = action.userWalletId,
)
is OnboardingWalletAction.Done -> state.copy(step = OnboardingWalletStep.Done)
else -> state
}
@ -51,6 +73,7 @@ private object ReducerForGlobalAction {
backupState = state.backupState.copy(
maxBackupCards = MAX_BACKUP_CARDS,
canSkipBackup = action.canSkipBackup,
startedSource = action.source,
),
)
}
@ -87,6 +110,7 @@ private object BackupReducer {
backupStep = BackupStep.InitBackup,
maxBackupCards = 2,
canSkipBackup = state.canSkipBackup,
startedSource = state.startedSource,
)
BackupAction.StartAddingPrimaryCard -> {
state.copy(

View file

@ -4,6 +4,7 @@ import android.graphics.Bitmap
import android.net.Uri
import com.tangem.common.card.Card
import com.tangem.domain.redux.StateDialog
import com.tangem.domain.wallets.models.UserWalletId
import org.rekotlin.StateType
/**
@ -15,18 +16,20 @@ data class OnboardingWalletState(
val backupState: BackupState = BackupState(),
val walletImages: WalletImages = WalletImages(),
val showConfetti: Boolean = false,
val isRingOnboarding: Boolean = false,
val userWalletId: UserWalletId? = null,
) : StateType {
@Suppress("MagicNumber")
fun getMaxProgress(): Int {
val baseProgress = 6
val baseProgress = 7
return getWallet2Progress() + baseProgress
}
@Suppress("ComplexMethod", "MagicNumber")
fun getProgressStep(): Int {
val progressByStep = when (step) {
OnboardingWalletStep.CreateWallet -> 1
OnboardingWalletStep.CreateWallet, OnboardingWalletStep.None -> 1
OnboardingWalletStep.Backup -> {
when (backupState.backupStep) {
null -> 2
@ -37,11 +40,11 @@ data class OnboardingWalletState(
BackupStep.ReenterAccessCode -> 4
BackupStep.SetAccessCode -> 4
BackupStep.WritePrimaryCard, is BackupStep.WriteBackupCard -> 5
BackupStep.Finished -> getMaxProgress()
BackupStep.Finished -> 6
}
}
OnboardingWalletStep.ManageTokens -> 6
OnboardingWalletStep.Done -> getMaxProgress()
else -> 1
}
return getWallet2Progress() + progressByStep
@ -61,7 +64,7 @@ data class OnboardingWallet2State(
)
enum class OnboardingWalletStep {
None, CreateWallet, Backup, Done
None, CreateWallet, Backup, ManageTokens, Done
}
data class BackupState(
@ -81,9 +84,14 @@ data class BackupState(
val isInterruptedBackup: Boolean = false,
val showBtnLoading: Boolean = false,
val hasBackupError: Boolean = false,
val startedSource: BackupStartedSource = BackupStartedSource.Onboarding,
val hasRing: Boolean = false,
)
enum class BackupStartedSource {
Onboarding, CreateBackup
}
enum class AccessCodeError {
CodeTooShort, CodesDoNotMatch
}

View file

@ -3,6 +3,8 @@ package com.tangem.tap.features.onboarding.products.wallet.ui
import android.content.res.ColorStateList
import android.net.Uri
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.TypedValue
import android.view.View
import android.view.ViewGroup
@ -10,12 +12,16 @@ import android.view.WindowManager
import android.widget.FrameLayout
import android.widget.ImageView
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Modifier
import androidx.core.view.MenuProvider
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.transition.TransitionManager
import by.kirich1409.viewbindingdelegate.viewBinding
import coil.load
import com.arkivanov.decompose.defaultComponentContext
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.tabs.TabLayoutMediator
import com.tangem.common.CardIdFormatter
@ -25,21 +31,29 @@ import com.tangem.common.routing.AppRoute
import com.tangem.core.analytics.Analytics
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.Basic
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.di.RootAppComponentContext
import com.tangem.core.ui.UiDependencies
import com.tangem.core.ui.extensions.setStatusBarColor
import com.tangem.core.ui.message.EventMessageEffect
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.windowsize.rememberWindowSize
import com.tangem.datasource.utils.isNullOrEmpty
import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.domain.models.scan.isRing
import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.feature.onboarding.data.model.CreateWalletResponse
import com.tangem.feature.onboarding.presentation.wallet2.analytics.SeedPhraseSource
import com.tangem.feature.onboarding.presentation.wallet2.viewmodel.SeedPhraseMediator
import com.tangem.feature.onboarding.presentation.wallet2.viewmodel.SeedPhraseRouter
import com.tangem.feature.onboarding.presentation.wallet2.viewmodel.SeedPhraseViewModel
import com.tangem.features.managetokens.component.OnboardingManageTokensComponent
import com.tangem.sdk.ui.widget.leapfrogWidget.LeapfrogWidget
import com.tangem.sdk.ui.widget.leapfrogWidget.PropertyCalculator
import com.tangem.tap.common.analytics.events.Onboarding
import com.tangem.tap.common.extensions.*
import com.tangem.tap.common.feedback.SupportInfo
import com.tangem.tap.common.redux.global.GlobalAction
import com.tangem.tap.features.BaseFragment
import com.tangem.tap.features.FragmentOnBackPressedHandler
import com.tangem.tap.features.addBackPressHandler
@ -47,6 +61,8 @@ import com.tangem.tap.features.onboarding.OnboardingMenuProvider
import com.tangem.tap.features.onboarding.products.wallet.redux.*
import com.tangem.tap.features.onboarding.products.wallet.ui.dialogs.AccessCodeDialog
import com.tangem.tap.mainScope
import com.tangem.tap.proxy.redux.DaggerGraphState
import com.tangem.tap.scope
import com.tangem.tap.store
import com.tangem.utils.Provider
import com.tangem.wallet.R
@ -56,6 +72,7 @@ import com.tangem.wallet.databinding.ViewOnboardingProgressBinding
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import org.rekotlin.StoreSubscriber
import javax.inject.Inject
@Suppress("LargeClass", "MagicNumber")
@AndroidEntryPoint
@ -64,10 +81,24 @@ class OnboardingWalletFragment :
StoreSubscriber<OnboardingWalletState>,
FragmentOnBackPressedHandler {
@Inject
internal lateinit var uiDependencies: UiDependencies
@Inject
internal lateinit var onboardingManageTokensComponentFactory: OnboardingManageTokensComponent.Factory
@Inject
@RootAppComponentContext
internal lateinit var rootComponentContext: AppComponentContext
private var onboardingManageTokensComponent: OnboardingManageTokensComponent? = null
private lateinit var onboardingComponentContext: AppComponentContext
internal val binding: FragmentOnboardingWalletBinding by viewBinding(FragmentOnboardingWalletBinding::bind)
internal val pbBinding: ViewOnboardingProgressBinding by viewBinding(ViewOnboardingProgressBinding::bind)
internal val bindingSeedPhrase: LayoutOnboardingSeedPhraseBinding by lazy { binding.onboardingSeedPhraseContainer }
private val bindingManageTokens by lazy { binding.onboardingManageTokensContainer }
private val canSkipBackup by lazy { arguments?.getBoolean(AppRoute.OnboardingWallet.CAN_SKIP_BACKUP_KEY) ?: true }
@ -89,6 +120,10 @@ class OnboardingWalletFragment :
seedPhraseRouter = newSeedPhraseRouter
seedPhraseViewModel.setRouter(newSeedPhraseRouter)
seedPhraseViewModel.setMediator(makeSeedPhraseMediator())
onboardingComponentContext = rootComponentContext.childByContext(
componentContext = defaultComponentContext(onBackPressedDispatcher = null),
)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@ -173,27 +208,29 @@ class OnboardingWalletFragment :
override fun onStop() {
super.onStop()
onboardingManageTokensComponent = null
store.unsubscribe(this)
}
override fun newState(state: OnboardingWalletState) {
if (activity == null || view == null) return
Handler(Looper.getMainLooper()).post {
if (activity == null || view == null) return@post
animator.updateBackupState(state.backupState)
requireActivity().invalidateOptionsMenu()
animator.updateBackupState(state.backupState)
requireActivity().invalidateOptionsMenu()
pbBinding.pbState.max = state.getMaxProgress()
pbBinding.pbState.progress = state.getProgressStep()
pbBinding.pbState.max = state.getMaxProgress()
pbBinding.pbState.progress = state.getProgressStep()
when {
state.wallet2State != null -> {
seedPhraseStateHandler.newState(this, state, seedPhraseViewModel)
seedPhraseViewModel.setCardArtworkUri(cardArtworkUri = state.walletImages.primaryCardImage.toString())
updateWalletImagesState(state.walletImages)
}
else -> {
updateWalletImagesState(state.walletImages)
handleOnboardingStep(state)
when {
state.wallet2State != null -> {
seedPhraseStateHandler.newState(this, state, seedPhraseViewModel)
seedPhraseViewModel.setCardArtworkUri(cardArtworkUri = state.walletImages.primaryCardImage.toString())
updateWalletImagesState(state.walletImages)
}
else -> {
updateWalletImagesState(state.walletImages)
handleOnboardingStep(state)
}
}
}
}
@ -221,14 +258,54 @@ class OnboardingWalletFragment :
internal fun handleOnboardingStep(state: OnboardingWalletState) {
when (state.step) {
OnboardingWalletStep.CreateWallet -> setupCreateWalletState()
OnboardingWalletStep.Backup -> setBackupState(
state = state.backupState,
)
OnboardingWalletStep.Backup -> setBackupState(state = state.backupState)
OnboardingWalletStep.ManageTokens -> setManageTokensState(requireNotNull(state.userWalletId))
OnboardingWalletStep.Done -> showSuccess()
else -> {}
}
}
private fun initializeOnboardingManageTokensComponent(userWalletId: UserWalletId) {
onboardingManageTokensComponent = onboardingManageTokensComponentFactory.create(
context = onboardingComponentContext,
params = OnboardingManageTokensComponent.Params(userWalletId = userWalletId),
)
}
private fun setManageTokensState(userWalletId: UserWalletId) {
with(binding) {
tvHeader.show()
tvBody.show()
viewPagerBackupInfo.hide()
tabLayoutBackupInfo.hide()
onboardingWalletContainer.show()
layoutButtonsCommon.btnWalletAlternativeAction.hide()
onboardingWalletContainer.hide()
toolbar.title = getText(R.string.onboarding_add_tokens)
}
pbBinding.pbState.show()
bindingSeedPhrase.onboardingSeedPhraseContainer.hide()
if (onboardingManageTokensComponent == null) {
initializeOnboardingManageTokensComponent(userWalletId)
}
with(bindingManageTokens) {
onboardingManageTokensContainer.hide()
onboardingManageTokensContainer.show()
onboardingManageTokensContainer.setContent {
TangemTheme(
isDark = isSystemInDarkTheme(),
windowSize = rememberWindowSize(activity = requireActivity()),
) {
onboardingManageTokensComponent?.Content(modifier = Modifier.fillMaxSize())
EventMessageEffect(
messageHandler = uiDependencies.eventMessageHandler,
snackbarHostState = uiDependencies.globalSnackbarHostState,
)
}
}
}
}
private fun setupCreateWalletState() = with(binding) {
layoutButtonsCommon.btnWalletMainAction.setText(R.string.onboarding_create_wallet_button_create_wallet)
layoutButtonsCommon.btnWalletMainAction.setIconResource(R.drawable.ic_tangem_24)
@ -258,7 +335,10 @@ class OnboardingWalletFragment :
BackupStep.ReenterAccessCode -> showReenterAccessCode(state)
is BackupStep.WritePrimaryCard -> showWritePrimaryCard(state)
is BackupStep.WriteBackupCard -> showWriteBackupCard(state)
BackupStep.Finished -> showSuccess()
BackupStep.Finished -> {
// don't need to navigate here"
// showSuccess()
}
}
}
@ -479,6 +559,8 @@ class OnboardingWalletFragment :
tvBody.show()
viewPagerBackupInfo.hide()
tabLayoutBackupInfo.hide()
onboardingWalletContainer.show()
bindingManageTokens.onboardingManageTokensContainer.hide()
tvBody.text = getText(R.string.onboarding_subtitle_success_tangem_wallet_onboarding)
layoutButtonsCommon.btnWalletMainAction.text = getText(R.string.onboarding_button_continue_wallet)
@ -536,14 +618,16 @@ class OnboardingWalletFragment :
onBack = ::legacyOnBackHandler,
onOpenChat = {
Analytics.send(Basic.ButtonSupport(AnalyticsParam.ScreensSources.Intro))
// changed on email support [REDACTED_TASK_KEY]
store.dispatch(
GlobalAction.SendEmail(
feedbackData = SupportInfo(),
scanResponse = store.state.globalState.onboardingState.onboardingManager?.scanResponse
?: error("ScanResponse must be not null"),
),
)
val scanResponse = requireNotNull(store.state.globalState.onboardingState.onboardingManager?.scanResponse)
val getCardInfoUseCase = store.inject(DaggerGraphState::getCardInfoUseCase)
val cardInfo = requireNotNull(getCardInfoUseCase.invoke(scanResponse).getOrNull())
scope.launch {
store.inject(DaggerGraphState::sendFeedbackEmailUseCase)
.invoke(type = FeedbackEmailType.DirectUserRequest(cardInfo))
}
},
onOpenUriClick = { uri ->
store.dispatchOpenUrl(uri.toString())

View file

@ -6,12 +6,15 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.tangem.core.analytics.Analytics
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.Basic
import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.tap.common.extensions.dispatchDialogHide
import com.tangem.tap.common.feedback.SupportInfo
import com.tangem.tap.common.redux.global.GlobalAction
import com.tangem.tap.common.extensions.inject
import com.tangem.tap.features.onboarding.OnboardingDialog
import com.tangem.tap.proxy.redux.DaggerGraphState
import com.tangem.tap.scope
import com.tangem.tap.store
import com.tangem.wallet.R
import kotlinx.coroutines.launch
object WalletActivationErrorDialog {
@ -23,13 +26,17 @@ object WalletActivationErrorDialog {
setNegativeButton(R.string.common_support) { _, _ ->
// changed on email support [REDACTED_TASK_KEY]
Analytics.send(Basic.ButtonSupport(AnalyticsParam.ScreensSources.Intro))
store.dispatch(
GlobalAction.SendEmail(
feedbackData = SupportInfo(),
scanResponse = store.state.globalState.onboardingState.onboardingManager?.scanResponse
?: error("ScanResponse must be not null"),
),
)
val scanResponse = store.state.globalState.onboardingState.onboardingManager?.scanResponse
?: error("ScanResponse must be not null")
val cardInfo = store.inject(DaggerGraphState::getCardInfoUseCase).invoke(scanResponse).getOrNull()
?: error("CardInfo must be not null")
scope.launch {
store.inject(DaggerGraphState::sendFeedbackEmailUseCase)
.invoke(type = FeedbackEmailType.DirectUserRequest(cardInfo))
}
}
setOnDismissListener { store.dispatchDialogHide() }
setCancelable(false)

View file

@ -24,5 +24,8 @@ internal sealed interface SaveWalletAction : Action {
data object Cancel : SaveWalletAction
}
data class SaveWalletAfterBackup(val hasBackupError: Boolean) : SaveWalletAction
data class SaveWalletAfterBackup(
val hasBackupError: Boolean,
val shouldNavigateToWallet: Boolean,
) : SaveWalletAction
}

View file

@ -16,6 +16,7 @@ import com.tangem.tap.common.extensions.dispatchWithMain
import com.tangem.tap.common.extensions.inject
import com.tangem.tap.common.extensions.onUserWalletSelected
import com.tangem.tap.common.redux.AppState
import com.tangem.tap.features.onboarding.products.wallet.redux.OnboardingWalletAction
import com.tangem.tap.proxy.redux.DaggerGraphState
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
@ -44,7 +45,11 @@ internal class SaveWalletMiddleware {
is SaveWalletAction.AllowToUseBiometrics -> allowToUseBiometrics(state)
is SaveWalletAction.EnrollBiometrics.Enroll -> enrollBiometrics()
is SaveWalletAction.Dismiss -> dismiss(state)
is SaveWalletAction.SaveWalletAfterBackup -> saveWalletAfterBackup(state, action.hasBackupError)
is SaveWalletAction.SaveWalletAfterBackup -> saveWalletAfterBackup(
state = state,
hasBackupError = action.hasBackupError,
shouldNavigateToWallet = action.shouldNavigateToWallet,
)
is SaveWalletAction.AllowToUseBiometrics.Success,
is SaveWalletAction.AllowToUseBiometrics.Error,
is SaveWalletAction.ProvideBackupInfo,
@ -55,7 +60,11 @@ internal class SaveWalletMiddleware {
}
}
private fun saveWalletAfterBackup(state: SaveWalletState, hasBackupError: Boolean) {
private fun saveWalletAfterBackup(
state: SaveWalletState,
hasBackupError: Boolean,
shouldNavigateToWallet: Boolean,
) {
scope.launch {
val backupInfo = state.backupInfo ?: error("Backup info is null")
@ -77,8 +86,13 @@ internal class SaveWalletMiddleware {
.doOnFailure { error ->
Timber.e(error, "Unable to save user wallet")
}
.doOnSuccess { mainScope.launch { store.onUserWalletSelected(userWallet) } }
.doOnResult { navigateToWallet() }
.doOnSuccess {
mainScope.launch { store.onUserWalletSelected(userWallet) }
if (!shouldNavigateToWallet) {
store.dispatch(OnboardingWalletAction.WalletSaved(userWallet.walletId))
}
}
.doOnResult { if (shouldNavigateToWallet) navigateToWallet() }
}
}

View file

@ -1,61 +0,0 @@
package com.tangem.tap.features.tokens.impl.data
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.testnet.TestnetTokensStorage
import com.tangem.domain.common.TapWorkarounds.isTestCard
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.tap.features.tokens.impl.domain.TokensListRepository
import com.tangem.tap.features.tokens.impl.domain.models.Token
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.Flow
/**
* Default repository implementation of tokens list feature
*
* @property tangemTechApi Tangem Tech API
* @property dispatchers coroutine dispatchers provider
* @property getSelectedWalletSyncUseCase use case that returns selected wallet
* @property testnetTokensStorage storage for getting testnet tokens data
*
[REDACTED_AUTHOR]
*/
internal class DefaultTokensListRepository(
private val tangemTechApi: TangemTechApi,
private val dispatchers: CoroutineDispatcherProvider,
private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase,
private val testnetTokensStorage: TestnetTokensStorage,
) : TokensListRepository {
override fun getAvailableTokens(searchText: String?, needFilterExcluded: Boolean): Flow<PagingData<Token>> {
return Pager(
config = PagingConfig(
pageSize = 100,
prefetchDistance = 70,
enablePlaceholders = false,
),
pagingSourceFactory = {
val defaultSource = TangemApiTokensPagingSource(
api = tangemTechApi,
dispatchers = dispatchers,
getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase,
searchText = searchText,
needFilterExcluded = needFilterExcluded,
)
getSelectedWalletSyncUseCase().fold(
ifLeft = { defaultSource },
ifRight = {
if (it.scanResponse.card.isTestCard) {
TestnetTokensPagingSource(testnetTokensStorage, searchText)
} else {
defaultSource
}
},
)
},
).flow
}
}

View file

@ -1,78 +0,0 @@
package com.tangem.tap.features.tokens.impl.data
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.isSupportedInApp
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.domain.common.extensions.supportedBlockchains
import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.tap.features.tokens.impl.data.converters.CoinsResponseConverter
import com.tangem.tap.features.tokens.impl.domain.models.Token
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.runCatching
/**
* Paging source that get tokens by Tangem Tech API
*
* @property api Tangem Tech API
* @property dispatchers coroutine dispatchers provider
* @property getSelectedWalletSyncUseCase use case that returns selected wallet
* @property searchText search text
* @property needFilterExcluded filter networks that are not supported in the app
*/
internal class TangemApiTokensPagingSource(
private val api: TangemTechApi,
private val dispatchers: CoroutineDispatcherProvider,
private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase,
private val searchText: String?,
private val needFilterExcluded: Boolean = false,
) : PagingSource<Int, Token>() {
private val coinsResponseConverter = CoinsResponseConverter(needFilterExcluded)
private val allAvailableBlockchains by lazy {
Blockchain.entries
.filter {
it.isTestnet().not() && (needFilterExcluded.not() || it.isSupportedInApp())
}
}
override fun getRefreshKey(state: PagingState<Int, Token>): Int? {
return state.anchorPosition?.let { anchorPosition ->
state.closestPageToPosition(anchorPosition)?.prevKey?.plus(other = 1)
?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(other = 1)
}
}
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Token> {
val page = params.key ?: 0
return runCatching(dispatchers.io) {
val supportedBlockchains = getSelectedWalletSyncUseCase().fold(
ifLeft = { allAvailableBlockchains },
ifRight = { it.scanResponse.card.supportedBlockchains(it.scanResponse.cardTypesResolver) },
)
api.getCoins(
networkIds = supportedBlockchains.joinToString(separator = ",", transform = Blockchain::toNetworkId),
active = true,
searchText = searchText,
offset = page * params.loadSize,
limit = params.loadSize,
).getOrThrow()
}.fold(
onSuccess = { response ->
LoadResult.Page(
data = coinsResponseConverter.convert(response),
prevKey = if (page == 0) null else page.minus(other = 1),
nextKey = if (response.coins.isEmpty()) null else page.plus(other = 1),
)
},
onFailure = { LoadResult.Error(it) },
)
}
}

View file

@ -1,43 +0,0 @@
package com.tangem.tap.features.tokens.impl.data
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.tangem.datasource.local.testnet.TestnetTokensStorage
import com.tangem.datasource.local.testnet.models.TestnetTokensConfig
import com.tangem.tap.features.tokens.impl.data.converters.TestnetTokensConfigConverter
import com.tangem.tap.features.tokens.impl.domain.models.Token
/**
* Paging source that get tokens by testnet tokens config
*
* @property testnetTokensStorage testnet tokens config storage
* @property searchText search text
*/
internal class TestnetTokensPagingSource(
private val testnetTokensStorage: TestnetTokensStorage,
private val searchText: String?,
) : PagingSource<Int, Token>() {
override fun getRefreshKey(state: PagingState<Int, Token>): Int? = null
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Token> {
return LoadResult.Page(
data = TestnetTokensConfigConverter.convert(
value = testnetTokensStorage.getConfig().searchByText(searchText),
),
prevKey = null,
nextKey = null,
)
}
private fun TestnetTokensConfig.searchByText(searchText: String?): TestnetTokensConfig {
if (searchText.isNullOrBlank()) return this
return copy(
tokens = tokens.filter { token ->
token.symbol.contains(other = searchText, ignoreCase = true) ||
token.name.contains(other = searchText, ignoreCase = true)
},
)
}
}

View file

@ -1,57 +0,0 @@
package com.tangem.tap.features.tokens.impl.data.converters
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.blockchainsdk.utils.isSupportedInApp
import com.tangem.datasource.api.tangemTech.models.CoinsResponse
import com.tangem.tap.features.tokens.impl.domain.models.Token
import com.tangem.utils.converter.Converter
/**
* Converter from data model [CoinsResponse] to list of domain models [Token]
*
[REDACTED_AUTHOR]
*/
internal class CoinsResponseConverter(val needFilterExcluded: Boolean) : Converter<CoinsResponse, List<Token>> {
override fun convert(value: CoinsResponse): List<Token> {
return value.coins.map { token ->
Token(
id = token.id,
name = token.name,
symbol = token.symbol,
iconUrl = getIconUrl(token.id, value.imageHost),
networks = token.networks.mapNotNull { network ->
val blockchain = Blockchain.fromNetworkId(network.networkId) ?: return@mapNotNull null
if (needFilterExcluded && !blockchain.isSupportedInApp()) {
return@mapNotNull null
}
// filter tokens, if contractAddress != null, assume that it is a token
if (network.contractAddress != null &&
!blockchain.canHandleTokens()
) {
return@mapNotNull null
}
Token.Network(
id = network.networkId,
blockchain = blockchain,
address = network.contractAddress,
iconUrl = getIconUrl(network.networkId, value.imageHost),
decimalCount = network.decimalCount?.toInt(),
)
},
)
}.filter { it.networks.isNotEmpty() }
}
fun getIconUrl(id: String, imageHost: String? = null): String {
return "${imageHost ?: DEFAULT_IMAGE_HOST}large/$id.png"
}
private companion object {
const val DEFAULT_IMAGE_HOST = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/"
}
}

View file

@ -1,38 +0,0 @@
package com.tangem.tap.features.tokens.impl.data.converters
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.datasource.local.testnet.models.TestnetTokensConfig
import com.tangem.tap.features.tokens.impl.domain.models.Token
import com.tangem.utils.converter.Converter
/**
* Converter from data model [TestnetTokensConfig] to list of domain models [Token]
*
[REDACTED_AUTHOR]
*/
internal object TestnetTokensConfigConverter : Converter<TestnetTokensConfig, List<Token>> {
private val coinsResponseConverter = CoinsResponseConverter(needFilterExcluded = false)
override fun convert(value: TestnetTokensConfig): List<Token> {
return value.tokens.map { token ->
Token(
id = token.id,
name = token.name,
symbol = token.symbol,
iconUrl = coinsResponseConverter.getIconUrl(token.id),
networks = token.networks?.mapNotNull { network ->
val blockchain = Blockchain.fromNetworkId(network.id) ?: return@mapNotNull null
Token.Network(
id = network.id,
blockchain = blockchain,
address = network.address,
iconUrl = coinsResponseConverter.getIconUrl(network.id),
decimalCount = network.decimalCount,
)
}.orEmpty(),
)
}
}
}

View file

@ -1,40 +0,0 @@
package com.tangem.tap.features.tokens.impl.di
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.testnet.TestnetTokensStorage
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.tap.features.tokens.impl.data.DefaultTokensListRepository
import com.tangem.tap.features.tokens.impl.domain.DefaultTokensListInteractor
import com.tangem.tap.features.tokens.impl.domain.TokensListInteractor
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ViewModelComponent
import dagger.hilt.android.scopes.ViewModelScoped
/**
[REDACTED_AUTHOR]
*/
@Module
@InstallIn(ViewModelComponent::class)
internal object TokensListInteractorModule {
@Provides
@ViewModelScoped
fun provideTokensListInteractor(
tangemTechApi: TangemTechApi,
dispatchers: CoroutineDispatcherProvider,
getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase,
testnetTokensStorage: TestnetTokensStorage,
): TokensListInteractor {
return DefaultTokensListInteractor(
repository = DefaultTokensListRepository(
tangemTechApi = tangemTechApi,
dispatchers = dispatchers,
getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase,
testnetTokensStorage = testnetTokensStorage,
),
)
}
}

View file

@ -1,37 +0,0 @@
package com.tangem.tap.features.tokens.impl.di
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.testnet.TestnetTokensStorage
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.tap.features.tokens.impl.data.DefaultTokensListRepository
import com.tangem.tap.features.tokens.impl.domain.TokensListRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
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 TokensListRepositoryModule {
@Provides
@Singleton
fun providesTokensListRepository(
tangemTechApi: TangemTechApi,
dispatchers: CoroutineDispatcherProvider,
getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase,
testnetTokensStorage: TestnetTokensStorage,
): TokensListRepository {
return DefaultTokensListRepository(
tangemTechApi = tangemTechApi,
dispatchers = dispatchers,
getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase,
testnetTokensStorage = testnetTokensStorage,
)
}
}

View file

@ -1,21 +0,0 @@
package com.tangem.tap.features.tokens.impl.di
import com.tangem.tap.features.tokens.impl.presentation.router.DefaultTokensListRouter
import com.tangem.tap.features.tokens.impl.presentation.router.TokensListRouter
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 TokensListRouterModule {
@Provides
@ViewModelScoped
fun provideTokensListRouter(): TokensListRouter = DefaultTokensListRouter()
}

View file

@ -1,20 +0,0 @@
package com.tangem.tap.features.tokens.impl.domain
import androidx.paging.PagingData
import com.tangem.tap.features.tokens.impl.domain.models.Token
import kotlinx.coroutines.flow.Flow
/**
* Default implementation of tokens list interactor
*
* @property repository repository of tokens list feature
*/
internal class DefaultTokensListInteractor(private val repository: TokensListRepository) : TokensListInteractor {
override fun getTokensList(searchText: String, needFilterExcluded: Boolean): Flow<PagingData<Token>> {
return repository.getAvailableTokens(
searchText = searchText.ifBlank(defaultValue = { null }),
needFilterExcluded = needFilterExcluded,
)
}
}

View file

@ -1,16 +0,0 @@
package com.tangem.tap.features.tokens.impl.domain
import androidx.paging.PagingData
import com.tangem.tap.features.tokens.impl.domain.models.Token
import kotlinx.coroutines.flow.Flow
/**
* Interactor of tokens list feature
*
[REDACTED_AUTHOR]
*/
internal interface TokensListInteractor {
/** Get tokens list using filter by text [searchText] */
fun getTokensList(searchText: String, needFilterExcluded: Boolean): Flow<PagingData<Token>>
}

View file

@ -1,23 +0,0 @@
package com.tangem.tap.features.tokens.impl.domain
import androidx.paging.PagingData
import com.tangem.tap.features.tokens.impl.domain.models.Token
import kotlinx.coroutines.flow.Flow
/**
* Repository of tokens list feature
*
[REDACTED_AUTHOR]
*/
internal interface TokensListRepository {
/**
* Get available tokens list
*
* @param searchText search text
* @param needFilterExcluded flag that determines if excluded tokens should be filtered
*
* @throws com.tangem.datasource.api.common.response.ApiResponseError
*/
fun getAvailableTokens(searchText: String?, needFilterExcluded: Boolean): Flow<PagingData<Token>>
}

View file

@ -1,42 +0,0 @@
package com.tangem.tap.features.tokens.impl.domain.models
import com.tangem.blockchain.common.Blockchain
/**
* Domain model of token for tokens list screen
*
* @property id token id
* @property name token name
* @property symbol token brief name. Example, "BTC"
* @property iconUrl token icon url
* @property networks token networks
*
[REDACTED_AUTHOR]
*/
internal data class Token(
val id: String,
val name: String,
val symbol: String,
val iconUrl: String,
val networks: List<Network>,
) {
/**
* Domain model of network for tokens list screen
*
* @property id network id
* @property blockchain blockchain
* @property address address. If address equals null, it means it is the main network of token
* @property iconUrl network icon url
* @property decimalCount decimal count
*
[REDACTED_AUTHOR]
*/
data class Network(
val id: String,
val blockchain: Blockchain,
val address: String?,
val iconUrl: String,
val decimalCount: Int?,
)
}

View file

@ -1,34 +0,0 @@
package com.tangem.tap.features.tokens.impl.presentation
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.hilt.navigation.compose.hiltViewModel
import com.tangem.core.ui.UiDependencies
import com.tangem.core.ui.screen.ComposeFragment
import com.tangem.tap.features.tokens.impl.presentation.ui.TokensListScreen
import com.tangem.tap.features.tokens.impl.presentation.viewmodels.TokensListViewModel
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
/**
* Fragment with list of tokens
*
[REDACTED_AUTHOR]
*/
@AndroidEntryPoint
internal class TokensListFragment : ComposeFragment() {
@Inject
override lateinit var uiDependencies: UiDependencies
@Composable
override fun ScreenContent(modifier: Modifier) {
val viewModel = hiltViewModel<TokensListViewModel>().apply {
LocalLifecycleOwner.current.lifecycle.addObserver(this)
}
TokensListScreen(
stateHolder = viewModel.uiState,
)
}
}

View file

@ -1,11 +0,0 @@
package com.tangem.tap.features.tokens.impl.presentation.models
/**
* State that shows is token support by given card
*/
sealed class SupportTokensState {
object NetworkTokensUnsupported : SupportTokensState()
object UnsupportedCurve : SupportTokensState()
object SupportedToken : SupportTokensState()
}

View file

@ -1,77 +0,0 @@
package com.tangem.tap.features.tokens.impl.presentation.router
import com.tangem.blockchain.common.Blockchain
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.tap.common.extensions.dispatchDialogShow
import com.tangem.tap.common.extensions.dispatchNavigationAction
import com.tangem.tap.common.extensions.dispatchNotification
import com.tangem.tap.common.redux.AppDialog
import com.tangem.tap.store
import com.tangem.wallet.R
/**
* Default implementation of tokens list router
* FIXME("Necessary to avoid using redux actions")
*
[REDACTED_AUTHOR]
*/
internal class DefaultTokensListRouter : TokensListRouter {
override fun popBackStack() {
store.dispatchNavigationAction(AppRouter::pop)
}
override fun openAddCustomTokenScreen() {
store.dispatchNavigationAction { push(AppRoute.AddCustomToken) }
}
override fun showAddressCopiedNotification() {
store.dispatchNotification(R.string.contract_address_copied_message)
}
override fun openUnableHideMainTokenAlert(tokenName: String, tokenSymbol: String, networkName: String) {
store.dispatchDialogShow(
dialog = AppDialog.TokensAreLinkedDialog(
currencyTitle = tokenName,
currencySymbol = tokenSymbol,
networkName = networkName,
),
)
}
override fun openRemoveWalletAlert(tokenName: String, onOkClick: () -> Unit) {
store.dispatchDialogShow(
dialog = AppDialog.RemoveWalletDialog(currencyTitle = tokenName, onOk = onOkClick),
)
}
override fun openUnsupportedNetworkAlert(blockchain: Blockchain) {
val alert = AppDialog.SimpleOkDialogRes(
headerId = R.string.common_warning,
messageId = R.string.alert_manage_tokens_unsupported_curve_message,
args = listOf(blockchain.getNetworkName()),
)
store.dispatchDialogShow(alert)
}
override fun showGenericErrorAlertAndPopBack() {
val alert = AppDialog.SimpleOkDialogRes(
headerId = R.string.common_error,
messageId = R.string.common_unknown_error,
onOk = { store.dispatchNavigationAction(AppRouter::pop) },
)
store.dispatchDialogShow(alert)
}
override fun openNetworkTokensNotSupportAlert(networkName: String) {
store.dispatchDialogShow(
AppDialog.SimpleOkDialogRes(
headerId = R.string.common_warning,
messageId = R.string.alert_manage_tokens_unsupported_message,
args = listOf(networkName),
),
)
}
}

View file

@ -1,46 +0,0 @@
package com.tangem.tap.features.tokens.impl.presentation.router
import com.tangem.blockchain.common.Blockchain
/** Tokens list feature router */
internal interface TokensListRouter {
/** Return to last screen */
fun popBackStack()
/** Open adding custom token screen */
fun openAddCustomTokenScreen()
/** Show notification to inform about copied address */
fun showAddressCopiedNotification()
/**
* Open alert if unable to hide the main token
*
* @param tokenName token name
* @param tokenSymbol token brief name
* @param networkName blockchain network full name
*/
fun openUnableHideMainTokenAlert(tokenName: String, tokenSymbol: String, networkName: String)
/**
* Open alert to remove wallet
*
* @param tokenName token name
* @param onOkClick lambda be invoked if OkButton is been clicked
*/
fun openRemoveWalletAlert(tokenName: String, onOkClick: () -> Unit)
/** Open alert if solana network is unsupported
*
* @param blockchain blockchain to show alert
*/
fun openUnsupportedNetworkAlert(blockchain: Blockchain)
fun showGenericErrorAlertAndPopBack()
/**
* Open alert with unsupported networks tokens error
*/
fun openNetworkTokensNotSupportAlert(networkName: String)
}

View file

@ -1,82 +0,0 @@
package com.tangem.tap.features.tokens.impl.presentation.states
import androidx.compose.runtime.MutableState
import com.tangem.blockchain.common.Blockchain
import com.tangem.core.ui.extensions.getActiveIconRes
import com.tangem.core.ui.extensions.getGreyedOutIconRes
/**
* Network item state
*
[REDACTED_AUTHOR]
*/
sealed interface NetworkItemState {
/** Network name */
val name: String
/** Network protocol name */
val protocolName: String
/** Network icon id from resources */
val iconResId: MutableState<Int>
/** Flag that determines if the network is the main network for the token */
val isMainNetwork: Boolean
/**
* Network item state that is available for read
*
* @property name network name
* @property protocolName network protocol name
* @property iconResId network icon id from resources
* @property isMainNetwork flag that determines if the network is the main network for the token
*/
data class ReadContent(
override val name: String,
override val protocolName: String,
override val iconResId: MutableState<Int>,
override val isMainNetwork: Boolean,
) : NetworkItemState
/**
* Network item state that is available for read and edit
*
* @property name network name
* @property protocolName network protocol name
* @property iconResId network icon id from resources
* @property isMainNetwork flag that determines if the network is the main network for the token
* @property isAdded flag that determines if the user has saved the token
* @property id network id
* @property address contract address
* @property decimalCount decimal count
* @property blockchain blockchain
* @property onToggleClick lambda be invoked when switch is been toggled
* @property onNetworkClick lambda be invoked when network item is been clicked
*/
data class ManageContent(
override val name: String,
override val protocolName: String,
override val iconResId: MutableState<Int>,
override val isMainNetwork: Boolean,
val isAdded: MutableState<Boolean>,
val id: String,
val address: String?,
val decimalCount: Int?,
val blockchain: Blockchain,
val onToggleClick: (TokenItemState.ManageContent, ManageContent) -> Unit,
val onNetworkClick: () -> Unit,
) : NetworkItemState {
/**
* Change toggle state [isAdded].
*
* It is a hack that helps us to change element of flow
*/
fun changeToggleState() {
val reverseState = !isAdded.value
isAdded.value = reverseState
iconResId.value = if (reverseState) getActiveIconRes(blockchain.id) else getGreyedOutIconRes(blockchain.id)
}
}
}

View file

@ -1,68 +0,0 @@
package com.tangem.tap.features.tokens.impl.presentation.states
import androidx.compose.runtime.Immutable
import kotlinx.collections.immutable.ImmutableList
/**
* Token item state.
* All subclasses is stable, but @Immutable annotation is required to use this sealed class like as
* field of TokensListStateHolder.
*
[REDACTED_AUTHOR]
*/
@Immutable
sealed interface TokenItemState {
/** Token id */
val id: String
/** Token full name (name with symbol) */
val fullName: String
/** Token icon url */
val iconUrl: String
/** List of networks */
val networks: ImmutableList<NetworkItemState>
/** Token composed id that unique for tokens in different networks */
val composedId: String
/**
* Token item state that is available for read
*
* @property id token id
* @property fullName token name
* @property iconUrl token icon url
* @property networks list of networks that is available for read
* @property composedId token composed id to use in lists
*/
data class ReadContent(
override val id: String,
override val fullName: String,
override val iconUrl: String,
override val networks: ImmutableList<NetworkItemState.ReadContent>,
override val composedId: String,
) : TokenItemState
/**
* Token item state that is available for read and manage
*
* @property id token id
* @property fullName token name
* @property iconUrl token icon url
* @property networks list of networks is available for read and edit
* @property composedId token composed id to use in lists
* @property name token name
* @property symbol token brief name
*/
data class ManageContent(
override val id: String,
override val fullName: String,
override val iconUrl: String,
override val networks: ImmutableList<NetworkItemState.ManageContent>,
override val composedId: String,
val name: String,
val symbol: String,
) : TokenItemState
}

View file

@ -1,101 +0,0 @@
package com.tangem.tap.features.tokens.impl.presentation.states
import androidx.compose.runtime.Immutable
import androidx.paging.LoadState
import androidx.paging.PagingData
import kotlinx.coroutines.flow.Flow
/**
* State holder for screen with list of tokens
*
[REDACTED_AUTHOR]
*/
@Immutable
internal sealed interface TokensListStateHolder {
/** Toolbar state */
val toolbarState: TokensListToolbarState
/** Loading state */
val isLoading: Boolean
/** Flag that determines if warning block is visible */
val isDifferentAddressesBlockVisible: Boolean
/** Tokens list */
val tokens: Flow<PagingData<TokenItemState>>
/** Callback to be invoked when [tokens] loading state is been changed */
val onTokensLoadStateChanged: (LoadState) -> Unit
/**
* Util function that allow to make a copy
*
* @param toolbarState toolbar state
* @param isLoading loading state
* @param isDifferentAddressesBlockVisible flag that determines if warning block is visible
* @param tokens tokens list
* @param onTokensLoadStateChanged callback to be invoked when tokens loading state is been changed
*/
fun copySealed(
toolbarState: TokensListToolbarState = this.toolbarState,
isLoading: Boolean = this.isLoading,
isDifferentAddressesBlockVisible: Boolean = this.isDifferentAddressesBlockVisible,
tokens: Flow<PagingData<TokenItemState>> = this.tokens,
onTokensLoadStateChanged: (LoadState) -> Unit = this.onTokensLoadStateChanged,
): TokensListStateHolder {
return when (this) {
is ManageContent -> copy(
toolbarState,
isLoading,
isDifferentAddressesBlockVisible,
tokens,
onTokensLoadStateChanged,
)
is ReadContent -> copy(
toolbarState,
isLoading,
isDifferentAddressesBlockVisible,
tokens,
onTokensLoadStateChanged,
)
}
}
/**
* State screen that is available only for read
*
* @property toolbarState toolbar state
* @property isLoading loading state
* @property isDifferentAddressesBlockVisible flag that determines if warning block is visible
* @property tokens tokens list
* @property onTokensLoadStateChanged callback to be invoked when tokens loading state is been changed
*/
data class ReadContent(
override val toolbarState: TokensListToolbarState,
override val isLoading: Boolean,
override val isDifferentAddressesBlockVisible: Boolean,
override val tokens: Flow<PagingData<TokenItemState>>,
override val onTokensLoadStateChanged: (LoadState) -> Unit,
) : TokensListStateHolder
/**
* State screen that is available for read and manage
*
* @property toolbarState toolbar state
* @property isLoading loading state
* @property isDifferentAddressesBlockVisible flag that determines if warning block is visible
* @property tokens tokens list
* @property onTokensLoadStateChanged callback to be invoked when tokens loading state is been changed
* @property onSaveButtonClick callback to be invoked when SaveButton is being clicked
*/
data class ManageContent(
override val toolbarState: TokensListToolbarState,
override val isLoading: Boolean,
override val isDifferentAddressesBlockVisible: Boolean,
override val tokens: Flow<PagingData<TokenItemState>>,
override val onTokensLoadStateChanged: (LoadState) -> Unit,
val onSaveButtonClick: () -> Unit,
val isSavingInProgress: Boolean,
) : TokensListStateHolder
}

View file

@ -1,68 +0,0 @@
package com.tangem.tap.features.tokens.impl.presentation.states
import androidx.compose.runtime.Immutable
/**
* Toolbar state.
* All subclasses is stable, but @Immutable annotation is required to use this sealed class like as
* field of TokensListStateHolder.
*/
@Immutable
sealed interface TokensListToolbarState {
/** Callback to be invoked when BackButton is being clicked */
val onBackButtonClick: () -> Unit
/** Toolbar state as title */
sealed interface Title : TokensListToolbarState {
/** Toolbar title id from resources */
val titleResId: Int
/** Callback to be invoked when SearchButton is being clicked */
val onSearchButtonClick: () -> Unit
/**
* Title state that is available only for read
*
* @property onBackButtonClick callback to be invoked when BackButton is being clicked
* @property titleResId toolbar title id from resources
* @property onSearchButtonClick callback to be invoked when SearchButton is being clicked
*/
data class Read(
override val onBackButtonClick: () -> Unit,
override val titleResId: Int,
override val onSearchButtonClick: () -> Unit,
) : Title
/**
* Title state that is available for read and manage
*
* @property onBackButtonClick callback to be invoked when BackButton is being clicked
* @property titleResId toolbar title id from resources
* @property onSearchButtonClick callback to be invoked when SearchButton is being clicked
* @property onAddCustomTokenClick callback to be invoked when AddCustomTokenButton is being clicked
*/
data class Manage(
override val onBackButtonClick: () -> Unit,
override val titleResId: Int,
override val onSearchButtonClick: () -> Unit,
val onAddCustomTokenClick: () -> Unit,
) : Title
}
/**
* Toolbar state as input field
*
* @property onBackButtonClick callback to be invoked when BackButton is being clicked
* @property value input value
* @property onValueChange lambda to be invoked when search value is being changed
* @property onCleanButtonClick callback to be invoked when CleanButton is being clicked
*/
data class InputField(
override val onBackButtonClick: () -> Unit,
val value: String,
val onValueChange: (String) -> Unit,
val onCleanButtonClick: () -> Unit,
) : TokensListToolbarState
}

View file

@ -1,144 +0,0 @@
package com.tangem.tap.features.tokens.impl.presentation.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Icon
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.TextUnitType
import com.tangem.core.ui.res.TangemTheme
import com.tangem.tap.features.tokens.impl.presentation.states.NetworkItemState
import kotlinx.collections.immutable.ImmutableCollection
/** This const configures how many items show to user and hide more than*/
private const val MAX_VISIBLE_BRIEF_ICONS = 9
/**
[REDACTED_AUTHOR]
*/
@Composable
internal fun BriefNetworksList(
isCollapsed: Boolean,
networks: ImmutableCollection<NetworkItemState>,
modifier: Modifier = Modifier,
) {
AnimatedVisibility(
visible = isCollapsed,
modifier = modifier,
enter = fadeIn(),
exit = fadeOut(),
) {
Row(horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4)) {
for ((index, network) in networks.withIndex()) {
if (index < MAX_VISIBLE_BRIEF_ICONS) {
key(network.name + network.protocolName) {
BriefNetworkItem(model = network)
}
} else {
if (networks.size < MAX_VISIBLE_BRIEF_ICONS + 1) {
key(network.name + network.protocolName) {
BriefNetworkItem(model = network)
}
} else {
HasMoreItem(moreCount = networks.size - index)
break
}
}
}
}
}
}
/**
[REDACTED_AUTHOR]
*/
@Composable
internal fun BriefNetworkItem(model: NetworkItemState, modifier: Modifier = Modifier) {
val isAdded = model is NetworkItemState.ManageContent && model.isAdded.value
Box(modifier = modifier.size(size = TangemTheme.dimens.size20)) {
if (!isAdded) {
Box(
modifier = Modifier
.size(TangemTheme.dimens.size20)
.clip(CircleShape)
.background(TangemTheme.colors.control.unchecked),
)
}
Icon(
painter = painterResource(id = model.iconResId.value),
contentDescription = null,
modifier = Modifier
.size(size = TangemTheme.dimens.size20)
.clip(CircleShape),
tint = if (isAdded) Color.Unspecified else TangemTheme.colors.text.tertiary,
)
if (model.isMainNetwork) {
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.size(TangemTheme.dimens.size7)
.clip(CircleShape)
.background(TangemTheme.colors.background.primary),
contentAlignment = Alignment.Center,
) {
Box(
modifier = Modifier
.size(TangemTheme.dimens.size5)
.clip(CircleShape)
.background(TangemTheme.colors.icon.accent),
)
}
}
}
}
@Suppress("MagicNumber")
@Composable
internal fun HasMoreItem(moreCount: Int) {
val count = if (moreCount > 99) 99 else moreCount
val themeTextStyle = TangemTheme.typography.overline.copy(
letterSpacing = TextUnit(value = 0f, type = TextUnitType.Sp),
)
var textStyle by remember(themeTextStyle) { mutableStateOf(themeTextStyle) }
var readyToDraw by remember(themeTextStyle) { mutableStateOf(false) }
Box(
modifier = Modifier
.size(size = TangemTheme.dimens.size20)
.clip(CircleShape)
.background(TangemTheme.colors.control.unchecked),
) {
Text(
modifier = Modifier
.align(Alignment.Center)
.drawWithContent { if (readyToDraw) drawContent() },
text = "+$count",
style = textStyle,
color = TangemTheme.colors.text.tertiary,
overflow = TextOverflow.Clip,
onTextLayout = { textLayoutResult ->
if (textLayoutResult.hasVisualOverflow) {
textStyle = textStyle.copy(fontSize = textStyle.fontSize * 0.9)
} else {
readyToDraw = true
}
},
)
}
}

View file

@ -1,163 +0,0 @@
package com.tangem.tap.features.tokens.impl.presentation.ui
import android.content.res.Configuration
import androidx.compose.animation.*
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.material.Switch
import androidx.compose.material.SwitchDefaults
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.sp
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.tap.features.tokens.impl.presentation.states.NetworkItemState
import com.tangem.tap.features.tokens.impl.presentation.states.TokenItemState
import kotlinx.collections.immutable.ImmutableCollection
/**
[REDACTED_AUTHOR]
*/
@Composable
internal fun DetailedNetworksList(
isExpanded: Boolean,
token: TokenItemState,
networks: ImmutableCollection<NetworkItemState>,
modifier: Modifier = Modifier,
) {
AnimatedVisibility(
visible = isExpanded,
modifier = modifier,
enter = fadeIn() + expandVertically(),
exit = shrinkVertically() + fadeOut(),
) {
Column {
networks.forEachIndexed { index, network ->
key(network.name + network.protocolName) {
DetailedNetworkItem(token = token, network = network, isLastItem = networks.size - 1 == index)
}
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun DetailedNetworkItem(token: TokenItemState, network: NetworkItemState, isLastItem: Boolean) {
val clipboardManager = LocalClipboardManager.current
val itemHeight = TangemTheme.dimens.size50
Row(
modifier = Modifier
.combinedClickable(
enabled = network is NetworkItemState.ManageContent,
onLongClick = {
if (network is NetworkItemState.ManageContent && network.address != null) {
clipboardManager.setText(AnnotatedString(text = network.address))
network.onNetworkClick()
}
},
onClick = {},
indication = null,
interactionSource = remember { MutableInteractionSource() },
)
.fillMaxWidth()
.heightIn(min = itemHeight)
.padding(start = TangemTheme.dimens.spacing38),
verticalAlignment = Alignment.CenterVertically,
) {
NetworkItemArrow(itemHeight = itemHeight, isLastItem = isLastItem)
Spacer(modifier = Modifier.width(TangemTheme.dimens.spacing16))
BriefNetworkItem(model = network)
Spacer(modifier = Modifier.width(TangemTheme.dimens.spacing6))
NetworkTitle(model = network)
if (network is NetworkItemState.ManageContent) {
Switch(
checked = network.isAdded.value,
onCheckedChange = {
network.onToggleClick(
requireNotNull(token as? TokenItemState.ManageContent),
network,
)
},
modifier = Modifier.padding(start = TangemTheme.dimens.spacing16, end = TangemTheme.dimens.spacing8),
colors = SwitchDefaults.colors(
checkedThumbColor = TangemTheme.colors.control.key,
checkedTrackColor = TangemTheme.colors.icon.accent,
uncheckedThumbColor = TangemTheme.colors.control.key,
uncheckedTrackColor = TangemTheme.colors.icon.informative,
),
)
}
}
}
@Composable
private fun RowScope.NetworkTitle(model: NetworkItemState) {
Text(
modifier = Modifier.weight(1f),
text = buildAnnotatedString {
append(text = model.name)
append(text = " ")
withStyle(
style = SpanStyle(
fontWeight = FontWeight.Normal,
color = if (model.isMainNetwork) {
TangemTheme.colors.icon.accent
} else {
TangemTheme.colors.icon.secondary
},
),
) {
append(text = model.protocolName)
}
},
fontWeight = FontWeight.SemiBold,
fontSize = 13.sp,
color = if (model is NetworkItemState.ManageContent && model.isAdded.value) {
TangemTheme.colors.text.primary1
} else {
TangemTheme.colors.text.secondary
},
)
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_DetailedNetworksList_ManageAccess() {
TangemThemePreview {
DetailedNetworksList(
isExpanded = true,
token = TokenListPreviewData.createManageToken(),
networks = TokenListPreviewData.createManageNetworksList(),
)
}
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_DetailedNetworksList_ReadAccess() {
TangemThemePreview {
DetailedNetworksList(
isExpanded = true,
token = TokenListPreviewData.createReadToken(),
networks = TokenListPreviewData.createReadNetworksList(),
)
}
}

View file

@ -1,58 +0,0 @@
package com.tangem.tap.features.tokens.impl.presentation.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.res.TangemTheme
import com.tangem.wallet.R
private const val SPECIAL_HEIGHT_3_5 = 3.5
/**
[REDACTED_AUTHOR]
*/
@Composable
internal fun NetworkItemArrow(itemHeight: Dp, isLastItem: Boolean) {
Box(modifier = Modifier.height(height = itemHeight)) {
if (!isLastItem) {
Box(
modifier = Modifier
.padding(start = TangemTheme.dimens.spacing0_5)
.background(color = TangemTheme.colors.stroke.primary)
.size(width = TangemTheme.dimens.size1, height = itemHeight),
)
}
Icon(
painter = painterResource(id = R.drawable.ic_link),
contentDescription = null,
modifier = Modifier.height(height = itemHeight / 2 + SPECIAL_HEIGHT_3_5.dp),
tint = TangemTheme.colors.stroke.primary,
)
}
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_NetworkItemArrow_Column() {
TangemThemePreview {
Column(
modifier = Modifier
.fillMaxWidth()
.background(color = TangemTheme.colors.background.primary)
.padding(start = TangemTheme.dimens.size36),
) {
NetworkItemArrow(itemHeight = TangemTheme.dimens.size62, isLastItem = false)
NetworkItemArrow(itemHeight = TangemTheme.dimens.size62, isLastItem = true)
}
}
}

View file

@ -1,236 +0,0 @@
package com.tangem.tap.features.tokens.impl.presentation.ui
import android.content.res.Configuration
import androidx.compose.animation.*
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.constraintlayout.compose.ConstraintLayout
import androidx.constraintlayout.compose.Dimension
import coil.compose.SubcomposeAsyncImage
import coil.request.ImageRequest
import com.tangem.core.ui.components.CurrencyPlaceholderIcon
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.utils.ImageBackgroundContrastChecker
import com.tangem.tap.features.tokens.impl.presentation.states.TokenItemState
import com.tangem.wallet.R
import kotlinx.coroutines.launch
/**
[REDACTED_AUTHOR]
*/
@Suppress("LongMethod")
@Composable
internal fun TokenItem(model: TokenItemState) {
var isExpanded by rememberSaveable { mutableStateOf(value = false) }
var iconBackgroundColor by remember { mutableStateOf(Color.Transparent) }
ConstraintLayout(
modifier = Modifier
.fillMaxWidth()
.background(color = TangemTheme.colors.background.primary)
.padding(top = TangemTheme.dimens.spacing16),
) {
val (icon, title, availableNetworksText) = createRefs()
val (briefNetworksList, detailedNetworksList, changeNetworksViewButton) = createRefs()
val spacing16 = TangemTheme.dimens.spacing16
val spacing6 = TangemTheme.dimens.spacing6
Box(
modifier = Modifier
.background(
color = iconBackgroundColor,
shape = TangemTheme.shapes.roundedCorners8,
)
.size(TangemTheme.dimens.size46)
.constrainAs(icon) {
top.linkTo(parent.top)
start.linkTo(anchor = parent.start, margin = spacing16)
},
) {
Icon(
name = model.fullName,
iconUrl = model.iconUrl,
onContrastCalculate = { iconBackgroundColor = it },
)
}
Title(
title = model.fullName,
modifier = Modifier.constrainAs(title) {
top.linkTo(parent.top)
start.linkTo(icon.end, margin = spacing16)
end.linkTo(anchor = changeNetworksViewButton.start, margin = spacing16)
width = Dimension.fillToConstraints
},
)
Subtitle(
isExpanded = isExpanded,
modifier = Modifier.constrainAs(availableNetworksText) {
top.linkTo(title.bottom, margin = spacing6)
start.linkTo(icon.end, margin = spacing16)
end.linkTo(anchor = changeNetworksViewButton.start, margin = spacing16)
width = Dimension.fillToConstraints
},
)
BriefNetworksList(
isCollapsed = !isExpanded,
networks = model.networks,
modifier = Modifier.constrainAs(briefNetworksList) {
top.linkTo(title.bottom, margin = spacing6)
start.linkTo(icon.end, margin = spacing16)
end.linkTo(anchor = changeNetworksViewButton.start, margin = spacing16)
width = Dimension.fillToConstraints
},
)
DetailedNetworksList(
isExpanded = isExpanded,
token = model,
networks = model.networks,
modifier = Modifier.constrainAs(detailedNetworksList) {
top.linkTo(anchor = availableNetworksText.bottom, margin = spacing16)
centerHorizontallyTo(parent)
width = Dimension.fillToConstraints
},
)
ChangeNetworksViewButton(
isExpanded = isExpanded,
onClick = { isExpanded = !isExpanded },
modifier = Modifier.constrainAs(changeNetworksViewButton) {
top.linkTo(parent.top)
end.linkTo(anchor = parent.end, margin = spacing6)
if (!isExpanded) bottom.linkTo(parent.bottom)
},
)
}
}
@Composable
private fun Icon(name: String, iconUrl: String, onContrastCalculate: (Color) -> Unit, modifier: Modifier = Modifier) {
val pixelsSize = with(LocalDensity.current) { TangemTheme.dimens.size46.roundToPx() }
val iconModifier = modifier
.size(size = TangemTheme.dimens.size46)
.clip(TangemTheme.shapes.roundedCorners8)
var iconBackgroundColor by remember { mutableStateOf(Color.Transparent) }
var isBackgroundColorDefined by remember { mutableStateOf(false) }
val itemBackgroundColor = TangemTheme.colors.background.primary.toArgb()
val isDarkTheme = isSystemInDarkTheme()
val coroutineScope = rememberCoroutineScope()
SubcomposeAsyncImage(
modifier = iconModifier,
model = ImageRequest.Builder(context = LocalContext.current)
.data(data = iconUrl)
.size(size = pixelsSize)
.memoryCacheKey(key = iconUrl + pixelsSize)
.crossfade(enable = true)
.allowHardware(false)
.listener(
onSuccess = { _, result ->
if (!isBackgroundColorDefined && isDarkTheme) {
coroutineScope.launch {
val color = ImageBackgroundContrastChecker(
drawable = result.drawable,
backgroundColor = itemBackgroundColor,
size = pixelsSize,
).getContrastColor(isDarkTheme = true)
onContrastCalculate(color)
iconBackgroundColor = color
isBackgroundColorDefined = true
}
}
},
)
.build(),
contentDescription = null,
loading = { CurrencyPlaceholderIcon(id = name, modifier = iconModifier) },
error = { CurrencyPlaceholderIcon(id = name, modifier = iconModifier) },
)
}
@Composable
private fun Title(title: String, modifier: Modifier = Modifier) {
Text(
text = title,
modifier = modifier,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Start,
maxLines = 1,
style = TangemTheme.typography.body1,
)
}
@Composable
private fun Subtitle(isExpanded: Boolean, modifier: Modifier = Modifier) {
AnimatedVisibility(
visible = isExpanded,
modifier = modifier,
enter = fadeIn() + expandVertically(),
exit = shrinkVertically() + fadeOut(),
) {
Text(
text = stringResource(id = R.string.currency_subtitle_expanded),
color = TangemTheme.colors.text.secondary,
maxLines = 1,
style = TangemTheme.typography.body2,
)
}
}
@Composable
private fun ChangeNetworksViewButton(isExpanded: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
IconButton(onClick = onClick, modifier = modifier.size(size = TangemTheme.dimens.size46)) {
AnimatedContent(
targetState = isExpanded,
transitionSpec = { (fadeIn() + scaleIn()).togetherWith(scaleOut() + fadeOut()) },
) { isExpanded ->
Icon(
painter = painterResource(
id = if (isExpanded) R.drawable.ic_arrow_extended else R.drawable.ic_arrow_collapsed,
),
contentDescription = null,
tint = TangemTheme.colors.icon.secondary,
)
}
}
}
@Preview(showBackground = true)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_TokenItem_ManageAccess() {
TangemThemePreview {
TokenItem(model = TokenListPreviewData.createManageToken())
}
}
@Preview(showBackground = true)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_TokenItem_ReadAccess() {
TangemThemePreview {
TokenItem(model = TokenListPreviewData.createReadToken())
}
}

View file

@ -1,92 +0,0 @@
package com.tangem.tap.features.tokens.impl.presentation.ui
import androidx.compose.runtime.mutableStateOf
import com.tangem.blockchain.common.Blockchain
import com.tangem.tap.features.tokens.impl.presentation.states.NetworkItemState
import com.tangem.tap.features.tokens.impl.presentation.states.TokenItemState
import com.tangem.wallet.R
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
/**
[REDACTED_AUTHOR]
*/
object TokenListPreviewData {
fun createManageToken(): TokenItemState.ManageContent {
return TokenItemState.ManageContent(
fullName = "Tether (USDT)",
iconUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/large/tether.png",
networks = createManageNetworksList(),
composedId = "11231",
id = "",
name = "Tether",
symbol = "",
)
}
fun createReadToken(): TokenItemState.ReadContent {
return TokenItemState.ReadContent(
id = "1",
fullName = "Tether (USDT)",
iconUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/large/tether.png",
networks = createReadNetworksList(),
composedId = "11231",
)
}
fun createManageNetworksList(): ImmutableList<NetworkItemState.ManageContent> {
return persistentListOf(
NetworkItemState.ManageContent(
name = "Ethereum",
protocolName = "MAIN",
iconResId = mutableStateOf(R.drawable.ic_eth_16),
isMainNetwork = true,
isAdded = mutableStateOf(true),
id = "",
address = null,
onToggleClick = { _, _ -> },
onNetworkClick = {},
decimalCount = null,
blockchain = Blockchain.Ethereum,
),
NetworkItemState.ManageContent(
name = "BNB SMART CHAIN",
protocolName = "BEP20",
iconResId = mutableStateOf(R.drawable.ic_bsc_16),
isMainNetwork = false,
isAdded = mutableStateOf(false),
id = "",
address = null,
onToggleClick = { _, _ -> },
onNetworkClick = {},
decimalCount = null,
blockchain = Blockchain.BSC,
),
)
}
fun createReadNetworksList(): ImmutableList<NetworkItemState.ReadContent> {
return persistentListOf(
NetworkItemState.ReadContent(
name = "Ethereum",
protocolName = "MAIN",
iconResId = mutableStateOf(R.drawable.ic_eth_16),
isMainNetwork = true,
),
NetworkItemState.ReadContent(
name = "BNB SMART CHAIN",
protocolName = "BEP20",
iconResId = mutableStateOf(R.drawable.ic_bsc_16),
isMainNetwork = false,
),
// check icon clipping
NetworkItemState.ReadContent(
name = "SHIBARIUM",
protocolName = "BEP20",
iconResId = mutableStateOf(R.drawable.ic_shibarium_22),
isMainNetwork = false,
),
)
}
}

View file

@ -1,271 +0,0 @@
package com.tangem.tap.features.tokens.impl.presentation.ui
import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.FabPosition
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
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.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
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
import androidx.paging.compose.itemContentType
import androidx.paging.compose.itemKey
import com.tangem.core.ui.components.BottomFade
import com.tangem.core.ui.components.NavigationBar3ButtonsScrim
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.res.TangemTheme
import com.tangem.tap.features.tokens.impl.presentation.states.TokenItemState
import com.tangem.tap.features.tokens.impl.presentation.states.TokensListStateHolder
import com.tangem.tap.features.tokens.impl.presentation.states.TokensListToolbarState
import com.tangem.wallet.R
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOf
/**
* Tokens list screen
*
* @param stateHolder state holder
*
[REDACTED_AUTHOR]
*/
@Composable
internal fun TokensListScreen(stateHolder: TokensListStateHolder, modifier: Modifier = Modifier) {
BackHandler(onBack = stateHolder.toolbarState.onBackButtonClick)
var floatingButtonHeight by remember { mutableStateOf(value = 0.dp) }
Scaffold(
modifier = modifier,
topBar = {
TokensListToolbar(state = stateHolder.toolbarState)
},
floatingActionButton = {
if (stateHolder is TokensListStateHolder.ManageContent) {
val density = LocalDensity.current
val verticalPadding = TangemTheme.dimens.spacing32
SaveChangesButton(
modifier = Modifier
.navigationBarsPadding()
.onSizeChanged {
with(density) { floatingButtonHeight = it.height.toDp() + verticalPadding }
},
showProgress = stateHolder.isSavingInProgress,
onClick = stateHolder.onSaveButtonClick,
)
}
},
floatingActionButtonPosition = FabPosition.Center,
backgroundColor = TangemTheme.colors.background.secondary,
) { _ ->
val tokens = stateHolder.tokens.collectAsLazyPagingItems()
if (stateHolder !is TokensListStateHolder.ManageContent) {
NavigationBar3ButtonsScrim()
}
Box {
TokensListContent(
isDifferentAddressesBlockVisible = stateHolder.isDifferentAddressesBlockVisible,
tokens = tokens,
bottomMarginDp = floatingButtonHeight,
)
if (stateHolder is TokensListStateHolder.ManageContent) {
BottomFade(Modifier.align(Alignment.BottomCenter))
}
}
Crossfade(targetState = stateHolder.isLoading, label = "Update progress bar visibility") {
if (it) {
LoadingContent()
}
}
LaunchedEffect(key1 = tokens.loadState.refresh) {
stateHolder.onTokensLoadStateChanged(tokens.loadState.refresh)
}
}
}
@Composable
private fun LoadingContent() {
Box(
modifier = Modifier
.fillMaxSize()
.background(color = TangemTheme.colors.background.primary),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(color = TangemTheme.colors.icon.accent)
}
}
@Composable
private fun TokensListContent(
isDifferentAddressesBlockVisible: Boolean,
tokens: LazyPagingItems<TokenItemState>,
bottomMarginDp: Dp,
) {
val state = rememberLazyListState()
val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() }
LazyColumn(
modifier = Modifier
.background(color = TangemTheme.colors.background.primary)
.imePadding()
.fillMaxSize(),
state = state,
contentPadding = PaddingValues(
bottom = bottomMarginDp + bottomBarHeight,
),
) {
item(
key = "DifferentAddressesWarning$isDifferentAddressesBlockVisible",
contentType = "DifferentAddressesWarning$isDifferentAddressesBlockVisible",
) {
if (isDifferentAddressesBlockVisible) DifferentAddressesWarning()
}
tokens.itemKey(TokenItemState::composedId)
tokens.itemContentType(TokenItemState::composedId)
items(items = tokens.itemSnapshotList.items, key = TokenItemState::composedId) {
TokenItem(model = it)
}
}
val keyboardController = LocalSoftwareKeyboardController.current
LaunchedEffect(key1 = state) {
snapshotFlow(state::isScrollInProgress)
.distinctUntilChanged()
.collectLatest { if (it) keyboardController?.hide() }
}
}
@Composable
private fun DifferentAddressesWarning() {
Box(
modifier = Modifier
.padding(TangemTheme.dimens.spacing16)
.background(
color = TangemTheme.colors.button.disabled,
shape = RoundedCornerShape(TangemTheme.dimens.radius10),
),
contentAlignment = Alignment.Center,
) {
val text = stringResource(id = R.string.warning_manage_tokens_legacy_derivation_message)
Text(
text = text,
modifier = Modifier.padding(
horizontal = TangemTheme.dimens.spacing16,
vertical = TangemTheme.dimens.spacing8,
),
color = TangemTheme.colors.text.tertiary,
textAlign = TextAlign.Start,
style = TangemTheme.typography.body2.copy(
letterSpacing = TextUnit(value = 0.5f, type = TextUnitType.Sp),
lineHeight = TextUnit(value = 25f, type = TextUnitType.Sp),
),
)
}
}
@Composable
private fun SaveChangesButton(showProgress: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
PrimaryButton(
modifier = modifier
.imePadding()
.padding(horizontal = TangemTheme.dimens.spacing16)
.fillMaxWidth(),
text = stringResource(id = R.string.common_save_changes),
showProgress = showProgress,
onClick = onClick,
)
}
@Preview(showSystemUi = true)
@Preview(showSystemUi = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_TokensListScreen(
@PreviewParameter(TokensListScreenProvider::class) stateHolder: TokensListStateHolder,
) {
TangemThemePreview {
TokensListScreen(stateHolder)
}
}
private class TokensListScreenProvider : CollectionPreviewParameterProvider<TokensListStateHolder>(
collection = listOf(
TokensListStateHolder.ReadContent(
toolbarState = TokensListToolbarState.Title.Manage(
titleResId = R.string.main_manage_tokens,
onBackButtonClick = {},
onSearchButtonClick = {},
onAddCustomTokenClick = {},
),
isLoading = true,
isDifferentAddressesBlockVisible = false,
tokens = emptyFlow(),
onTokensLoadStateChanged = {},
),
TokensListStateHolder.ManageContent(
toolbarState = TokensListToolbarState.Title.Manage(
titleResId = R.string.main_manage_tokens,
onBackButtonClick = {},
onSearchButtonClick = {},
onAddCustomTokenClick = {},
),
isLoading = false,
isDifferentAddressesBlockVisible = true,
tokens = flowOf(
PagingData.from(
listOf(TokenListPreviewData.createManageToken()),
),
),
onSaveButtonClick = {},
onTokensLoadStateChanged = {},
isSavingInProgress = false,
),
TokensListStateHolder.ReadContent(
toolbarState = TokensListToolbarState.Title.Read(
titleResId = R.string.common_search_tokens,
onBackButtonClick = {},
onSearchButtonClick = {},
),
isLoading = false,
isDifferentAddressesBlockVisible = false,
tokens = flowOf(
PagingData.from(
listOf(TokenListPreviewData.createManageToken()),
),
),
onTokensLoadStateChanged = {},
),
),
)

View file

@ -1,221 +0,0 @@
package com.tangem.tap.features.tokens.impl.presentation.ui
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.sp
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.tap.features.tokens.impl.presentation.states.TokensListToolbarState
import com.tangem.tap.features.tokens.impl.presentation.states.TokensListToolbarState.InputField
import com.tangem.tap.features.tokens.impl.presentation.states.TokensListToolbarState.Title
import com.tangem.wallet.R
import kotlinx.coroutines.delay
/**
[REDACTED_AUTHOR]
*/
@Composable
internal fun TokensListToolbar(state: TokensListToolbarState) {
val toolbarElevation = if (isSystemInDarkTheme()) {
TangemTheme.dimens.elevation0
} else {
AppBarDefaults.TopAppBarElevation
}
val statusBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getTop(this).toDp() }
TopAppBar(
backgroundColor = TangemTheme.colors.background.secondary,
contentPadding = PaddingValues(
start = TangemTheme.dimens.spacing4,
end = TangemTheme.dimens.spacing4,
top = statusBarHeight,
),
elevation = toolbarElevation,
) {
IconButton(onClick = state.onBackButtonClick) {
Icon(
painter = painterResource(id = R.drawable.ic_back_24),
contentDescription = "Go back",
tint = TangemTheme.colors.icon.primary1,
)
}
when (state) {
is Title -> TitleContent(state = state, modifier = Modifier.weight(1f))
is InputField -> InputContent(state = state, modifier = Modifier.weight(1f))
}
}
}
@Composable
private fun TitleContent(state: Title, modifier: Modifier = Modifier) {
Text(
text = stringResource(id = state.titleResId),
modifier = modifier.padding(horizontal = TangemTheme.dimens.spacing26),
color = TangemTheme.colors.text.primary1,
maxLines = 1,
style = TangemTheme.typography.h3,
)
IconButton(onClick = state.onSearchButtonClick) {
Icon(
painter = painterResource(id = R.drawable.ic_search_24),
contentDescription = "Search",
tint = TangemTheme.colors.icon.primary1,
)
}
if (state is Title.Manage) {
IconButton(onClick = state.onAddCustomTokenClick) {
Icon(
painter = painterResource(id = R.drawable.ic_plus_24),
contentDescription = "Add custom token",
tint = TangemTheme.colors.icon.primary1,
)
}
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun InputContent(state: InputField, modifier: Modifier = Modifier) {
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
BasicTextField(
value = state.value,
onValueChange = state.onValueChange,
modifier = modifier.focusRequester(focusRequester),
textStyle = TangemTheme.typography.subtitle1.copy(
fontWeight = FontWeight.Normal,
color = TangemTheme.colors.text.primary1,
),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
singleLine = true,
maxLines = 1,
cursorBrush = SolidColor(value = TangemTheme.colors.stroke.secondary),
) { innerTextField ->
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.weight(1f)
.padding(
start = TangemTheme.dimens.spacing28,
top = TangemTheme.dimens.spacing2,
end = TangemTheme.dimens.spacing16,
),
) {
Hint(value = state.value)
innerTextField()
}
IconButton(onClick = state.onCleanButtonClick) {
Icon(
painter = painterResource(id = R.drawable.ic_cross_rounded_24),
contentDescription = "Clean entered value",
tint = TangemTheme.colors.icon.secondary,
)
}
}
}
// This is a hack because Compose can't show the keyboard if the input field is not found.
// So first I request focus and make a delay and then show the keyboard.
val keyboardManager = LocalSoftwareKeyboardController.current
LaunchedEffect(keyboardManager) {
focusRequester.requestFocus()
delay(timeMillis = 100)
keyboardManager?.show()
}
}
@Composable
private fun Hint(value: String) {
if (value.isBlank()) {
Row(
modifier = Modifier.padding(start = TangemTheme.dimens.spacing4),
horizontalArrangement = Arrangement.spacedBy(space = TangemTheme.dimens.spacing4),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
painter = painterResource(id = R.drawable.ic_search_24),
contentDescription = null,
tint = TangemTheme.colors.icon.secondary,
)
Text(
text = stringResource(id = R.string.common_search),
modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing2),
color = TangemTheme.colors.text.tertiary,
maxLines = 1,
// FIXME("Incorrect typography. Replace with typography from design system")
style = TangemTheme.typography.subtitle1.copy(fontSize = 18.sp, fontWeight = FontWeight.Normal),
)
}
}
}
@Preview
@Composable
private fun Preview_AddTokensToolbar_EditAccess() {
TangemThemePreview {
TokensListToolbar(
state = Title.Manage(
titleResId = R.string.main_manage_tokens,
onBackButtonClick = {},
onSearchButtonClick = {},
onAddCustomTokenClick = {},
),
)
}
}
@Preview
@Composable
private fun Preview_AddTokensToolbar_ReadAccess() {
TangemThemePreview {
TokensListToolbar(
state = Title.Read(
titleResId = R.string.common_search_tokens,
onBackButtonClick = {},
onSearchButtonClick = {},
),
)
}
}
@Preview
@Composable
private fun Preview_AddTokensToolbar_SearchInputField() {
var value by remember { mutableStateOf("") }
TangemThemePreview {
TokensListToolbar(
state = InputField(
onBackButtonClick = {},
value = value,
onValueChange = { value = it },
onCleanButtonClick = { value = "" },
),
)
}
}

View file

@ -1,63 +0,0 @@
package com.tangem.tap.features.tokens.impl.presentation.viewmodels
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.Token
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.tap.common.analytics.events.AnalyticsParam
import com.tangem.tap.common.analytics.events.ManageTokens
/** Analytics sender for tokens list screen */
class TokensListAnalyticsSender(private val analyticsEventHandler: AnalyticsEventHandler) {
fun sendWhenScreenOpened() {
analyticsEventHandler.send(event = ManageTokens.ScreenOpened())
}
fun sendWhenTokenAdded(token: Token) {
analyticsEventHandler.send(
event = ManageTokens.TokenSwitcherChanged(
type = AnalyticsParam.CurrencyType.Token(token),
state = AnalyticsParam.OnOffState.On,
),
)
}
fun sendWhenBlockchainAdded(blockchain: Blockchain) {
analyticsEventHandler.send(
event = ManageTokens.TokenSwitcherChanged(
type = AnalyticsParam.CurrencyType.Blockchain(blockchain),
state = AnalyticsParam.OnOffState.On,
),
)
}
fun sendWhenTokenRemoved(token: Token) {
analyticsEventHandler.send(
event = ManageTokens.TokenSwitcherChanged(
type = AnalyticsParam.CurrencyType.Token(token),
state = AnalyticsParam.OnOffState.Off,
),
)
}
fun sendWhenBlockchainRemoved(blockchain: Blockchain) {
analyticsEventHandler.send(
event = ManageTokens.TokenSwitcherChanged(
type = AnalyticsParam.CurrencyType.Blockchain(blockchain),
state = AnalyticsParam.OnOffState.Off,
),
)
}
fun sendWhenSaveButtonClicked() {
analyticsEventHandler.send(ManageTokens.ButtonSaveChanges())
}
fun sendWhenTokenSearched() {
analyticsEventHandler.send(ManageTokens.TokenSearched())
}
fun sendWhenAddCustomTokenClicked() {
analyticsEventHandler.send(ManageTokens.ButtonCustomToken())
}
}

View file

@ -1,9 +0,0 @@
package com.tangem.tap.features.tokens.impl.presentation.viewmodels
import com.tangem.blockchain.common.Blockchain
import com.tangem.domain.tokens.TokenWithBlockchain
internal data class TokensListCryptoCurrencies(
val coins: List<Blockchain>,
val tokens: List<TokenWithBlockchain>,
)

Some files were not shown because too many files have changed in this diff Show more