Updated on 2026-08-14

This commit is contained in:
Tangem 2024-02-12 15:50:29 +03:00
commit cef36091da
379 changed files with 8948 additions and 8976 deletions

View file

@ -41,6 +41,8 @@ dependencies {
kapt(deps.hilt.kapt)
/** Core modules */
implementation(projects.core.analytics)
implementation(projects.core.analytics.models)
implementation(projects.core.featuretoggles)
implementation(projects.core.navigation)
implementation(projects.core.ui)
@ -57,8 +59,6 @@ dependencies {
implementation(projects.domain.settings)
implementation(projects.domain.tokens)
implementation(projects.domain.tokens.models)
implementation(projects.domain.txhistory)
implementation(projects.domain.txhistory.models)
implementation(projects.domain.wallets)
implementation(projects.domain.wallets.models)
implementation(projects.domain.appCurrency)

View file

@ -1,4 +1,4 @@
package com.tangem.managetokens.presentation.customtokens.state
package com.tangem.managetokens.presentation.addcustomtoken.state
import com.tangem.core.ui.event.StateEvent
import com.tangem.core.ui.event.consumedEvent

View file

@ -1,4 +1,4 @@
package com.tangem.managetokens.presentation.customtokens.state
package com.tangem.managetokens.presentation.addcustomtoken.state
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference

View file

@ -1,4 +1,4 @@
package com.tangem.managetokens.presentation.customtokens.state
package com.tangem.managetokens.presentation.addcustomtoken.state
internal data class ButtonState(
val isEnabled: Boolean,

View file

@ -1,4 +1,4 @@
package com.tangem.managetokens.presentation.customtokens.state
package com.tangem.managetokens.presentation.addcustomtoken.state
import kotlinx.collections.immutable.ImmutableList

View file

@ -1,4 +1,4 @@
package com.tangem.managetokens.presentation.customtokens.state
package com.tangem.managetokens.presentation.addcustomtoken.state
import com.tangem.managetokens.presentation.common.state.NetworkItemState
import kotlinx.collections.immutable.ImmutableList

View file

@ -1,4 +1,4 @@
package com.tangem.managetokens.presentation.customtokens.state
package com.tangem.managetokens.presentation.addcustomtoken.state
internal data class CustomTokenData(
val contractAddressTextField: TextFieldState,

View file

@ -1,4 +1,4 @@
package com.tangem.managetokens.presentation.customtokens.state
package com.tangem.managetokens.presentation.addcustomtoken.state
internal data class Derivation(
val networkName: String,

View file

@ -1,4 +1,4 @@
package com.tangem.managetokens.presentation.customtokens.state
package com.tangem.managetokens.presentation.addcustomtoken.state
internal data class EnterCustomDerivationState(
val value: String,

View file

@ -1,4 +1,4 @@
package com.tangem.managetokens.presentation.customtokens.state
package com.tangem.managetokens.presentation.addcustomtoken.state
internal sealed class TextFieldState {
object Loading : TextFieldState()
@ -8,6 +8,7 @@ internal sealed class TextFieldState {
val isEnabled: Boolean,
val error: AddCustomTokenWarning? = null,
val onValueChange: (String) -> Unit,
val onFocusExit: () -> Unit,
) : TextFieldState()
fun isInputValid(): Boolean = this is Editable && value.isNotBlank() && error == null

View file

@ -1,23 +1,23 @@
package com.tangem.managetokens.presentation.customtokens.state.factory
package com.tangem.managetokens.presentation.addcustomtoken.state.factory
import com.tangem.core.ui.event.consumedEvent
import com.tangem.core.ui.event.triggeredEvent
import com.tangem.domain.AddCustomTokenError
import com.tangem.domain.tokens.error.AddCustomTokenError
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.managetokens.presentation.common.state.*
import com.tangem.managetokens.presentation.customtokens.state.*
import com.tangem.managetokens.presentation.customtokens.viewmodels.CustomTokensClickIntents
import com.tangem.managetokens.presentation.addcustomtoken.state.*
import com.tangem.managetokens.presentation.addcustomtoken.viewmodels.AddCustomTokenClickIntents
import com.tangem.utils.Provider
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.collections.immutable.toPersistentSet
internal class CustomTokensStateFactory(
internal class AddCustomTokenStateFactory(
private val currentStateProvider: Provider<AddCustomTokenState>,
private val clickIntents: CustomTokensClickIntents,
private val clickIntents: AddCustomTokenClickIntents,
) {
fun getInitialState(): AddCustomTokenState {
@ -201,21 +201,25 @@ internal class CustomTokensStateFactory(
value = "",
isEnabled = true,
onValueChange = clickIntents::onContractAddressChange,
onFocusExit = clickIntents::onContractAddressFocusExit,
),
nameTextField = TextFieldState.Editable(
value = "",
isEnabled = false,
onValueChange = clickIntents::onTokenNameChange,
onFocusExit = clickIntents::onTokenNameFocusExit,
),
symbolTextField = TextFieldState.Editable(
value = "",
isEnabled = false,
onValueChange = clickIntents::onSymbolChange,
onFocusExit = clickIntents::onSymbolFocusExit,
),
decimalsTextField = TextFieldState.Editable(
value = "",
isEnabled = false,
onValueChange = clickIntents::onDecimalsChange,
onFocusExit = clickIntents::onDecimalsFocusExit,
),
)
} else {
@ -268,6 +272,8 @@ internal class CustomTokensStateFactory(
value = contractAddress,
isEnabled = true,
onValueChange = clickIntents::onContractAddressChange,
onFocusExit = clickIntents::onContractAddressFocusExit,
),
nameTextField = TextFieldState.Loading,
symbolTextField = TextFieldState.Loading,
@ -279,7 +285,7 @@ internal class CustomTokensStateFactory(
fun handleAddressError(error: AddCustomTokenError): AddCustomTokenState {
val uiState = currentStateProvider()
return when (error) {
AddCustomTokenError.InvalidContractAddress -> {
AddCustomTokenError.INVALID_CONTRACT_ADDRESS -> {
addTokenAddressFieldError(AddCustomTokenWarning.InvalidContractAddress)
.copy(
addTokenButton = uiState.addTokenButton.copy(isEnabled = false),
@ -288,7 +294,7 @@ internal class CustomTokensStateFactory(
.toPersistentSet(),
)
}
AddCustomTokenError.FieldIsEmpty ->
AddCustomTokenError.FIELD_IS_EMPTY ->
removeTokenAddressError()
.copy(
addTokenButton = uiState.addTokenButton.copy(

View file

@ -1,11 +1,11 @@
package com.tangem.managetokens.presentation.customtokens.state.factory
package com.tangem.managetokens.presentation.addcustomtoken.state.factory
import com.tangem.data.tokens.utils.CryptoCurrencyFactory
import com.tangem.domain.common.DerivationStyleProvider
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.managetokens.presentation.customtokens.state.AddCustomTokenState
import com.tangem.managetokens.presentation.customtokens.state.CustomTokenData
import com.tangem.managetokens.presentation.customtokens.state.TextFieldState
import com.tangem.managetokens.presentation.addcustomtoken.state.AddCustomTokenState
import com.tangem.managetokens.presentation.addcustomtoken.state.CustomTokenData
import com.tangem.managetokens.presentation.addcustomtoken.state.TextFieldState
import com.tangem.utils.converter.Converter
internal class AddCustomTokenStateToCryptoCurrencyConverter(
@ -39,7 +39,7 @@ internal class AddCustomTokenStateToCryptoCurrencyConverter(
private fun parseTokenOrNull(tokenData: CustomTokenData?): CryptoCurrencyFactory.Token? {
val contractAddress = (tokenData?.contractAddressTextField as? TextFieldState.Editable)?.value
val symbol = (tokenData?.symbolTextField as? TextFieldState.Editable)?.value
val name = (tokenData?.contractAddressTextField as? TextFieldState.Editable)?.value
val name = (tokenData?.nameTextField as? TextFieldState.Editable)?.value
val decimals = (tokenData?.decimalsTextField as? TextFieldState.Editable)?.value?.toIntOrNull()
return if (
!contractAddress.isNullOrBlank() && !symbol.isNullOrBlank() && !name.isNullOrBlank() && decimals != null

View file

@ -1,12 +1,12 @@
package com.tangem.managetokens.presentation.customtokens.state.factory
package com.tangem.managetokens.presentation.addcustomtoken.state.factory
import com.tangem.managetokens.presentation.customtokens.state.CustomTokenData
import com.tangem.managetokens.presentation.customtokens.state.TextFieldState
import com.tangem.managetokens.presentation.customtokens.viewmodels.CustomTokensClickIntents
import com.tangem.managetokens.presentation.addcustomtoken.state.CustomTokenData
import com.tangem.managetokens.presentation.addcustomtoken.state.TextFieldState
import com.tangem.managetokens.presentation.addcustomtoken.viewmodels.AddCustomTokenClickIntents
import com.tangem.utils.converter.Converter
internal class ContractAddressToCustomTokenDataConverter(
private val clickIntents: CustomTokensClickIntents,
private val clickIntents: AddCustomTokenClickIntents,
) : Converter<String, CustomTokenData> {
override fun convert(value: String): CustomTokenData {
return CustomTokenData(
@ -14,21 +14,25 @@ internal class ContractAddressToCustomTokenDataConverter(
value = value,
isEnabled = true,
onValueChange = clickIntents::onContractAddressChange,
onFocusExit = clickIntents::onContractAddressFocusExit,
),
nameTextField = TextFieldState.Editable(
value = "",
isEnabled = true,
onValueChange = clickIntents::onTokenNameChange,
onFocusExit = clickIntents::onTokenNameFocusExit,
),
symbolTextField = TextFieldState.Editable(
value = "",
isEnabled = true,
onValueChange = clickIntents::onSymbolChange,
onFocusExit = clickIntents::onSymbolFocusExit,
),
decimalsTextField = TextFieldState.Editable(
value = "",
isEnabled = true,
onValueChange = clickIntents::onDecimalsChange,
onFocusExit = clickIntents::onDecimalsFocusExit,
),
)
}

View file

@ -1,13 +1,13 @@
package com.tangem.managetokens.presentation.customtokens.state.factory
package com.tangem.managetokens.presentation.addcustomtoken.state.factory
import com.tangem.domain.tokens.model.FoundToken
import com.tangem.managetokens.presentation.customtokens.state.CustomTokenData
import com.tangem.managetokens.presentation.customtokens.state.TextFieldState
import com.tangem.managetokens.presentation.customtokens.viewmodels.CustomTokensClickIntents
import com.tangem.managetokens.presentation.addcustomtoken.state.CustomTokenData
import com.tangem.managetokens.presentation.addcustomtoken.state.TextFieldState
import com.tangem.managetokens.presentation.addcustomtoken.viewmodels.AddCustomTokenClickIntents
import com.tangem.utils.converter.Converter
internal class FoundTokenToCustomTokenDataConverter(
private val clickIntents: CustomTokensClickIntents,
private val clickIntents: AddCustomTokenClickIntents,
) : Converter<FoundToken, CustomTokenData> {
override fun convert(value: FoundToken): CustomTokenData {
return CustomTokenData(
@ -15,21 +15,25 @@ internal class FoundTokenToCustomTokenDataConverter(
value = value.contractAddress,
isEnabled = true,
onValueChange = clickIntents::onContractAddressChange,
onFocusExit = clickIntents::onContractAddressFocusExit,
),
nameTextField = TextFieldState.Editable(
value = value.name,
isEnabled = false,
onValueChange = clickIntents::onTokenNameChange,
onFocusExit = clickIntents::onTokenNameFocusExit,
),
symbolTextField = TextFieldState.Editable(
value = value.symbol,
isEnabled = false,
onValueChange = clickIntents::onSymbolChange,
onFocusExit = clickIntents::onSymbolFocusExit,
),
decimalsTextField = TextFieldState.Editable(
value = value.decimals.toString(),
isEnabled = false,
onValueChange = clickIntents::onDecimalsChange,
onFocusExit = clickIntents::onDecimalsFocusExit,
),
)
}

View file

@ -1,4 +1,4 @@
package com.tangem.managetokens.presentation.customtokens.state.factory
package com.tangem.managetokens.presentation.addcustomtoken.state.factory
import com.tangem.core.ui.extensions.getActiveIconResByNetworkId
import com.tangem.domain.tokens.model.Network

View file

@ -1,8 +1,8 @@
package com.tangem.managetokens.presentation.customtokens.state.previewdata
package com.tangem.managetokens.presentation.addcustomtoken.state.previewdata
import com.tangem.managetokens.presentation.common.state.ChooseWalletState
import com.tangem.managetokens.presentation.common.state.WalletState
import com.tangem.managetokens.presentation.customtokens.state.*
import com.tangem.managetokens.presentation.addcustomtoken.state.*
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
@ -38,6 +38,7 @@ internal object AddCustomTokenPreviewData {
value = "0x4ace7262705b68bcba5b91de96889349394",
isEnabled = false,
onValueChange = {},
onFocusExit = {},
),
nameTextField = TextFieldState.Loading,
symbolTextField = TextFieldState.Loading,

View file

@ -1,7 +1,7 @@
package com.tangem.managetokens.presentation.customtokens.state.previewdata
package com.tangem.managetokens.presentation.addcustomtoken.state.previewdata
import com.tangem.managetokens.presentation.customtokens.state.ChooseDerivationState
import com.tangem.managetokens.presentation.customtokens.state.Derivation
import com.tangem.managetokens.presentation.addcustomtoken.state.ChooseDerivationState
import com.tangem.managetokens.presentation.addcustomtoken.state.Derivation
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf

View file

@ -1,8 +1,8 @@
package com.tangem.managetokens.presentation.customtokens.state.previewdata
package com.tangem.managetokens.presentation.addcustomtoken.state.previewdata
import com.tangem.features.managetokens.impl.R
import com.tangem.managetokens.presentation.common.state.NetworkItemState
import com.tangem.managetokens.presentation.customtokens.state.ChooseNetworkState
import com.tangem.managetokens.presentation.addcustomtoken.state.ChooseNetworkState
import kotlinx.collections.immutable.persistentListOf
internal object ChooseNetworkCustomPreviewData {

View file

@ -1,4 +1,4 @@
package com.tangem.managetokens.presentation.customtokens.ui
package com.tangem.managetokens.presentation.addcustomtoken.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
@ -12,9 +12,11 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.components.Keyboard
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.keyboardAsState
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.managetokens.impl.R
@ -25,13 +27,13 @@ import com.tangem.managetokens.presentation.common.ui.ChooseWalletBottomSheetCon
import com.tangem.managetokens.presentation.common.ui.EventEffect
import com.tangem.managetokens.presentation.common.ui.components.Alert
import com.tangem.managetokens.presentation.common.ui.components.SimpleSelectionBlock
import com.tangem.managetokens.presentation.customtokens.state.AddCustomTokenState
import com.tangem.managetokens.presentation.customtokens.state.CustomTokenData
import com.tangem.managetokens.presentation.customtokens.state.TextFieldState
import com.tangem.managetokens.presentation.customtokens.state.previewdata.AddCustomTokenPreviewData
import com.tangem.managetokens.presentation.addcustomtoken.state.AddCustomTokenState
import com.tangem.managetokens.presentation.addcustomtoken.state.CustomTokenData
import com.tangem.managetokens.presentation.addcustomtoken.state.TextFieldState
import com.tangem.managetokens.presentation.addcustomtoken.state.previewdata.AddCustomTokenPreviewData
@Composable
internal fun CustomTokensScreen(state: AddCustomTokenState, modifier: Modifier = Modifier) {
internal fun AddCustomTokenScreen(state: AddCustomTokenState, modifier: Modifier = Modifier) {
var alertState by remember { mutableStateOf<AlertState?>(value = null) }
EventEffect(
@ -46,11 +48,14 @@ internal fun CustomTokensScreen(state: AddCustomTokenState, modifier: Modifier =
@Composable
private fun Content(state: AddCustomTokenState, modifier: Modifier = Modifier) {
val keyboard by keyboardAsState()
Column(
modifier = modifier
.background(color = TangemTheme.colors.background.tertiary)
.statusBarsPadding()
.navigationBarsPadding()
.imePadding()
.padding(
top = TangemTheme.dimens.spacing10,
start = TangemTheme.dimens.spacing16,
@ -78,12 +83,14 @@ private fun Content(state: AddCustomTokenState, modifier: Modifier = Modifier) {
.padding(bottom = TangemTheme.dimens.spacing16),
)
CustomTokenItemsList(state = state)
PrimaryButton(
text = stringResource(id = R.string.custom_token_add_token),
onClick = state.addTokenButton.onClick,
enabled = state.addTokenButton.isEnabled,
modifier = Modifier.fillMaxWidth(),
)
if (keyboard is Keyboard.Closed) {
PrimaryButton(
text = stringResource(id = R.string.custom_token_add_token),
onClick = state.addTokenButton.onClick,
enabled = state.addTokenButton.isEnabled,
modifier = Modifier.fillMaxWidth(),
)
}
}
if (state.chooseWalletState is ChooseWalletState.Choose && state.chooseWalletState.show) {
@ -243,7 +250,7 @@ private fun TokenTextFieldTitle(state: TextFieldState?, title: String) {
@Composable
private fun Preview_ChooseDerivationScreen_Light() {
TangemTheme(isDark = false) {
CustomTokensScreen(state = AddCustomTokenPreviewData.state)
AddCustomTokenScreen(state = AddCustomTokenPreviewData.state)
}
}
@ -251,6 +258,6 @@ private fun Preview_ChooseDerivationScreen_Light() {
@Composable
private fun Preview_ChooseDerivationScreen_Dark() {
TangemTheme(isDark = true) {
CustomTokensScreen(state = AddCustomTokenPreviewData.state)
AddCustomTokenScreen(state = AddCustomTokenPreviewData.state)
}
}

View file

@ -1,4 +1,4 @@
package com.tangem.managetokens.presentation.customtokens.ui
package com.tangem.managetokens.presentation.addcustomtoken.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@ -16,8 +16,8 @@ import com.tangem.core.ui.decorations.roundedShapeItemDecoration
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.managetokens.impl.R
import com.tangem.managetokens.presentation.common.ui.components.SimpleSelectionBlock
import com.tangem.managetokens.presentation.customtokens.state.ChooseDerivationState
import com.tangem.managetokens.presentation.customtokens.state.previewdata.ChooseDerivationPreviewData
import com.tangem.managetokens.presentation.addcustomtoken.state.ChooseDerivationState
import com.tangem.managetokens.presentation.addcustomtoken.state.previewdata.ChooseDerivationPreviewData
@Composable
internal fun ChooseDerivationScreen(state: ChooseDerivationState, modifier: Modifier = Modifier) {

View file

@ -1,4 +1,4 @@
package com.tangem.managetokens.presentation.customtokens.ui
package com.tangem.managetokens.presentation.addcustomtoken.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@ -16,8 +16,8 @@ import com.tangem.core.ui.decorations.roundedShapeItemDecoration
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.managetokens.impl.R
import com.tangem.managetokens.presentation.common.ui.components.NetworkItem
import com.tangem.managetokens.presentation.customtokens.state.ChooseNetworkState
import com.tangem.managetokens.presentation.customtokens.state.previewdata.ChooseNetworkCustomPreviewData
import com.tangem.managetokens.presentation.addcustomtoken.state.ChooseNetworkState
import com.tangem.managetokens.presentation.addcustomtoken.state.previewdata.ChooseNetworkCustomPreviewData
@Composable
internal fun ChooseNetworkCustomScreen(state: ChooseNetworkState, modifier: Modifier = Modifier) {

View file

@ -1,4 +1,4 @@
package com.tangem.managetokens.presentation.customtokens.ui
package com.tangem.managetokens.presentation.addcustomtoken.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
@ -6,7 +6,7 @@ import com.tangem.core.ui.components.AdditionalTextInputDialogParams
import com.tangem.core.ui.components.DialogButton
import com.tangem.core.ui.components.TextInputDialog
import com.tangem.features.managetokens.impl.R
import com.tangem.managetokens.presentation.customtokens.state.EnterCustomDerivationState
import com.tangem.managetokens.presentation.addcustomtoken.state.EnterCustomDerivationState
@Composable
internal fun CustomDerivationDialog(state: EnterCustomDerivationState) {

View file

@ -1,4 +1,4 @@
package com.tangem.managetokens.presentation.customtokens.ui
package com.tangem.managetokens.presentation.addcustomtoken.ui
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBarsPadding

View file

@ -1,4 +1,4 @@
package com.tangem.managetokens.presentation.customtokens.ui
package com.tangem.managetokens.presentation.addcustomtoken.ui
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
@ -6,13 +6,17 @@ import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import com.tangem.core.ui.res.TangemTheme
import com.tangem.managetokens.presentation.customtokens.state.TextFieldState
import com.tangem.managetokens.presentation.addcustomtoken.state.TextFieldState
@Composable
internal fun TokenTextField(
@ -20,6 +24,11 @@ internal fun TokenTextField(
placeholder: String,
keyboardType: KeyboardType = KeyboardType.Text,
) {
val isInitiallyComposed = remember { mutableStateOf(false) }
LaunchedEffect(key1 = true) {
isInitiallyComposed.value = true
}
BasicTextField(
value = state.value,
onValueChange = state.onValueChange,
@ -32,7 +41,12 @@ internal fun TokenTextField(
),
cursorBrush = SolidColor(TangemTheme.colors.icon.primary1),
modifier = Modifier
.fillMaxWidth(),
.fillMaxWidth()
.onFocusChanged {
if (!it.isFocused && isInitiallyComposed.value) {
state.onFocusExit()
}
},
decorationBox = { innerTextField ->
Row(modifier = Modifier.fillMaxWidth()) {
if (state.value.isEmpty()) {

View file

@ -1,9 +1,10 @@
package com.tangem.managetokens.presentation.customtokens.viewmodels
package com.tangem.managetokens.presentation.addcustomtoken.viewmodels
import com.tangem.managetokens.presentation.common.state.NetworkItemState
import com.tangem.managetokens.presentation.customtokens.state.Derivation
import com.tangem.managetokens.presentation.addcustomtoken.state.Derivation
internal interface CustomTokensClickIntents {
@Suppress("TooManyFunctions")
internal interface AddCustomTokenClickIntents {
fun onNetworkSelected(networkItemState: NetworkItemState)
@ -25,6 +26,14 @@ internal interface CustomTokensClickIntents {
fun onDecimalsChange(input: String)
fun onContractAddressFocusExit()
fun onTokenNameFocusExit()
fun onSymbolFocusExit()
fun onDecimalsFocusExit()
fun onDerivationSelected(derivation: Derivation)
fun onChooseDerivationClick()

View file

@ -1,29 +1,34 @@
package com.tangem.managetokens.presentation.customtokens.viewmodels
package com.tangem.managetokens.presentation.addcustomtoken.viewmodels
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import arrow.core.getOrElse
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.crypto.hdWallet.HDWalletError
import com.tangem.domain.common.util.derivationStyleProvider
import com.tangem.domain.tokens.*
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.domain.wallets.usecase.SelectWalletUseCase
import com.tangem.managetokens.presentation.common.analytics.ManageTokens
import com.tangem.managetokens.presentation.common.state.AlertState
import com.tangem.managetokens.presentation.common.state.Event
import com.tangem.managetokens.presentation.common.state.NetworkItemState
import com.tangem.managetokens.presentation.customtokens.state.*
import com.tangem.managetokens.presentation.customtokens.state.factory.AddCustomTokenStateToCryptoCurrencyConverter
import com.tangem.managetokens.presentation.customtokens.state.factory.ContractAddressToCustomTokenDataConverter
import com.tangem.managetokens.presentation.customtokens.state.factory.CustomTokensStateFactory
import com.tangem.managetokens.presentation.customtokens.state.factory.FoundTokenToCustomTokenDataConverter
import com.tangem.managetokens.presentation.addcustomtoken.state.*
import com.tangem.managetokens.presentation.addcustomtoken.state.factory.AddCustomTokenStateToCryptoCurrencyConverter
import com.tangem.managetokens.presentation.addcustomtoken.state.factory.ContractAddressToCustomTokenDataConverter
import com.tangem.managetokens.presentation.addcustomtoken.state.factory.AddCustomTokenStateFactory
import com.tangem.managetokens.presentation.addcustomtoken.state.factory.FoundTokenToCustomTokenDataConverter
import com.tangem.managetokens.presentation.router.InnerManageTokensRouter
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -39,9 +44,9 @@ import kotlinx.coroutines.withContext
import javax.inject.Inject
import kotlin.properties.Delegates
@Suppress("LongParameterList")
@Suppress("LongParameterList", "TooManyFunctions", "LargeClass")
@HiltViewModel
internal class CustomTokensViewModel @Inject constructor(
internal class AddCustomTokenViewModel @Inject constructor(
private val dispatchers: CoroutineDispatcherProvider,
private val getWalletsUseCase: GetWalletsUseCase,
private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase,
@ -52,11 +57,12 @@ internal class CustomTokensViewModel @Inject constructor(
private val validateContractAddressUseCase: ValidateContractAddressUseCase,
private val getNetworksSupportedByWallet: GetNetworksSupportedByWallet,
private val areTokensSupportedByNetworkUseCase: AreTokensSupportedByNetworkUseCase,
) : ViewModel(), CustomTokensClickIntents, DefaultLifecycleObserver {
private val analyticsEventHandler: AnalyticsEventHandler,
) : ViewModel(), AddCustomTokenClickIntents, DefaultLifecycleObserver {
private val debouncer = Debouncer()
private val stateFactory = CustomTokensStateFactory(
private val stateFactory = AddCustomTokenStateFactory(
currentStateProvider = Provider { uiState },
clickIntents = this,
)
@ -66,7 +72,7 @@ internal class CustomTokensViewModel @Inject constructor(
var uiState: AddCustomTokenState by mutableStateOf(stateFactory.getInitialState())
private set
init {
override fun onCreate(owner: LifecycleOwner) {
viewModelScope.launch(dispatchers.io) {
getWalletsUseCase()
.distinctUntilChanged()
@ -86,6 +92,10 @@ internal class CustomTokensViewModel @Inject constructor(
}
}
override fun onDestroy(owner: LifecycleOwner) {
uiState = stateFactory.getInitialState()
}
private suspend fun selectSuitableWallet(suitableUserWallets: List<UserWallet>): UserWalletId? {
val selectedWallet = getSelectedWalletSyncUseCase().getOrNull()
val selectedWalletId = if (walletSupportsAddingTokens(selectedWallet) && suitableUserWallets.isNotEmpty()) {
@ -110,6 +120,7 @@ internal class CustomTokensViewModel @Inject constructor(
}
override fun onNetworkSelected(networkItemState: NetworkItemState) {
analyticsEventHandler.send(ManageTokens.CustomTokenNetworkSelected(networkItemState.name))
selectNetwork(networkItemState)
router.popBackStack()
}
@ -128,7 +139,7 @@ internal class CustomTokensViewModel @Inject constructor(
}
override fun onChooseNetworkClick() {
router.openCustomTokensChooseNetwork()
router.openCustomTokenChooseNetwork()
}
override fun onCloseChoosingNetworkClick() {
@ -136,6 +147,7 @@ internal class CustomTokensViewModel @Inject constructor(
}
override fun onWalletSelected(walletId: String) {
analyticsEventHandler.send(ManageTokens.WalletSelected(ManageTokens.WalletSelected.Source.CustomToken))
viewModelScope.launch(dispatchers.io) {
val userWalletId = UserWalletId(walletId)
selectWalletUseCase(userWalletId)
@ -152,7 +164,7 @@ internal class CustomTokensViewModel @Inject constructor(
}
override fun onChooseWalletClick() {
router.openCustomTokensChooseWallet()
router.openCustomTokenChooseWallet()
}
override fun onCloseChoosingWalletClick() {
@ -166,6 +178,7 @@ internal class CustomTokensViewModel @Inject constructor(
value = input,
isEnabled = true,
onValueChange = this::onContractAddressChange,
onFocusExit = this::onContractAddressFocusExit,
),
),
)
@ -200,22 +213,34 @@ internal class CustomTokensViewModel @Inject constructor(
networkId = networkId,
).fold(
ifLeft = {
val tokenData = ContractAddressToCustomTokenDataConverter(this@CustomTokensViewModel)
val tokenData = ContractAddressToCustomTokenDataConverter(this@AddCustomTokenViewModel)
.convert(contractAddress)
val isButtonEnabled = tokenData.isRequiredInformationProvided()
uiState = uiState.copy(
tokenData = tokenData,
warnings = (uiState.warnings + AddCustomTokenWarning.PotentialScamToken).toPersistentSet(),
addTokenButton = uiState.addTokenButton.copy(
isEnabled = isButtonEnabled,
),
)
},
ifRight = { token ->
val tokenData = if (token != null) {
FoundTokenToCustomTokenDataConverter(this@CustomTokensViewModel).convert(token)
FoundTokenToCustomTokenDataConverter(this@AddCustomTokenViewModel).convert(token)
} else {
ContractAddressToCustomTokenDataConverter(this@CustomTokensViewModel).convert(
ContractAddressToCustomTokenDataConverter(this@AddCustomTokenViewModel).convert(
contractAddress,
)
}
uiState = uiState.copy(tokenData = tokenData)
val isButtonEnabled = tokenData.isRequiredInformationProvided()
uiState = uiState.copy(
tokenData = tokenData,
addTokenButton = uiState.addTokenButton.copy(
isEnabled = isButtonEnabled,
),
)
},
)
}
@ -229,8 +254,12 @@ internal class CustomTokensViewModel @Inject constructor(
value = input,
isEnabled = true,
onValueChange = this::onTokenNameChange,
onFocusExit = this::onTokenNameFocusExit,
),
),
addTokenButton = uiState.addTokenButton.copy(
isEnabled = uiState.tokenData?.isRequiredInformationProvided() == true,
),
)
}
@ -241,8 +270,12 @@ internal class CustomTokensViewModel @Inject constructor(
value = input,
isEnabled = true,
onValueChange = this::onSymbolChange,
onFocusExit = this::onSymbolFocusExit,
),
),
addTokenButton = uiState.addTokenButton.copy(
isEnabled = uiState.tokenData?.isRequiredInformationProvided() == true,
),
)
}
@ -260,19 +293,45 @@ internal class CustomTokensViewModel @Inject constructor(
isEnabled = true,
onValueChange = this::onDecimalsChange,
error = error,
onFocusExit = this::onDecimalsFocusExit,
),
),
addTokenButton = uiState.addTokenButton.copy(
isEnabled = uiState.tokenData?.isRequiredInformationProvided() == true,
),
)
}
override fun onContractAddressFocusExit() {
val error = (uiState.tokenData?.contractAddressTextField as? TextFieldState.Editable)?.error
val validated = error !is AddCustomTokenWarning.InvalidContractAddress
analyticsEventHandler.send(ManageTokens.CustomTokenAddress(validated = validated))
}
override fun onTokenNameFocusExit() {
analyticsEventHandler.send(ManageTokens.CustomTokenName)
}
override fun onSymbolFocusExit() {
analyticsEventHandler.send(ManageTokens.CustomTokenSymbol)
}
override fun onDecimalsFocusExit() {
analyticsEventHandler.send(ManageTokens.CustomTokenDecimals)
}
override fun onDerivationSelected(derivation: Derivation) {
uiState =
uiState.copy(chooseDerivationState = uiState.chooseDerivationState?.copy(selectedDerivation = derivation))
derivation.standardType?.let {
analyticsEventHandler.send(ManageTokens.CustomTokenDerivationSelected(derivation.networkName))
}
uiState = uiState.copy(
chooseDerivationState = uiState.chooseDerivationState?.copy(selectedDerivation = derivation),
)
router.popBackStack()
}
override fun onChooseDerivationClick() {
router.openCustomTokensChooseDerivation()
router.openCustomTokenChooseDerivation()
}
override fun onCloseChoosingDerivationClick() {
@ -280,6 +339,7 @@ internal class CustomTokensViewModel @Inject constructor(
}
override fun onCustomDerivationChange(input: String) {
analyticsEventHandler.send(ManageTokens.CustomTokenDerivationSelected(ManageTokens.Derivation.CUSTOM.value))
uiState = uiState.copy(
chooseDerivationState = uiState.chooseDerivationState?.copy(
enterCustomDerivationState = uiState.chooseDerivationState?.enterCustomDerivationState?.copy(
@ -332,21 +392,77 @@ internal class CustomTokensViewModel @Inject constructor(
val cryptoCurrency = AddCustomTokenStateToCryptoCurrencyConverter(
selectedWallet.scanResponse.derivationStyleProvider,
).convert(uiState)
val alreadyAdded =
getCurrenciesUseCase(selectedWallet.walletId).getOrNull()?.any { it == cryptoCurrency }
if (alreadyAdded == true) {
val alreadyAdded = isCryptoCurrencyAlreadyAdded(selectedWallet, cryptoCurrency)
if (alreadyAdded) {
uiState = stateFactory.getStateAndTriggerEvent(
state = uiState,
event = Event.ShowAlert(AlertState.TokenAlreadyAdded),
setUiState = { uiState = it },
)
} else {
sendTokenAddedEvent(cryptoCurrency)
addCryptoCurrenciesUseCase(selectedWallet.walletId, currency = cryptoCurrency)
withContext(dispatchers.main) { router.popBackStack() }
}
}
}
private suspend fun isCryptoCurrencyAlreadyAdded(
selectedWallet: UserWallet,
cryptoCurrency: CryptoCurrency,
): Boolean {
val currenciesList = getCurrenciesUseCase(selectedWallet.walletId).getOrElse { emptyList() }
return when (cryptoCurrency) {
is CryptoCurrency.Coin -> {
currenciesList.any {
it is CryptoCurrency.Coin &&
it.id == cryptoCurrency.id &&
it.network.derivationPath == cryptoCurrency.network.derivationPath
}
}
is CryptoCurrency.Token -> {
currenciesList.any {
(it as? CryptoCurrency.Token)?.let {
it.id == cryptoCurrency.id &&
it.contractAddress == cryptoCurrency.contractAddress &&
it.network.id == cryptoCurrency.network.id &&
it.network.derivationPath == cryptoCurrency.network.derivationPath
} ?: false
}
}
}
}
private fun sendTokenAddedEvent(cryptoCurrency: CryptoCurrency) {
val selectedDerivation = uiState.chooseDerivationState?.selectedDerivation
val derivation = when {
selectedDerivation == null -> ManageTokens.Derivation.DEFAULT.value
selectedDerivation.networkName.isNotEmpty() -> selectedDerivation.networkName
else -> ManageTokens.Derivation.CUSTOM.value
}
when (cryptoCurrency) {
is CryptoCurrency.Token -> {
analyticsEventHandler.send(
ManageTokens.CustomTokenWasAdded(
derivation = derivation,
networkId = cryptoCurrency.network.name,
contractAddress = cryptoCurrency.contractAddress,
token = cryptoCurrency.symbol,
),
)
}
is CryptoCurrency.Coin -> {
analyticsEventHandler.send(
ManageTokens.CustomTokenWasAdded(
derivation = derivation,
networkId = cryptoCurrency.network.name,
),
)
}
}
}
override fun onBack() {
router.popBackStack()
}

View file

@ -0,0 +1,102 @@
package com.tangem.managetokens.presentation.common.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam
sealed class ManageTokens(
event: String,
params: Map<String, String> = emptyMap(),
) : AnalyticsEvent("Manage Tokens", event, params) {
class ScreenOpened : ManageTokens("Manage Tokens Screen Opened")
class TokenIsNotFound(userInput: String) : ManageTokens(
event = "Token Is Not Found",
params = mapOf("Input" to userInput),
)
class TokenSwitcherChanged(
token: String,
state: AnalyticsParam.OnOffState,
) : ManageTokens(
event = "Token Switcher Changed",
params = mapOf(
"Token" to token,
"State" to state.value,
),
)
class ButtonAdd(token: String) : ManageTokens(
event = "Button - Add",
params = mapOf("Token" to token),
)
class ButtonEdit(token: String) : ManageTokens(
event = "Button - Edit",
params = mapOf("Token" to token),
)
object ButtonChooseWallet : ManageTokens(event = "Button - Choose Wallet")
class WalletSelected(source: Source) : ManageTokens(
event = "Wallet Selected",
params = mapOf("Source" to source.name),
) {
enum class Source(name: String) {
MainToken("Main Token"),
CustomToken("Custom Token"),
}
}
object NoticeNonNativeNetworkClicked : ManageTokens(event = "Notice - Non Native Network Clicked")
class ButtonGenerateAddresses(cardCount: Int) : ManageTokens(
event = "Button - Get Addresses",
params = mapOf("CardCount" to cardCount.toString()),
)
object ButtonCustomToken : ManageTokens("Button - Custom Token")
class CustomTokenWasAdded(
val derivation: String,
val networkId: String,
val token: String? = null,
val contractAddress: String? = null,
) : ManageTokens(
event = "Custom Token Was Added",
params = mutableMapOf(
"Derivation" to derivation,
"Network Id" to networkId,
).apply {
token?.let { put("Token", it) }
contractAddress?.let { put("Contract Address", it) }
},
)
class CustomTokenNetworkSelected(blockchain: String) : ManageTokens(
event = "Custom Token Network Selected",
params = mapOf("blockchain" to blockchain),
)
class CustomTokenDerivationSelected(derivation: String) : ManageTokens(
event = "Custom Token Derivation Selected",
params = mapOf("Derivation" to derivation),
)
class CustomTokenAddress(validated: Boolean) : ManageTokens(
"Custom Token Address",
params = mapOf("Validation" to if (validated) "Ok" else "Error"),
)
object CustomTokenName : ManageTokens("Custom Token Name")
object CustomTokenSymbol : ManageTokens("Custom Token Symbol")
object CustomTokenDecimals : ManageTokens("Custom Token Decimals")
enum class Derivation(val value: String) {
DEFAULT("Default"),
CUSTOM("Custom"),
}
}

View file

@ -38,7 +38,7 @@ internal sealed interface NetworkItemState {
* @property onToggleClick lambda be invoked when switch is been toggled
*/
@Suppress("LongParameterList")
class Toggleable(
data class Toggleable(
override val name: String,
override val protocolName: String,
override val id: String,
@ -72,7 +72,7 @@ internal sealed interface NetworkItemState {
* @property onNetworkClick lambda be invoked when network item is been clicked
*
*/
class Selectable(
data class Selectable(
override val name: String,
override val protocolName: String,
val iconResId: Int,

View file

@ -16,6 +16,7 @@ internal data class ManageTokensState(
val selectedToken: TokenItemState.Loaded? = null,
val showChooseWalletScreen: Boolean = false,
val event: StateEvent<Event>,
val onEmptySearchResult: (String) -> Unit,
)
data class AddCustomTokenButton(

View file

@ -10,6 +10,7 @@ import com.tangem.managetokens.presentation.common.state.*
import com.tangem.managetokens.presentation.common.utils.CurrencyUtils
import com.tangem.managetokens.presentation.managetokens.state.*
import com.tangem.managetokens.presentation.managetokens.viewmodels.ManageTokensClickIntents
import com.tangem.managetokens.presentation.managetokens.viewmodels.ManageTokensUiEvents
import com.tangem.utils.Provider
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.flow.Flow
@ -17,6 +18,7 @@ import kotlinx.coroutines.flow.Flow
internal class ManageTokensStateFactory(
private val currentStateProvider: Provider<ManageTokensState>,
private val clickIntents: ManageTokensClickIntents,
private val uiIntents: ManageTokensUiEvents,
) {
fun getInitialState(tokens: Flow<PagingData<TokenItemState>>): ManageTokensState {
@ -36,6 +38,7 @@ internal class ManageTokensStateFactory(
isLoading = false,
event = consumedEvent(),
chooseWalletState = ChooseWalletState.NoSelection,
onEmptySearchResult = uiIntents::onEmptySearchResult,
)
}
@ -176,7 +179,7 @@ internal class ManageTokensStateFactory(
totalNeeded = totalNeeded,
totalWallets = totalWallets,
walletsToDerive = walletsToDerive,
onGenerateClick = clickIntents::onGenerateDerivationClick,
onGenerateClick = clickIntents::onGetAddressesClick,
)
}
return currentStateProvider().copy(derivationNotification = derivationNotificationState)

View file

@ -37,7 +37,7 @@ internal val nonNativeNetworks = listOf(
iconResId = mutableStateOf(R.drawable.img_kusama_22),
isMainNetwork = false,
isAdded = mutableStateOf(true),
id = "",
id = "1",
onToggleClick = { _, _ -> },
address = "",
decimals = 0,
@ -48,7 +48,7 @@ internal val nonNativeNetworks = listOf(
iconResId = mutableStateOf(R.drawable.ic_bsc_16),
isMainNetwork = false,
isAdded = mutableStateOf(false),
id = "",
id = "2",
onToggleClick = { _, _ -> },
address = "",
decimals = 0,

View file

@ -19,6 +19,7 @@ internal object ManageTokensStatePreviewData {
derivationNotification = DerivationNotificationStatePreviewData.state,
event = consumedEvent(),
chooseWalletState = ChooseWalletStatePreviewData.state,
onEmptySearchResult = {},
)
val loadingState: ManageTokensState

View file

@ -84,29 +84,18 @@ internal fun ChooseNetworkScreen(
SpacerH(height = TangemTheme.dimens.spacing16)
}
item {
if (networkState.nativeNetworks.isNotEmpty()) {
NativeNetworks(networkState = networkState, tokenState = state)
}
if (networkState.nativeNetworks.isNotEmpty()) {
this@LazyColumn.nativeNetworks(networkState = networkState, tokenState = state)
}
if (networkState.nonNativeNetworks.isNotEmpty()) {
item {
NonNativeNetworksHeader(networkState.onNonNativeNetworkHintClick)
}
item {
SpacerH(height = TangemTheme.dimens.spacing8)
}
item {
this@LazyColumn.NonNativeNetworks(networkState = networkState, tokenState = state)
}
this@LazyColumn.nonNativeNetworks(networkState = networkState, tokenState = state)
}
}
}
@Composable
private fun NativeNetworks(networkState: ChooseNetworkState, tokenState: TokenItemState.Loaded) {
Column {
private fun LazyListScope.nativeNetworks(networkState: ChooseNetworkState, tokenState: TokenItemState.Loaded) {
item {
Text(
text = stringResource(id = R.string.manage_tokens_network_selector_native_title),
color = TangemTheme.colors.text.tertiary,
@ -119,25 +108,35 @@ private fun NativeNetworks(networkState: ChooseNetworkState, tokenState: TokenIt
style = TangemTheme.typography.caption2,
)
SpacerH(height = TangemTheme.dimens.spacing8)
}
networkState.nativeNetworks.forEachIndexed { index, network ->
NetworkItem(
state = network,
tokenState = tokenState,
modifier = Modifier
.roundedShapeItemDecoration(
currentIndex = index,
lastIndex = networkState.nativeNetworks.lastIndex,
addDefaultPadding = false,
),
)
}
items(
count = networkState.nativeNetworks.count(),
key = { index -> networkState.nativeNetworks[index].id },
) { index ->
NetworkItem(
state = networkState.nativeNetworks[index],
tokenState = tokenState,
modifier = Modifier
.roundedShapeItemDecoration(
currentIndex = index,
lastIndex = networkState.nativeNetworks.lastIndex,
addDefaultPadding = false,
),
)
}
item {
SpacerH(height = TangemTheme.dimens.spacing16)
}
}
@Composable
private fun LazyListScope.NonNativeNetworks(networkState: ChooseNetworkState, tokenState: TokenItemState.Loaded) {
private fun LazyListScope.nonNativeNetworks(networkState: ChooseNetworkState, tokenState: TokenItemState.Loaded) {
item {
NonNativeNetworksHeader(networkState.onNonNativeNetworkHintClick)
SpacerH(height = TangemTheme.dimens.spacing8)
}
items(
count = networkState.nonNativeNetworks.count(),
key = { index -> networkState.nonNativeNetworks[index].id },
@ -153,6 +152,7 @@ private fun LazyListScope.NonNativeNetworks(networkState: ChooseNetworkState, to
),
)
}
item {
SpacerH(height = TangemTheme.dimens.spacing16)
}

View file

@ -2,10 +2,7 @@ package com.tangem.managetokens.presentation.managetokens.ui
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.Surface
import androidx.compose.runtime.*
@ -14,6 +11,8 @@ 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 androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.res.TangemTheme
@ -50,6 +49,8 @@ private fun Content(state: ManageTokensState) {
Box(
modifier = Modifier
.fillMaxSize()
.navigationBarsPadding()
.imePadding()
.background(color = TangemTheme.colors.background.primary)
.padding(top = TangemTheme.dimens.spacing32),
) {
@ -77,6 +78,14 @@ private fun Content(state: ManageTokensState) {
)
}
val tokens = state.tokens.collectAsLazyPagingItems()
val query = state.searchBarState.query
TrackPossibleEmptySearchResult(
tokens = tokens,
query = query,
onEmptySearchResult = state.onEmptySearchResult,
)
TokensList(tokens = tokens, addCustomTokenButton = state.addCustomTokenButton)
}
state.derivationNotification?.let {
@ -92,6 +101,27 @@ private fun Content(state: ManageTokensState) {
}
}
@Composable
private fun TrackPossibleEmptySearchResult(
tokens: LazyPagingItems<TokenItemState>,
query: String,
onEmptySearchResult: (String) -> Unit,
) {
val wasLoading = remember { mutableStateOf(false) }
LaunchedEffect(tokens.loadState) {
val isLoading = tokens.loadState.refresh == LoadState.Loading
val stoppedLoading = wasLoading.value && !isLoading
val queryAndTokensCondition = query.isNotEmpty() && tokens.itemSnapshotList.isEmpty()
if (stoppedLoading && queryAndTokensCondition) {
onEmptySearchResult(query)
}
wasLoading.value = isLoading
}
}
@Composable
private fun ManageTokensBottomSheet(selectedToken: TokenItemState.Loaded, state: ManageTokensState) {
if (state.showChooseWalletScreen && state.chooseWalletState is ChooseWalletState.Choose) {

View file

@ -4,23 +4,24 @@ import com.tangem.managetokens.presentation.common.state.NetworkItemState
import com.tangem.managetokens.presentation.managetokens.state.TokenItemState
internal interface ManageTokensClickIntents {
fun onAddCustomTokensButtonClick()
fun onSearchQueryChange(query: String)
fun onSearchActiveChange(active: Boolean)
fun onTokenItemButtonClick(token: TokenItemState.Loaded)
fun onGenerateDerivationClick()
fun onGetAddressesClick()
fun onBackClick()
fun onCloseChooseNetworkScreen()
fun onNetworkToggleClick(token: TokenItemState.Loaded, network: NetworkItemState.Toggleable)
fun onNonNativeNetworkHintClick()
fun onSelectWalletsClick()
fun onNonNativeNetworkHintClick()
fun onChooseWalletClick()

View file

@ -0,0 +1,6 @@
package com.tangem.managetokens.presentation.managetokens.viewmodels
internal interface ManageTokensUiEvents {
fun onEmptySearchResult(query: String)
}

View file

@ -9,6 +9,8 @@ import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.map
import arrow.core.getOrElse
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.card.DerivePublicKeysUseCase
@ -20,6 +22,7 @@ import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.domain.wallets.usecase.SelectWalletUseCase
import com.tangem.managetokens.presentation.common.analytics.ManageTokens
import com.tangem.managetokens.presentation.common.state.AlertState
import com.tangem.managetokens.presentation.common.state.Event
import com.tangem.managetokens.presentation.common.state.NetworkItemState
@ -58,13 +61,15 @@ internal class ManageTokensViewModel @Inject constructor(
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val checkCurrencyCompatibilityUseCase: CheckCurrencyCompatibilityUseCase,
private val isCryptoCurrencyCoinCouldHide: IsCryptoCurrencyCoinCouldHideUseCase,
) : ViewModel(), ManageTokensClickIntents, DefaultLifecycleObserver {
private val analyticsEventHandler: AnalyticsEventHandler,
) : ViewModel(), ManageTokensClickIntents, ManageTokensUiEvents, DefaultLifecycleObserver {
private val debouncer = Debouncer()
private val stateFactory = ManageTokensStateFactory(
currentStateProvider = Provider { uiState },
clickIntents = this,
uiIntents = this,
)
var router: InnerManageTokensRouter by Delegates.notNull()
@ -80,7 +85,7 @@ internal class ManageTokensViewModel @Inject constructor(
private var selectedWallet: UserWallet? = null
private var neededDerivations: Map<UserWalletId, List<CryptoCurrency>> = emptyMap()
private var currenciesToGenerateAddresses: Map<UserWalletId, List<CryptoCurrency>> = emptyMap()
private val selectedAppCurrencyFlow: StateFlow<AppCurrency> = createSelectedAppCurrencyFlow()
@ -107,6 +112,8 @@ internal class ManageTokensViewModel @Inject constructor(
)
init {
analyticsEventHandler.send(ManageTokens.ScreenOpened())
viewModelScope.launch(dispatchers.io) {
getWalletsUseCase()
.distinctUntilChanged()
@ -151,7 +158,7 @@ internal class ManageTokensViewModel @Inject constructor(
.distinctUntilChanged()
.collectLatest {
it.onRight { mapOfMissingDerivations ->
neededDerivations = mapOfMissingDerivations
currenciesToGenerateAddresses = mapOfMissingDerivations
withContext(dispatchers.main) { updateDerivation() }
}
}
@ -159,8 +166,8 @@ internal class ManageTokensViewModel @Inject constructor(
}
private fun updateDerivation() {
val totalNeeded = neededDerivations.values.sumOf { derivations -> derivations.size }
val walletsToDerive = neededDerivations.values
val totalNeeded = currenciesToGenerateAddresses.values.sumOf { derivations -> derivations.size }
val walletsToDerive = currenciesToGenerateAddresses.values
.filter { derivations -> derivations.isNotEmpty() }.size
uiState = stateFactory.updateDerivationNotification(
totalNeeded = totalNeeded,
@ -182,7 +189,8 @@ internal class ManageTokensViewModel @Inject constructor(
}
override fun onAddCustomTokensButtonClick() {
router.openCustomTokensScreen()
analyticsEventHandler.send(ManageTokens.ButtonCustomToken)
router.openAddCustomTokenScreen()
}
override fun onSearchQueryChange(query: String) {
@ -201,6 +209,13 @@ internal class ManageTokensViewModel @Inject constructor(
override fun onTokenItemButtonClick(token: TokenItemState.Loaded) {
when (token.availableAction.value) {
TokenButtonType.ADD, TokenButtonType.EDIT -> {
if (token.availableAction.value == TokenButtonType.ADD) {
analyticsEventHandler.send(ManageTokens.ButtonAdd(token.currencySymbol))
}
if (token.availableAction.value == TokenButtonType.EDIT) {
analyticsEventHandler.send(ManageTokens.ButtonEdit(token.currencySymbol))
}
uiState = uiState.copy(selectedToken = token)
val addedCurrenciesOnWallet = addedCurrenciesByWallet[selectedWallet] ?: listOf()
stateFactory.updateTokenNetworksOnTokenSelection(token, addedCurrenciesOnWallet)
@ -219,17 +234,21 @@ internal class ManageTokensViewModel @Inject constructor(
}
}
override fun onGenerateDerivationClick() {
if (neededDerivations.isNotEmpty()) {
override fun onGetAddressesClick() {
if (currenciesToGenerateAddresses.isNotEmpty()) {
viewModelScope.launch(dispatchers.io) {
val walletId = neededDerivations.keys.firstOrNull()
val currenciesToDerive = neededDerivations[walletId]
if (walletId == null || currenciesToDerive.isNullOrEmpty()) return@launch
derivePublicKeysUseCase(walletId, currenciesToDerive)
.onRight {
updateDerivationNotificationState()
fetchTokenListUseCase(userWalletId = walletId)
val cardCount = currenciesToGenerateAddresses.count { it.value.isNotEmpty() }
analyticsEventHandler.send(ManageTokens.ButtonGenerateAddresses(cardCount))
currenciesToGenerateAddresses.forEach { (walletId, currenciesToDerive) ->
if (currenciesToDerive.isNotEmpty()) {
derivePublicKeysUseCase(walletId, currenciesToDerive)
.onRight {
updateDerivationNotificationState()
fetchTokenListUseCase(userWalletId = walletId)
}
}
}
}
}
}
@ -247,8 +266,14 @@ internal class ManageTokensViewModel @Inject constructor(
if (!selectedWallet.isMultiCurrency || selectedWallet.isLocked) return
if (network.isAdded.value) {
analyticsEventHandler.send(
ManageTokens.TokenSwitcherChanged(token = token.currencySymbol, AnalyticsParam.OnOffState.Off),
)
toggleToken(token, network, selectedWallet)
} else {
analyticsEventHandler.send(
ManageTokens.TokenSwitcherChanged(token = token.currencySymbol, AnalyticsParam.OnOffState.On),
)
viewModelScope.launch(dispatchers.io) {
checkCompatibilityAndToggleToken(token, network, selectedWallet)
}
@ -298,7 +323,6 @@ internal class ManageTokensViewModel @Inject constructor(
"It is only null if Blockchain is Unknown, which mustn't happen here"
}
if (!network.isAdded.value) {
updateUi(token, network)
addedCurrenciesByWallet[selectedWallet]?.add(cryptoCurrency)
allAddedCurrencies.add(cryptoCurrency)
viewModelScope.launch(dispatchers.io) {
@ -307,13 +331,14 @@ internal class ManageTokensViewModel @Inject constructor(
currency = cryptoCurrency,
)
}
updateUi(token, network)
} else {
viewModelScope.launch(dispatchers.io) {
if (canBeRemovedAndShowAlertIfNot(selectedWallet.walletId, cryptoCurrency)) {
withContext(dispatchers.main) { updateUi(token, network) }
addedCurrenciesByWallet[selectedWallet]?.remove(cryptoCurrency)
allAddedCurrencies.remove(cryptoCurrency)
removeCurrencyUseCase(selectedWallet.walletId, cryptoCurrency)
withContext(dispatchers.main) { updateUi(token, network) }
}
}
}
@ -348,6 +373,7 @@ internal class ManageTokensViewModel @Inject constructor(
}
override fun onNonNativeNetworkHintClick() {
analyticsEventHandler.send(ManageTokens.NoticeNonNativeNetworkClicked)
uiState = stateFactory.getStateAndTriggerEvent(
state = uiState,
event = Event.ShowAlert(AlertState.NonNative),
@ -355,13 +381,8 @@ internal class ManageTokensViewModel @Inject constructor(
)
}
override fun onSelectWalletsClick() {
uiState = uiState.copy(
showChooseWalletScreen = true,
)
}
override fun onChooseWalletClick() {
analyticsEventHandler.send(ManageTokens.ButtonChooseWallet)
uiState = uiState.copy(
showChooseWalletScreen = true,
)
@ -374,6 +395,7 @@ internal class ManageTokensViewModel @Inject constructor(
}
override fun onWalletSelected(walletId: String) {
analyticsEventHandler.send(ManageTokens.WalletSelected(ManageTokens.WalletSelected.Source.MainToken))
viewModelScope.launch(dispatchers.io) {
selectWalletUseCase(UserWalletId(walletId))
}
@ -381,4 +403,8 @@ internal class ManageTokensViewModel @Inject constructor(
uiState.selectedToken?.let { onTokenItemButtonClick(it) }
uiState = stateFactory.updateSelectedWallet(selectedWalletId = selectedWallet?.walletId?.stringValue)
}
override fun onEmptySearchResult(query: String) {
analyticsEventHandler.send(ManageTokens.TokenIsNotFound(query))
}
}

View file

@ -15,11 +15,11 @@ import com.tangem.core.navigation.NavigationAction
import com.tangem.core.navigation.ReduxNavController
import com.tangem.managetokens.ManageTokensFragment
import com.tangem.managetokens.presentation.common.state.ChooseWalletState
import com.tangem.managetokens.presentation.customtokens.ui.ChooseDerivationScreen
import com.tangem.managetokens.presentation.customtokens.ui.ChooseNetworkCustomScreen
import com.tangem.managetokens.presentation.customtokens.ui.CustomTokensChooseWalletScreen
import com.tangem.managetokens.presentation.customtokens.ui.CustomTokensScreen
import com.tangem.managetokens.presentation.customtokens.viewmodels.CustomTokensViewModel
import com.tangem.managetokens.presentation.addcustomtoken.ui.ChooseDerivationScreen
import com.tangem.managetokens.presentation.addcustomtoken.ui.ChooseNetworkCustomScreen
import com.tangem.managetokens.presentation.addcustomtoken.ui.CustomTokensChooseWalletScreen
import com.tangem.managetokens.presentation.addcustomtoken.ui.AddCustomTokenScreen
import com.tangem.managetokens.presentation.addcustomtoken.viewmodels.AddCustomTokenViewModel
import com.tangem.managetokens.presentation.managetokens.ui.ManageTokensScreen
import com.tangem.managetokens.presentation.managetokens.viewmodels.ManageTokensViewModel
import kotlin.properties.Delegates
@ -46,21 +46,22 @@ internal class DefaultManageTokensRouter(
}
navigation(
startDestination = ManageTokensRoute.CustomTokens.Main.route,
route = ManageTokensRoute.CustomTokens.route,
startDestination = ManageTokensRoute.AddCustomToken.Main.route,
route = ManageTokensRoute.AddCustomToken.route,
) {
composable(
ManageTokensRoute.CustomTokens.Main.route,
ManageTokensRoute.AddCustomToken.Main.route,
) {
val viewModel = hiltViewModel<CustomTokensViewModel>(viewModelStoreOwner).apply {
val viewModel = hiltViewModel<AddCustomTokenViewModel>(viewModelStoreOwner).apply {
router = this@DefaultManageTokensRouter
}
CustomTokensScreen(state = viewModel.uiState)
LocalLifecycleOwner.current.lifecycle.addObserver(viewModel)
AddCustomTokenScreen(state = viewModel.uiState)
}
composable(
ManageTokensRoute.CustomTokens.ChooseNetwork.route,
ManageTokensRoute.AddCustomToken.ChooseNetwork.route,
) {
val viewModel = hiltViewModel<CustomTokensViewModel>(viewModelStoreOwner).apply {
val viewModel = hiltViewModel<AddCustomTokenViewModel>(viewModelStoreOwner).apply {
router = this@DefaultManageTokensRouter
}
ChooseNetworkCustomScreen(
@ -68,9 +69,9 @@ internal class DefaultManageTokensRouter(
)
}
composable(
ManageTokensRoute.CustomTokens.ChooseDerivation.route,
ManageTokensRoute.AddCustomToken.ChooseDerivation.route,
) {
val viewModel = hiltViewModel<CustomTokensViewModel>(viewModelStoreOwner).apply {
val viewModel = hiltViewModel<AddCustomTokenViewModel>(viewModelStoreOwner).apply {
router = this@DefaultManageTokensRouter
}
ChooseDerivationScreen(
@ -78,9 +79,9 @@ internal class DefaultManageTokensRouter(
)
}
composable(
ManageTokensRoute.CustomTokens.ChooseWallet.route,
ManageTokensRoute.AddCustomToken.ChooseWallet.route,
) {
val viewModel = hiltViewModel<CustomTokensViewModel>(viewModelStoreOwner).apply {
val viewModel = hiltViewModel<AddCustomTokenViewModel>(viewModelStoreOwner).apply {
router = this@DefaultManageTokensRouter
}
CustomTokensChooseWalletScreen(
@ -103,19 +104,19 @@ internal class DefaultManageTokensRouter(
navController.navigate(ManageTokensRoute.ManageTokens.route)
}
override fun openCustomTokensScreen() {
navController.navigate(ManageTokensRoute.CustomTokens.route)
override fun openAddCustomTokenScreen() {
navController.navigate(ManageTokensRoute.AddCustomToken.route)
}
override fun openCustomTokensChooseNetwork() {
navController.navigate(ManageTokensRoute.CustomTokens.ChooseNetwork.route)
override fun openCustomTokenChooseNetwork() {
navController.navigate(ManageTokensRoute.AddCustomToken.ChooseNetwork.route)
}
override fun openCustomTokensChooseDerivation() {
navController.navigate(ManageTokensRoute.CustomTokens.ChooseDerivation.route)
override fun openCustomTokenChooseDerivation() {
navController.navigate(ManageTokensRoute.AddCustomToken.ChooseDerivation.route)
}
override fun openCustomTokensChooseWallet() {
navController.navigate(ManageTokensRoute.CustomTokens.ChooseWallet.route)
override fun openCustomTokenChooseWallet() {
navController.navigate(ManageTokensRoute.AddCustomToken.ChooseWallet.route)
}
}

View file

@ -6,6 +6,7 @@ import com.tangem.core.navigation.AppScreen
import com.tangem.features.managetokens.navigation.ManageTokensRouter
internal interface InnerManageTokensRouter : ManageTokensRouter {
/**
* Initialize router
**/
@ -19,12 +20,15 @@ internal interface InnerManageTokensRouter : ManageTokensRouter {
/** Open manage tokens screen */
fun openManageTokensScreen()
/** Open custom tokens screen */
fun openCustomTokensScreen()
/** Open add custom token screen */
fun openAddCustomTokenScreen()
fun openCustomTokensChooseNetwork()
/** Open custom token choose network screen */
fun openCustomTokenChooseNetwork()
fun openCustomTokensChooseDerivation()
/** Open custom token choose derivation screen */
fun openCustomTokenChooseDerivation()
fun openCustomTokensChooseWallet()
/** Open custom token choose wallet screen */
fun openCustomTokenChooseWallet()
}

View file

@ -10,10 +10,10 @@ internal sealed class ManageTokensRoute(val route: String) {
object ManageTokens : ManageTokensRoute(route = "manage_tokens")
object CustomTokens : ManageTokensRoute(route = "manage_tokens/custom_tokens") {
object Main : ManageTokensRoute(CustomTokens.route + "/main")
object ChooseNetwork : ManageTokensRoute(CustomTokens.route + "/choose_network")
object ChooseWallet : ManageTokensRoute(CustomTokens.route + "/choose_wallet")
object ChooseDerivation : ManageTokensRoute(CustomTokens.route + "/choose_derivation")
object AddCustomToken : ManageTokensRoute(route = "manage_tokens/add_custom_token") {
object Main : ManageTokensRoute(AddCustomToken.route + "/main")
object ChooseNetwork : ManageTokensRoute(AddCustomToken.route + "/choose_network")
object ChooseWallet : ManageTokensRoute(AddCustomToken.route + "/choose_wallet")
object ChooseDerivation : ManageTokensRoute(AddCustomToken.route + "/choose_derivation")
}
}

View file

@ -4,30 +4,23 @@ import arrow.core.getOrElse
import com.tangem.domain.card.DerivePublicKeysUseCase
import com.tangem.domain.tokens.AddCryptoCurrenciesUseCase
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.feature.referral.domain.converter.TokensConverter
import com.tangem.feature.referral.domain.models.ReferralData
import com.tangem.feature.referral.domain.models.TokenData
import com.tangem.features.tester.api.TesterFeatureToggles
import com.tangem.lib.crypto.DerivationManager
import com.tangem.lib.crypto.UserWalletManager
import timber.log.Timber
@Suppress("LongParameterList")
internal class ReferralInteractorImpl(
private val repository: ReferralRepository,
private val derivationManager: DerivationManager,
private val userWalletManager: UserWalletManager,
private val tokensConverter: TokensConverter,
private val derivePublicKeysUseCase: DerivePublicKeysUseCase,
private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase,
private val addCryptoCurrenciesUseCase: AddCryptoCurrenciesUseCase,
private val testerFeatureToggles: TesterFeatureToggles,
) : ReferralInteractor {
private val tokensForReferral = mutableListOf<TokenData>()
override val isDemoMode: Boolean
get() = repository.isDemoMode
override val isDemoMode: Boolean get() = repository.isDemoMode
override suspend fun getReferralStatus(): ReferralData {
val referralData = repository.getReferralData(userWalletManager.getWalletId())
@ -38,19 +31,9 @@ internal class ReferralInteractorImpl(
}
override suspend fun startReferral(): ReferralData {
return if (tokensForReferral.isNotEmpty()) {
if (testerFeatureToggles.isDerivePublicKeysRefactoringEnabled) {
startReferralNew(tokenData = tokensForReferral.first())
} else {
// TODO: delete [REDACTED_JIRA]
startReferralLegacy(tokenData = tokensForReferral.first())
}
} else {
error("Tokens for ref is empty")
}
}
if (tokensForReferral.isEmpty()) error("Tokens for ref is empty")
private suspend fun startReferralNew(tokenData: TokenData): ReferralData {
val tokenData = tokensForReferral.first()
val userWallet = getSelectedWalletSyncUseCase().getOrElse {
error("Failed to get selected wallet: $it")
}
@ -79,18 +62,6 @@ internal class ReferralInteractorImpl(
)
}
private suspend fun startReferralLegacy(tokenData: TokenData): ReferralData {
val currency = tokensConverter.convert(tokenData)
val derivationPath = derivationManager.deriveAndAddTokens(currency)
val publicAddress = userWalletManager.getWalletAddress(currency.networkId, derivationPath)
return repository.startReferral(
walletId = userWalletManager.getWalletId(),
networkId = currency.networkId,
tokenId = currency.id,
address = publicAddress,
)
}
private fun saveReferralTokens(tokens: List<TokenData>) {
tokensForReferral.clear()
tokensForReferral.addAll(tokens)

View file

@ -1,34 +0,0 @@
package com.tangem.feature.referral.domain.converter
import com.tangem.feature.referral.domain.models.TokenData
import com.tangem.lib.crypto.models.Currency
import com.tangem.lib.crypto.models.Currency.NativeToken
import com.tangem.lib.crypto.models.Currency.NonNativeToken
import com.tangem.utils.converter.Converter
import javax.inject.Inject
class TokensConverter @Inject constructor() : Converter<TokenData, Currency> {
override fun convert(value: TokenData): Currency {
return if (value.decimalCount != null &&
value.contractAddress != null
) {
NonNativeToken(
id = value.id,
name = value.name,
symbol = value.symbol,
networkId = value.networkId,
contractAddress = value.contractAddress,
decimalCount = value.decimalCount,
)
} else {
NativeToken(
id = value.id,
name = value.name,
symbol = value.symbol,
networkId = value.networkId,
)
}
}
}

View file

@ -6,9 +6,6 @@ import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.feature.referral.domain.ReferralInteractor
import com.tangem.feature.referral.domain.ReferralInteractorImpl
import com.tangem.feature.referral.domain.ReferralRepository
import com.tangem.feature.referral.domain.converter.TokensConverter
import com.tangem.features.tester.api.TesterFeatureToggles
import com.tangem.lib.crypto.DerivationManager
import com.tangem.lib.crypto.UserWalletManager
import dagger.Module
import dagger.Provides
@ -24,23 +21,17 @@ class ReferralDomainModule {
@ViewModelScoped
fun provideReferralInteractor(
referralRepository: ReferralRepository,
derivationManager: DerivationManager,
userWalletManager: UserWalletManager,
tokensConverter: TokensConverter,
derivePublicKeysUseCase: DerivePublicKeysUseCase,
getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase,
addCryptoCurrenciesUseCase: AddCryptoCurrenciesUseCase,
testerFeatureToggles: TesterFeatureToggles,
): ReferralInteractor {
return ReferralInteractorImpl(
repository = referralRepository,
derivationManager = derivationManager,
userWalletManager = userWalletManager,
tokensConverter = tokensConverter,
derivePublicKeysUseCase = derivePublicKeysUseCase,
getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase,
addCryptoCurrenciesUseCase = addCryptoCurrenciesUseCase,
testerFeatureToggles = testerFeatureToggles,
)
}
}

View file

@ -9,5 +9,8 @@ interface SendRouter {
companion object {
const val CRYPTO_CURRENCY_KEY = "send_crypto_currency"
const val USER_WALLET_ID_KEY = "send_user_wallet_id"
const val TRANSACTION_ID_KEY = "send_transaction_id"
const val AMOUNT_KEY = "send_amount"
const val DESTINATION_ADDRESS_KEY = "send_destination_address"
}
}

View file

@ -45,6 +45,14 @@ dependencies {
implementation(projects.core.ui)
implementation(projects.core.utils)
implementation(projects.core.navigation)
implementation(projects.core.analytics)
implementation(projects.core.analytics.models)
/** Common */
implementation(projects.common)
/** Libs */
implementation(projects.libs.crypto)
/** Domain modules */
implementation(projects.domain.models)
@ -59,6 +67,8 @@ dependencies {
implementation(projects.domain.txhistory.models)
implementation(projects.domain.transaction)
implementation(projects.domain.card)
implementation(projects.domain.balanceHiding)
implementation(projects.domain.balanceHiding.models)
/** Feature modules */
implementation(projects.features.send.api)

View file

@ -8,6 +8,7 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope
import arrow.core.getOrElse
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.ui.components.SystemBarsEffect
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.screen.ComposeFragment
@ -41,6 +42,9 @@ internal class SendFragment : ComposeFragment() {
@Inject
lateinit var listenToQrScanningUseCase: ListenToQrScanningUseCase
@Inject
lateinit var analyticsEventsHandler: AnalyticsEventHandler
private val viewModel by viewModels<SendViewModel>()
private val innerSendRouter: InnerSendRouter
get() = requireNotNull(router as? InnerSendRouter) {
@ -50,10 +54,14 @@ internal class SendFragment : ComposeFragment() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycle.addObserver(viewModel)
val isEditingDisabled = arguments?.getString(SendRouter.TRANSACTION_ID_KEY) != null
viewModel.setRouter(
innerSendRouter,
StateRouter(
fragmentManager = WeakReference(parentFragmentManager),
isEditingDisabled = isEditingDisabled,
analyticsEventsHandler = analyticsEventsHandler,
),
)
listenToQrCode()

View file

@ -0,0 +1,136 @@
package com.tangem.features.send.impl.presentation.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam.Key.SOURCE
import com.tangem.core.analytics.models.AnalyticsParam.Key.TYPE
import com.tangem.core.analytics.models.AnalyticsParam.Key.VALIDATION
/**
* Send screen analytics
*/
internal sealed class SendAnalyticEvents(
event: String,
params: Map<String, String> = mapOf(),
) : AnalyticsEvent(category = "Token / Send", event = event, params = params) {
/** Send screen opened */
object SendOpened : SendAnalyticEvents(event = "Send Screen Opened")
/** Next button clicked */
data class NextButtonClicked(val source: SendScreenSource) : SendAnalyticEvents(
event = "Button - Next",
params = mapOf(SOURCE to source.name),
)
/** Back button clicked */
data class BackButtonClicked(val source: SendScreenSource) : SendAnalyticEvents(
event = "Button - Back",
params = mapOf(SOURCE to source.name),
)
// region Address
/** Recipient address screen opened */
object AddressScreenOpened : SendAnalyticEvents(event = "Address Screen Opened")
/** Address to send entered */
data class AddressEntered(val source: EnterAddressSource, val isValid: Boolean) : SendAnalyticEvents(
event = "Address Entered",
params = mapOf(
SOURCE to source.name,
VALIDATION to if (isValid) "Success" else "Fail",
),
)
/** Paste from clipboard button clicked */
data class PasteButtonClicked(val type: PasteType) : SendAnalyticEvents(
event = "Button - Paste",
params = mapOf(
TYPE to type.name,
),
)
/** Qr Code button clicked */
object QrCodeButtonClicked : SendAnalyticEvents(event = "Button - QR Code")
// endregion
// region Amount
/** Amount screen opened */
object AmountScreenOpened : SendAnalyticEvents(event = "Amount Screen Opened")
/** Selected currency */
data class SelectedCurrency(val type: SelectedCurrencyType) : SendAnalyticEvents(
event = "Selected Currency",
params = mapOf(TYPE to type.value),
)
/** Currency selector button clicked */
object SwapCurrencyButtonClicked : SendAnalyticEvents(event = "Button - Swap Currency")
// endregion
// region Fee
/** Fee screen opened */
object FeeScreenOpened : SendAnalyticEvents(event = "Fee Screen Opened")
/** Selected fee (send after next screen opened) */
data class SelectedFee(val fee: String) : SendAnalyticEvents(
event = "Fee Selected",
params = mapOf("Commission" to fee),
)
/** Custom fee selected */
object CustomFeeButtonClicked : SendAnalyticEvents(event = "Custom Fee Clicked")
/** Custom fee edited */
object GasPriceInserter : SendAnalyticEvents(event = "Gas Price Inserted")
/** Subtract from amount selector switched (send after next screen opened) */
object SubtractFromAmount : SendAnalyticEvents(event = "Subtract from Amount")
// endregion
// region Confirmation
/** Confirmation screen opened */
object ConfirmationScreenOpened : SendAnalyticEvents(event = "Confirm Screen Opened")
/** Send transaction button clicked */
object SendButtonClicked : SendAnalyticEvents(event = "Button - Send")
/** Screen reopened from confirmation screen */
data class ScreenReopened(val source: SendScreenSource) : SendAnalyticEvents(
event = "Screen Reopened",
params = mapOf(SOURCE to source.name),
)
// endregion
// region Transaction Result
/** Transaction send screen opened */
object TransactionScreenOpened : SendAnalyticEvents(event = "Transaction Sent Screen Opened")
/** Share button clicked */
object ShareButtonClicked : SendAnalyticEvents(event = "Button - Share")
/** Expore button clicked */
object ExploreButtonClicked : SendAnalyticEvents(event = "Button - Explore")
// endregion
}
internal enum class SendScreenSource {
Address,
Amount,
Fee,
}
internal enum class EnterAddressSource {
QRCode,
PasteButton,
RecentAddress,
}
internal enum class PasteType {
Address,
Memo,
}
internal enum class SelectedCurrencyType(val value: String) {
Token("Token"),
AppCurrency("App Currency"),
}

View file

@ -0,0 +1,44 @@
package com.tangem.features.send.impl.presentation.analytics.utils
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.features.send.impl.presentation.analytics.SelectedCurrencyType
import com.tangem.features.send.impl.presentation.analytics.SendAnalyticEvents
import com.tangem.features.send.impl.presentation.state.SendUiState
import com.tangem.features.send.impl.presentation.state.SendUiStateType
import com.tangem.features.send.impl.presentation.state.fee.FeeSelectorState
import com.tangem.features.send.impl.presentation.state.fee.FeeType
internal class SendOnNextScreenAnalyticSender(
private val analyticsEventHandler: AnalyticsEventHandler,
) {
fun send(prevScreen: SendUiStateType, state: SendUiState) {
when (prevScreen) {
SendUiStateType.Fee -> {
val feeState = state.feeState ?: return
val feeSelectorState = feeState.feeSelectorState as? FeeSelectorState.Content
feeSelectorState?.selectedFee?.let { selectedFee ->
val isCustomFeeEdited = feeState.fee?.amount?.value != feeSelectorState.fees.normal.amount.value
if (selectedFee == FeeType.Custom && isCustomFeeEdited) {
analyticsEventHandler.send(SendAnalyticEvents.GasPriceInserter)
}
analyticsEventHandler.send(SendAnalyticEvents.SelectedFee(selectedFee.name))
}
if (feeState.isSubtract) {
analyticsEventHandler.send(SendAnalyticEvents.SubtractFromAmount)
}
}
SendUiStateType.Amount -> {
val isFiatSelected = state.amountState?.amountTextField?.isFiatValue ?: return
val selectedCurrency = if (isFiatSelected) {
SelectedCurrencyType.Token
} else {
SelectedCurrencyType.AppCurrency
}
analyticsEventHandler.send(
SendAnalyticEvents.SelectedCurrency(selectedCurrency),
)
}
else -> Unit
}
}
}

View file

@ -0,0 +1,26 @@
package com.tangem.features.send.impl.presentation.analytics.utils
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.features.send.impl.presentation.analytics.EnterAddressSource
import com.tangem.features.send.impl.presentation.analytics.PasteType
import com.tangem.features.send.impl.presentation.analytics.SendAnalyticEvents
internal class SendRecipientAnalyticsSender(
private val analyticsEventHandler: AnalyticsEventHandler,
) {
fun sendAddressAnalytics(type: EnterAddressSource?, isValidAddress: Boolean) {
type?.let {
if (type == EnterAddressSource.PasteButton) {
analyticsEventHandler.send(SendAnalyticEvents.PasteButtonClicked(PasteType.Address))
}
analyticsEventHandler.send(SendAnalyticEvents.AddressEntered(it, isValidAddress))
}
}
fun sendMemoAnalytics(isPasted: Boolean) {
if (isPasted) {
analyticsEventHandler.send(SendAnalyticEvents.PasteButtonClicked(PasteType.Memo))
}
}
}

View file

@ -23,7 +23,9 @@ internal class SendEventStateFactory(
private val clickIntents: SendClickIntents,
private val feeStateFactory: FeeStateFactory,
) {
private val sendTransactionErrorConverter by lazy { SendTransactionAlertConverter(clickIntents) }
private val sendTransactionErrorConverter by lazy(LazyThreadSafetyMode.NONE) {
SendTransactionAlertConverter(clickIntents)
}
fun onConsumeEventState(): SendUiState {
return currentStateProvider().copy(event = consumedEvent())
@ -48,10 +50,10 @@ internal class SendEventStateFactory(
is TransactionFee.Single -> fee.normal
is TransactionFee.Choosable -> {
when (feeSelector.selectedFee) {
FeeType.SLOW -> fee.minimum
FeeType.MARKET -> fee.normal
FeeType.FAST -> fee.priority
FeeType.CUSTOM -> return state
FeeType.Slow -> fee.minimum
FeeType.Market -> fee.normal
FeeType.Fast -> fee.priority
FeeType.Custom -> return state
}
}
}

View file

@ -4,10 +4,11 @@ import com.tangem.blockchain.common.Blockchain
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import com.tangem.utils.Provider
import com.tangem.utils.isNullOrZero
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@ -21,7 +22,7 @@ internal class SendNotificationFactory(
private val coinCryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val currentStateProvider: Provider<SendUiState>,
private val userWalletProvider: Provider<UserWallet>,
private val walletManagersFacade: WalletManagersFacade,
private val currencyChecksRepository: CurrencyChecksRepository,
private val clickIntents: SendClickIntents,
) {
@ -39,6 +40,7 @@ internal class SendNotificationFactory(
addExceedBalanceNotification(feeAmount, sendAmount)
addInvalidAmountNotification(feeState.isSubtract, sendAmount)
addMinimumAmountErrorNotification(feeAmount, sendAmount)
addDustWarningNotification(feeAmount, sendAmount)
addReserveAmountErrorNotification(recipientState.addressTextField.value)
addTransactionLimitErrorNotification(feeAmount, sendAmount)
// warnings
@ -117,12 +119,12 @@ internal class SendNotificationFactory(
private suspend fun MutableList<SendNotification>.addReserveAmountErrorNotification(recipientAddress: String) {
val userWalletId = userWalletProvider().walletId
val cryptoCurrency = cryptoCurrencyStatusProvider().currency
val isAccountFunded = walletManagersFacade.checkIfAccountFunded(
val isAccountFunded = currencyChecksRepository.checkIfAccountFunded(
userWalletId,
cryptoCurrency.network,
recipientAddress,
)
val minimumAmount = walletManagersFacade.getReserveAmount(userWalletId, cryptoCurrency.network)
val minimumAmount = currencyChecksRepository.getReserveAmount(userWalletId, cryptoCurrency.network)
if (!isAccountFunded && minimumAmount != null && minimumAmount > BigDecimal.ZERO) {
add(
SendNotification.Error.ReserveAmountError(
@ -141,7 +143,7 @@ internal class SendNotificationFactory(
) {
val userWalletId = userWalletProvider().walletId
val cryptoCurrency = cryptoCurrencyStatusProvider().currency
val utxoLimit = walletManagersFacade.checkUtxoAmountLimit(
val utxoLimit = currencyChecksRepository.checkUtxoAmountLimit(
userWalletId = userWalletId,
network = cryptoCurrency.network,
amount = receivedAmount,
@ -173,7 +175,7 @@ internal class SendNotificationFactory(
} else {
feeAmount + receivedAmount
}
val currencyDeposit = walletManagersFacade.getExistentialDeposit(
val currencyDeposit = currencyChecksRepository.getExistentialDeposit(
userWalletId,
cryptoCurrency.network,
)
@ -210,6 +212,29 @@ internal class SendNotificationFactory(
}
}
private suspend fun MutableList<SendNotification>.addDustWarningNotification(
feeAmount: BigDecimal,
receivedAmount: BigDecimal,
) {
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val dustValue = currencyChecksRepository.getDustValue(
userWalletProvider().walletId,
cryptoCurrencyStatus.currency.network,
)
val balance = cryptoCurrencyStatus.value.amount ?: BigDecimal.ZERO
if (dustValue != null && !balance.isNullOrZero() && receivedAmount < balance) {
val totalAmount = feeAmount + receivedAmount
val change = balance - totalAmount
val isChangeLowerThanDust = change < dustValue && change != BigDecimal.ZERO
val isShowWarning = totalAmount < dustValue || isChangeLowerThanDust
if (isShowWarning) {
add(
SendNotification.Error.MinimumAmountError(dustValue.toPlainString()),
)
}
}
}
companion object {
private const val CARDANO_MINIMUM = "1"
private const val DOGECOIN_MINIMUM = "0.01"

View file

@ -14,10 +14,8 @@ import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.usecase.ValidateWalletMemoUseCase
import com.tangem.features.send.impl.R
import com.tangem.features.send.impl.presentation.domain.AvailableWallet
import com.tangem.features.send.impl.presentation.state.amount.SendAmountCurrencyConverter
import com.tangem.features.send.impl.presentation.state.amount.SendAmountStateConverter
import com.tangem.features.send.impl.presentation.state.fee.SendFeeStateConverter
import com.tangem.features.send.impl.presentation.state.fields.SendAmountFieldChangeConverter
import com.tangem.features.send.impl.presentation.state.fields.SendAmountFieldConverter
import com.tangem.features.send.impl.presentation.state.recipient.SendRecipientListConverter
import com.tangem.features.send.impl.presentation.state.recipient.SendRecipientStateConverter
@ -38,29 +36,17 @@ internal class SendStateFactory(
private val validateWalletMemoUseCase: ValidateWalletMemoUseCase,
private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase,
) {
private val iconStateConverter by lazy(::CryptoCurrencyToIconStateConverter)
private val amountFieldConverter by lazy {
private val amountFieldConverter by lazy(LazyThreadSafetyMode.NONE) {
SendAmountFieldConverter(
clickIntents = clickIntents,
cryptoCurrencyStatusProvider = cryptoCurrencyStatusProvider,
appCurrencyProvider = appCurrencyProvider,
)
}
private val amountFieldChangeConverter by lazy {
SendAmountFieldChangeConverter(
currentStateProvider = currentStateProvider,
cryptoCurrencyStatusProvider = cryptoCurrencyStatusProvider,
)
}
private val amountCurrencyConverter by lazy {
SendAmountCurrencyConverter(
currentStateProvider = currentStateProvider,
cryptoCurrencyStatusProvider = cryptoCurrencyStatusProvider,
)
}
private val amountStateConverter by lazy {
private val amountStateConverter by lazy(LazyThreadSafetyMode.NONE) {
SendAmountStateConverter(
appCurrencyProvider = appCurrencyProvider,
iconStateConverter = iconStateConverter,
@ -69,20 +55,20 @@ internal class SendStateFactory(
cryptoCurrencyStatusProvider = cryptoCurrencyStatusProvider,
)
}
private val recipientStateConverter by lazy {
private val recipientStateConverter by lazy(LazyThreadSafetyMode.NONE) {
SendRecipientStateConverter(
clickIntents = clickIntents,
cryptoCurrencyStatusProvider = cryptoCurrencyStatusProvider,
)
}
private val feeStateConverter by lazy {
private val feeStateConverter by lazy(LazyThreadSafetyMode.NONE) {
SendFeeStateConverter(
appCurrencyProvider = appCurrencyProvider,
cryptoCurrencyStatusProvider = cryptoCurrencyStatusProvider,
)
}
private val recipientListStateConverter by lazy {
private val recipientListStateConverter by lazy(LazyThreadSafetyMode.NONE) {
SendRecipientListConverter(
currentStateProvider = currentStateProvider,
cryptoCurrencyStatusProvider = cryptoCurrencyStatusProvider,
@ -92,24 +78,34 @@ internal class SendStateFactory(
// region UI states
fun getInitialState(): SendUiState = SendUiState(
clickIntents = clickIntents,
currentState = MutableStateFlow(SendUiStateType.Amount),
currentState = MutableStateFlow(SendUiStateType.None),
event = consumedEvent(),
isEditingDisabled = false,
isBalanceHidden = false,
)
fun getReadyState(): SendUiState {
val state = currentStateProvider()
return state.copy(
amountState = state.amountState ?: amountStateConverter.convert(Unit),
recipientState = state.recipientState ?: recipientStateConverter.convert(Unit),
amountState = state.amountState ?: amountStateConverter.convert(""),
recipientState = state.recipientState ?: recipientStateConverter.convert(""),
feeState = state.feeState ?: feeStateConverter.convert(Unit),
)
}
//endregion
//region amount state clicks
fun getOnAmountValueChange(value: String) = amountFieldChangeConverter.convert(value)
fun getReadyState(amount: String, destinationAddress: String): SendUiState {
val state = currentStateProvider()
return state.copy(
amountState = state.amountState ?: amountStateConverter.convert(amount),
recipientState = state.recipientState ?: recipientStateConverter.convert(destinationAddress),
feeState = state.feeState ?: feeStateConverter.convert(Unit),
isEditingDisabled = true,
)
}
fun getOnCurrencyChangedState(isFiat: Boolean) = amountCurrencyConverter.convert(isFiat)
fun getOnHideBalanceState(isBalanceHidden: Boolean): SendUiState {
return currentStateProvider().copy(isBalanceHidden = isBalanceHidden)
}
//endregion
//region recipient
@ -125,12 +121,13 @@ internal class SendStateFactory(
)
}
fun onRecipientAddressValueChange(value: String): SendUiState {
fun onRecipientAddressValueChange(value: String, isXAddress: Boolean = false): SendUiState {
val state = currentStateProvider()
val recipientState = state.recipientState ?: return state
return state.copy(
recipientState = recipientState.copy(
addressTextField = recipientState.addressTextField.copy(value = value),
memoTextField = recipientState.memoTextField?.copy(isEnabled = !isXAddress),
),
)
}
@ -205,6 +202,20 @@ internal class SendStateFactory(
isValidating = false,
memoTextField = recipientState.memoTextField?.copy(
isError = value.isNotEmpty() && !isValidMemo,
isEnabled = true,
),
),
)
}
fun getOnXAddressMemoState(): SendUiState {
val state = currentStateProvider()
val recipientState = state.recipientState ?: return state
return state.copy(
recipientState = recipientState.copy(
memoTextField = recipientState.memoTextField?.copy(
value = "",
isEnabled = false,
),
),
)

View file

@ -18,6 +18,7 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import java.math.BigDecimal
/**
@ -26,12 +27,14 @@ import java.math.BigDecimal
@Immutable
internal data class SendUiState(
val clickIntents: SendClickIntents,
val isEditingDisabled: Boolean,
val amountState: SendStates.AmountState? = null,
val recipientState: SendStates.RecipientState? = null,
val feeState: SendStates.FeeState? = null,
val sendState: SendStates.SendState = SendStates.SendState(),
val recipientList: MutableStateFlow<PagingData<SendRecipientListContent>> = MutableStateFlow(PagingData.empty()),
val currentState: MutableStateFlow<SendUiStateType>,
val currentState: StateFlow<SendUiStateType>,
val isBalanceHidden: Boolean,
val event: StateEvent<SendEvent>,
)
@ -99,6 +102,7 @@ internal sealed class SendStates {
}
enum class SendUiStateType {
None,
Amount,
Recipient,
Fee,

View file

@ -1,60 +1,119 @@
package com.tangem.features.send.impl.presentation.state
import androidx.fragment.app.FragmentManager
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.features.send.impl.presentation.analytics.SendAnalyticEvents
import com.tangem.features.send.impl.presentation.analytics.SendScreenSource
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import java.lang.ref.WeakReference
internal class StateRouter(
private val fragmentManager: WeakReference<FragmentManager>,
private val analyticsEventsHandler: AnalyticsEventHandler,
private val isEditingDisabled: Boolean,
) {
var currentState: MutableStateFlow<SendUiStateType> = MutableStateFlow(SendUiStateType.Recipient)
private set
private var mutableCurrentState: MutableStateFlow<SendUiStateType> = MutableStateFlow(
if (isEditingDisabled) {
SendUiStateType.None
} else {
SendUiStateType.Recipient
},
)
val currentState: StateFlow<SendUiStateType> = mutableCurrentState
fun popBackStack() {
fragmentManager.get()?.popBackStack()
}
fun onBackClick() {
when (currentState.value) {
SendUiStateType.Recipient -> popBackStack()
SendUiStateType.Amount -> showRecipient()
SendUiStateType.Fee -> showAmount()
SendUiStateType.Send -> showFee()
fun onBackClick(isSuccess: Boolean = false) {
when {
isSuccess -> popBackStack()
isEditingDisabled -> when (currentState.value) {
SendUiStateType.Send -> {
analyticsEventsHandler.send(SendAnalyticEvents.BackButtonClicked(SendScreenSource.Fee))
showFee()
}
else -> popBackStack()
}
else -> when (currentState.value) {
SendUiStateType.Amount -> {
analyticsEventsHandler.send(SendAnalyticEvents.BackButtonClicked(SendScreenSource.Address))
showRecipient()
}
SendUiStateType.Fee -> {
analyticsEventsHandler.send(SendAnalyticEvents.BackButtonClicked(SendScreenSource.Amount))
showAmount()
}
SendUiStateType.Send -> {
analyticsEventsHandler.send(SendAnalyticEvents.BackButtonClicked(SendScreenSource.Fee))
showFee()
}
else -> popBackStack()
}
}
}
fun onNextClick() {
fun onNextClick(): SendUiStateType {
val prevState = currentState.value
when (currentState.value) {
SendUiStateType.Recipient -> showAmount()
SendUiStateType.Amount -> showFee()
SendUiStateType.Fee -> showSend()
SendUiStateType.Send -> onBackClick()
SendUiStateType.Recipient -> {
analyticsEventsHandler.send(SendAnalyticEvents.NextButtonClicked(SendScreenSource.Amount))
showAmount()
}
SendUiStateType.Amount -> {
analyticsEventsHandler.send(SendAnalyticEvents.NextButtonClicked(SendScreenSource.Fee))
showFee()
}
SendUiStateType.Fee -> {
analyticsEventsHandler.send(SendAnalyticEvents.NextButtonClicked(SendScreenSource.Fee))
showSend()
}
SendUiStateType.Send -> {
onBackClick()
}
else -> popBackStack()
}
return prevState
}
fun onPrevClick() {
when (currentState.value) {
SendUiStateType.Recipient -> popBackStack()
SendUiStateType.Amount -> showRecipient()
SendUiStateType.Fee -> showAmount()
SendUiStateType.Send -> popBackStack()
if (isEditingDisabled) {
popBackStack()
} else {
when (currentState.value) {
SendUiStateType.Amount -> {
analyticsEventsHandler.send(SendAnalyticEvents.BackButtonClicked(SendScreenSource.Amount))
showRecipient()
}
SendUiStateType.Fee -> {
analyticsEventsHandler.send(SendAnalyticEvents.BackButtonClicked(SendScreenSource.Fee))
showAmount()
}
else -> popBackStack()
}
}
}
fun showAmount() {
currentState.update { SendUiStateType.Amount }
analyticsEventsHandler.send(SendAnalyticEvents.AmountScreenOpened)
mutableCurrentState.update { SendUiStateType.Amount }
}
fun showRecipient() {
currentState.update { SendUiStateType.Recipient }
analyticsEventsHandler.send(SendAnalyticEvents.AddressScreenOpened)
mutableCurrentState.update { SendUiStateType.Recipient }
}
fun showFee() {
currentState.update { SendUiStateType.Fee }
analyticsEventsHandler.send(SendAnalyticEvents.FeeScreenOpened)
mutableCurrentState.update { SendUiStateType.Fee }
}
private fun showSend() {
currentState.update { SendUiStateType.Send }
analyticsEventsHandler.send(SendAnalyticEvents.ConfirmationScreenOpened)
mutableCurrentState.update { SendUiStateType.Send }
}
}

View file

@ -0,0 +1,44 @@
package com.tangem.features.send.impl.presentation.state.amount
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.send.impl.presentation.state.SendUiState
import com.tangem.features.send.impl.presentation.state.fields.SendAmountFieldChangeConverter
import com.tangem.features.send.impl.presentation.state.fields.SendAmountFieldMaxAmountConverter
import com.tangem.utils.Provider
/**
* Factory to produce amount state for [SendUiState]
*/
internal class AmountStateFactory(
private val currentStateProvider: Provider<SendUiState>,
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
) {
private val amountFieldChangeConverter by lazy(LazyThreadSafetyMode.NONE) {
SendAmountFieldChangeConverter(
currentStateProvider = currentStateProvider,
cryptoCurrencyStatusProvider = cryptoCurrencyStatusProvider,
)
}
private val amountFieldMaxAmountConverter by lazy(LazyThreadSafetyMode.NONE) {
SendAmountFieldMaxAmountConverter(
currentStateProvider = currentStateProvider,
cryptoCurrencyStatusProvider = cryptoCurrencyStatusProvider,
)
}
private val amountCurrencyConverter by lazy(LazyThreadSafetyMode.NONE) {
SendAmountCurrencyConverter(
currentStateProvider = currentStateProvider,
cryptoCurrencyStatusProvider = cryptoCurrencyStatusProvider,
)
}
fun getOnAmountValueChange(value: String) = amountFieldChangeConverter.convert(value)
fun getOnMaxAmountClick(): SendUiState {
return amountFieldMaxAmountConverter.convert(Unit)
}
fun getOnCurrencyChangedState(isFiat: Boolean) = amountCurrencyConverter.convert(isFiat)
}

View file

@ -22,9 +22,9 @@ internal class SendAmountStateConverter(
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val iconStateConverter: CryptoCurrencyToIconStateConverter,
private val sendAmountFieldConverter: SendAmountFieldConverter,
) : Converter<Unit, SendStates.AmountState> {
) : Converter<String, SendStates.AmountState> {
override fun convert(value: Unit): SendStates.AmountState {
override fun convert(value: String): SendStates.AmountState {
val userWallet = userWalletProvider()
val appCurrency = appCurrencyProvider()
val status = cryptoCurrencyStatusProvider()
@ -35,7 +35,7 @@ internal class SendAmountStateConverter(
walletName = userWallet.name,
walletBalance = resourceReference(R.string.send_wallet_balance_format, wrappedList(crypto, fiat)),
tokenIconState = iconStateConverter.convert(status),
amountTextField = sendAmountFieldConverter.convert(Unit),
amountTextField = sendAmountFieldConverter.convert(value),
isPrimaryButtonEnabled = false,
segmentedButtonConfig = persistentListOf(
SendAmountSegmentedButtonsConfig(

View file

@ -28,10 +28,10 @@ internal class FeeConverter(
return when (val fees = value.fees) {
is TransactionFee.Choosable -> {
when (value.selectedFee) {
FeeType.SLOW -> fees.minimum
FeeType.MARKET -> fees.normal
FeeType.FAST -> fees.priority
FeeType.CUSTOM -> convertCustom(value, fees)
FeeType.Slow -> fees.minimum
FeeType.Market -> fees.normal
FeeType.Fast -> fees.priority
FeeType.Custom -> convertCustom(value, fees)
}
}
is TransactionFee.Single -> fees.normal

View file

@ -69,8 +69,8 @@ internal class FeeNotificationFactory(
val minimumValue = multipleFees.minimum.amount.value ?: return
val customAmount = customFee.firstOrNull() ?: return
val customValue = customAmount.value.parseToBigDecimal(customAmount.decimals)
if (selectedFee == FeeType.CUSTOM && minimumValue > customValue) {
add(SendFeeNotification.Informational.TooLow)
if (selectedFee == FeeType.Custom && minimumValue > customValue) {
add(SendFeeNotification.Warning.TooLow)
}
}
@ -84,7 +84,7 @@ internal class FeeNotificationFactory(
val customAmount = customFee.firstOrNull() ?: return
val customValue = customAmount.value.parseToBigDecimal(customAmount.decimals)
val diff = customValue / highValue
if (selectedFee == FeeType.CUSTOM && diff > FEE_MAX_DIFF) {
if (selectedFee == FeeType.Custom && diff > FEE_MAX_DIFF) {
add(SendFeeNotification.Warning.TooHigh(diff.toFormattedString(HIGH_FEE_DIFF_DECIMALS)))
}
}

View file

@ -13,7 +13,7 @@ internal sealed class FeeSelectorState {
data class Content(
val fees: TransactionFee,
val selectedFee: FeeType = FeeType.MARKET,
val selectedFee: FeeType = FeeType.Market,
val customValues: ImmutableList<SendTextField.CustomFee> = persistentListOf(),
) : FeeSelectorState()
@ -21,8 +21,8 @@ internal sealed class FeeSelectorState {
}
enum class FeeType {
SLOW,
MARKET,
FAST,
CUSTOM,
Slow,
Market,
Fast,
Custom,
}

View file

@ -27,7 +27,7 @@ internal class FeeStateFactory(
private val appCurrencyProvider: Provider<AppCurrency>,
private val isFeeApproximateUseCase: IsFeeApproximateUseCase,
) {
private val customFeeFieldConverter by lazy {
private val customFeeFieldConverter by lazy(LazyThreadSafetyMode.NONE) {
SendFeeCustomFieldConverter(
clickIntents = clickIntents,
appCurrencyProvider = appCurrencyProvider,
@ -35,7 +35,7 @@ internal class FeeStateFactory(
)
}
val feeConverter by lazy {
val feeConverter by lazy(LazyThreadSafetyMode.NONE) {
FeeConverter(
clickIntents = clickIntents,
appCurrencyProvider = appCurrencyProvider,
@ -191,7 +191,7 @@ internal class FeeStateFactory(
val fee = feeConverter.convert(feeSelectorState)
val feeValue = fee.amount.value ?: BigDecimal.ZERO
val isNotCustom = feeSelectorState.selectedFee != FeeType.CUSTOM
val isNotCustom = feeSelectorState.selectedFee != FeeType.Custom
val isNotEmptyCustom = if (customValue != null) {
!customValue.value.parseToBigDecimal(customValue.decimals).isZero() && !isNotCustom
} else {

View file

@ -8,22 +8,6 @@ import com.tangem.features.send.impl.R
sealed class SendFeeNotification(val config: NotificationConfig) {
sealed class Informational(
val title: TextReference,
val subtitle: TextReference,
) : SendFeeNotification(
config = NotificationConfig(
title = title,
subtitle = subtitle,
iconResId = R.drawable.ic_alert_circle_24,
),
) {
object TooLow : Informational(
title = resourceReference(id = R.string.send_notification_transaction_delay_title),
subtitle = resourceReference(id = R.string.send_notification_transaction_delay_text),
)
}
sealed class Warning(
val title: TextReference,
val subtitle: TextReference,
@ -36,6 +20,11 @@ sealed class SendFeeNotification(val config: NotificationConfig) {
buttonsState = buttonsState,
),
) {
object TooLow : Warning(
title = resourceReference(id = R.string.send_notification_transaction_delay_title),
subtitle = resourceReference(id = R.string.send_notification_transaction_delay_text),
)
data class TooHigh(
val value: String,
) : Warning(

View file

@ -1,5 +1,6 @@
package com.tangem.features.send.impl.presentation.state.fee.custom
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
@ -43,6 +44,7 @@ internal class EthereumCustomFeeConverter(
title = resourceReference(R.string.send_max_fee),
footer = resourceReference(R.string.send_max_fee_footer),
label = getFeeFormatted(value.amount.value),
keyboardActions = KeyboardActions(),
),
SendTextField.CustomFee(
value = value.gasPrice.toString(),
@ -55,6 +57,7 @@ internal class EthereumCustomFeeConverter(
imeAction = ImeAction.Next,
keyboardType = KeyboardType.Number,
),
keyboardActions = KeyboardActions(),
),
SendTextField.CustomFee(
value = value.gasLimit.toString(),
@ -67,6 +70,7 @@ internal class EthereumCustomFeeConverter(
imeAction = ImeAction.Done,
keyboardType = KeyboardType.Number,
),
keyboardActions = KeyboardActions(onDone = { clickIntents.onNextClick() }),
),
)
}

View file

@ -1,9 +1,12 @@
package com.tangem.features.send.impl.presentation.state.fields
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import com.tangem.blockchain.extensions.toBigDecimalOrDefault
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.Amount
import com.tangem.domain.tokens.model.AmountType
@ -21,20 +24,29 @@ internal class SendAmountFieldConverter(
private val clickIntents: SendClickIntents,
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val appCurrencyProvider: Provider<AppCurrency>,
) : Converter<Unit, SendTextField.AmountField> {
) : Converter<String, SendTextField.AmountField> {
override fun convert(value: Unit): SendTextField.AmountField {
override fun convert(value: String): SendTextField.AmountField {
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val cryptoDecimal = value.toBigDecimalOrDefault()
val cryptoAmount = cryptoDecimal.convertToAmount(cryptoCurrencyStatus.currency)
val fiatValue = if (value.isEmpty()) {
""
} else {
val fiatDecimal = cryptoCurrencyStatus.value.fiatRate?.multiply(cryptoDecimal) ?: BigDecimal.ZERO
fiatDecimal.parseBigDecimal(FIAT_DECIMALS)
}
return SendTextField.AmountField(
value = "",
fiatValue = "",
value = value,
fiatValue = fiatValue,
onValueChange = clickIntents::onAmountValueChange,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Next,
imeAction = ImeAction.Done,
keyboardType = KeyboardType.Number,
),
keyboardActions = KeyboardActions(onDone = { clickIntents.onNextClick() }),
isFiatValue = false,
cryptoAmount = BigDecimal.ZERO.convertToAmount(cryptoCurrencyStatus.currency),
cryptoAmount = cryptoAmount,
fiatAmount = getAppCurrencyAmount(appCurrencyProvider()),
isError = false,
error = TextReference.Res(R.string.swapping_insufficient_funds),

View file

@ -0,0 +1,44 @@
package com.tangem.features.send.impl.presentation.state.fields
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.send.impl.presentation.state.SendUiState
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
internal class SendAmountFieldMaxAmountConverter(
private val currentStateProvider: Provider<SendUiState>,
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
) : Converter<Unit, SendUiState> {
override fun convert(value: Unit): SendUiState {
val state = currentStateProvider()
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val amountState = state.amountState ?: return state
val amountTextField = amountState.amountTextField
val feeState = state.feeState ?: return state
val cryptoDecimals = amountTextField.cryptoAmount.decimals
val fiatDecimals = amountTextField.fiatAmount.decimals
val decimalCryptoValue = cryptoCurrencyStatus.value.amount
val decimalFiatValue = cryptoCurrencyStatus.value.fiatAmount
val cryptoValue = decimalCryptoValue?.parseBigDecimal(cryptoDecimals).orEmpty()
val fiatValue = decimalFiatValue?.parseBigDecimal(fiatDecimals).orEmpty()
return state.copy(
amountState = amountState.copy(
isPrimaryButtonEnabled = true,
amountTextField = amountTextField.copy(
value = cryptoValue,
fiatValue = fiatValue,
isError = false,
cryptoAmount = amountTextField.cryptoAmount.copy(value = decimalCryptoValue),
fiatAmount = amountTextField.fiatAmount.copy(value = decimalFiatValue),
),
),
feeState = feeState.copy(
isSubtract = true,
),
)
}
}

View file

@ -1,5 +1,6 @@
package com.tangem.features.send.impl.presentation.state.fields
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.extensions.TextReference
@ -21,6 +22,7 @@ internal sealed class SendTextField {
override val value: String,
override val onValueChange: (String) -> Unit,
override val keyboardOptions: KeyboardOptions,
val keyboardActions: KeyboardActions,
val cryptoAmount: Amount,
val fiatAmount: Amount,
val isFiatValue: Boolean,
@ -47,12 +49,15 @@ internal sealed class SendTextField {
val label: TextReference,
val isError: Boolean = false,
val error: TextReference? = null,
val disabledText: TextReference,
val isEnabled: Boolean,
) : SendTextField()
data class CustomFee(
override val value: String,
override val onValueChange: (String) -> Unit,
override val keyboardOptions: KeyboardOptions,
val keyboardActions: KeyboardActions,
val symbol: String?,
val decimals: Int,
val title: TextReference,

View file

@ -11,11 +11,11 @@ import com.tangem.utils.converter.Converter
internal class SendRecipientAddressFieldConverter(
private val clickIntents: SendClickIntents,
) : Converter<Unit, SendTextField.RecipientAddress> {
) : Converter<String, SendTextField.RecipientAddress> {
override fun convert(value: Unit): SendTextField.RecipientAddress {
override fun convert(value: String): SendTextField.RecipientAddress {
return SendTextField.RecipientAddress(
value = "",
value = value,
onValueChange = clickIntents::onRecipientAddressValueChange,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Next,

View file

@ -44,6 +44,8 @@ internal class SendRecipientMemoFieldConverter(
placeholder = resourceReference(R.string.send_optional_field),
label = resourceReference(value),
error = resourceReference(R.string.send_memo_destination_tag_error),
disabledText = resourceReference(R.string.send_additional_field_already_included),
isEnabled = true,
)
}
}

View file

@ -9,7 +9,7 @@ import com.tangem.utils.converter.Converter
internal class SendRecipientStateConverter(
private val clickIntents: SendClickIntents,
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
) : Converter<Unit, SendStates.RecipientState> {
) : Converter<String, SendStates.RecipientState> {
private val addressFieldConverter by lazy { SendRecipientAddressFieldConverter(clickIntents) }
private val memoFieldConverter by lazy {
@ -19,9 +19,9 @@ internal class SendRecipientStateConverter(
)
}
override fun convert(value: Unit): SendStates.RecipientState {
override fun convert(value: String): SendStates.RecipientState {
return SendStates.RecipientState(
addressTextField = addressFieldConverter.convert(Unit),
addressTextField = addressFieldConverter.convert(value),
memoTextField = memoFieldConverter.convertOrNull(),
network = cryptoCurrencyStatusProvider().currency.network.name,
isPrimaryButtonEnabled = false,

View file

@ -24,6 +24,7 @@ import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.ui.R
import com.tangem.core.ui.components.*
import com.tangem.core.ui.extensions.rememberHapticFeedback
import com.tangem.core.ui.extensions.shareText
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.send.impl.presentation.state.SendUiState
@ -49,9 +50,10 @@ internal fun SendNavigationButtons(uiState: SendUiState) {
@Composable
private fun SendSecondaryNavigationButton(uiState: SendUiState) {
val currentState = uiState.currentState.collectAsState()
val isEditingDisabled = uiState.isEditingDisabled
val isCorrectScreen = currentState.value == SendUiStateType.Amount || currentState.value == SendUiStateType.Fee
AnimatedVisibility(
visible = currentState.value == SendUiStateType.Amount ||
currentState.value == SendUiStateType.Fee,
visible = !isEditingDisabled && isCorrectScreen,
) {
Icon(
modifier = Modifier
@ -94,11 +96,12 @@ private fun SendPrimaryNavigationButton(uiState: SendUiState, modifier: Modifier
) { textId ->
when {
currentState.value == SendUiStateType.Send && !isSuccess -> {
val hapticFeedback = rememberHapticFeedback(state = currentState, onAction = buttonClick)
PrimaryButtonIconEnd(
text = stringResource(textId),
iconResId = R.drawable.ic_tangem_24,
enabled = isButtonEnabled,
onClick = buttonClick,
onClick = hapticFeedback,
showProgress = isSending,
)
}
@ -107,6 +110,7 @@ private fun SendPrimaryNavigationButton(uiState: SendUiState, modifier: Modifier
textRes = textId,
txUrl = txUrl,
onExploreClick = { uiState.clickIntents.onExploreClick(txUrl) },
onShareClick = uiState.clickIntents::onShareClick,
onDoneClick = buttonClick,
modifier = Modifier,
)
@ -127,6 +131,7 @@ private fun PrimaryButtonsDone(
@StringRes textRes: Int,
txUrl: String,
onExploreClick: () -> Unit,
onShareClick: () -> Unit,
onDoneClick: () -> Unit,
modifier: Modifier = Modifier,
) {
@ -149,6 +154,7 @@ private fun PrimaryButtonsDone(
onClick = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
context.shareText(txUrl)
onShareClick()
},
modifier = Modifier.weight(1f),
)
@ -170,6 +176,7 @@ private fun getButtonData(
isSuccess: Boolean,
): Pair<Int, () -> Unit> {
return when (currentState.value) {
SendUiStateType.None,
SendUiStateType.Amount,
SendUiStateType.Recipient,
SendUiStateType.Fee,

View file

@ -45,6 +45,7 @@ internal fun SendScreen(uiState: SendUiState) {
SendUiStateType.Recipient -> R.string.send_recipient_label
SendUiStateType.Fee -> R.string.common_fee_selector_title
SendUiStateType.Send -> if (!isSuccess) R.string.send_confirm_label else null
else -> null
}
val iconRes = if (currentState.value == SendUiStateType.Recipient) {
R.drawable.ic_qrcode_scan_24
@ -89,19 +90,21 @@ private fun SendScreenContent(
) { state ->
when (state) {
SendUiStateType.Amount -> SendAmountContent(
uiState.amountState,
uiState.clickIntents,
amountState = uiState.amountState,
isBalanceHiding = uiState.isBalanceHidden,
clickIntents = uiState.clickIntents,
)
SendUiStateType.Recipient -> SendRecipientContent(
uiState.recipientState,
uiState.clickIntents,
recipientList,
uiState = uiState.recipientState,
clickIntents = uiState.clickIntents,
recipientList = recipientList,
)
SendUiStateType.Fee -> SendSpeedAndFeeContent(
uiState.feeState,
uiState.clickIntents,
state = uiState.feeState,
clickIntents = uiState.clickIntents,
)
SendUiStateType.Send -> SendContent(uiState)
else -> Unit
}
}
}

View file

@ -28,10 +28,10 @@ internal fun AmountField(sendField: SendTextField.AmountField, isFiat: Boolean)
sendField.value to sendField.fiatValue
}
val (primaryAmount, secondaryAmount) = if (!isFiat) {
sendField.cryptoAmount to sendField.fiatAmount
} else {
val (primaryAmount, secondaryAmount) = if (isFiat) {
sendField.fiatAmount to sendField.cryptoAmount
} else {
sendField.cryptoAmount to sendField.fiatAmount
}
AmountTextField(
@ -40,6 +40,7 @@ internal fun AmountField(sendField: SendTextField.AmountField, isFiat: Boolean)
symbol = primaryAmount.currencySymbol,
onValueChange = sendField.onValueChange,
keyboardOptions = sendField.keyboardOptions,
keyboardActions = sendField.keyboardActions,
textStyle = TangemTheme.typography.h2.copy(
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,

View file

@ -1,5 +1,6 @@
package com.tangem.features.send.impl.presentation.ui.amount
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
@ -11,13 +12,18 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextAlign
import com.tangem.common.Strings.STARS
import com.tangem.core.ui.components.currency.tokenicon.TokenIcon
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.send.impl.presentation.state.SendStates
@Composable
internal fun AmountFieldContainer(amountState: SendStates.AmountState, modifier: Modifier = Modifier) {
internal fun AmountFieldContainer(
amountState: SendStates.AmountState,
isBalanceHiding: Boolean,
modifier: Modifier = Modifier,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
@ -37,14 +43,21 @@ internal fun AmountFieldContainer(amountState: SendStates.AmountState, modifier:
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing14),
)
Text(
text = amountState.walletBalance.resolveReference(),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
textAlign = TextAlign.Center,
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing2),
)
val balance = if (isBalanceHiding) STARS else amountState.walletBalance.resolveReference()
AnimatedContent(
targetState = balance,
label = "Hide Balance Animation",
) {
Text(
text = it,
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
textAlign = TextAlign.Center,
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing2),
)
}
TokenIcon(
state = amountState.tokenIconState,
modifier = Modifier

View file

@ -22,13 +22,17 @@ import com.tangem.features.send.impl.presentation.state.amount.SendAmountSegment
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
@Composable
internal fun SendAmountContent(amountState: SendStates.AmountState?, clickIntents: SendClickIntents) {
internal fun SendAmountContent(
amountState: SendStates.AmountState?,
isBalanceHiding: Boolean,
clickIntents: SendClickIntents,
) {
if (amountState == null) return
Column(
modifier = Modifier
.background(TangemTheme.colors.background.tertiary),
) {
AmountFieldContainer(amountState = amountState)
AmountFieldContainer(amountState = amountState, isBalanceHiding = isBalanceHiding)
Row(
modifier = Modifier
.padding(

View file

@ -20,7 +20,7 @@ internal fun SendCustomFeeEthereum(
selectedFee: FeeType,
modifier: Modifier = Modifier,
) {
if (selectedFee == FeeType.CUSTOM && customValues.isNotEmpty()) {
if (selectedFee == FeeType.Custom && customValues.isNotEmpty()) {
Column(
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
modifier = modifier,
@ -38,6 +38,7 @@ internal fun SendCustomFeeEthereum(
title = value.title,
info = value.label,
keyboardOptions = value.keyboardOptions,
keyboardActions = value.keyboardActions,
onValueChange = value.onValueChange,
showDivider = false,
modifier = Modifier
@ -54,6 +55,7 @@ internal fun SendCustomFeeEthereum(
symbol = value.symbol,
onValueChange = value.onValueChange,
keyboardOptions = value.keyboardOptions,
keyboardActions = value.keyboardActions,
showDivider = false,
modifier = Modifier
.background(

View file

@ -15,14 +15,15 @@ import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.send.impl.presentation.state.SendStates
import com.tangem.features.send.impl.presentation.state.fee.FeeSelectorState
import com.tangem.features.send.impl.presentation.state.fee.FeeType
import com.tangem.features.send.impl.presentation.state.fee.SendFeeNotification
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
private const val FEE_SELECTOR_KEY = "FEE_SELECTOR_KEY"
private const val FEE_CUSTOM_KEY = "FEE_CUSTOM_KEY"
@OptIn(ExperimentalFoundationApi::class)
@Composable
internal fun SendSpeedAndFeeContent(state: SendStates.FeeState?, clickIntents: SendClickIntents) {
if (state == null) return
@ -36,46 +37,92 @@ internal fun SendSpeedAndFeeContent(state: SendStates.FeeState?, clickIntents: S
horizontal = TangemTheme.dimens.spacing16,
),
) {
item(
key = FEE_SELECTOR_KEY,
) {
SendSpeedSelector(
state = state,
clickIntents = clickIntents,
modifier = Modifier.animateItemPlacement(),
)
}
feeSelector(state, clickIntents)
topNotifications(notifications)
customFee(feeSendState)
notifications(notifications)
subtractButton(
receivedAmount = state.receivedAmount,
isSubtract = state.isSubtract,
isSubtractAvailable = state.isSubtractAvailable,
clickIntents = clickIntents,
)
middleNotifications(notifications)
subtractButton(state, clickIntents)
bottomNotifications(notifications)
}
}
@OptIn(ExperimentalFoundationApi::class)
internal fun LazyListScope.notifications(configs: ImmutableList<SendFeeNotification>, modifier: Modifier = Modifier) {
private fun LazyListScope.feeSelector(state: SendStates.FeeState, clickIntents: SendClickIntents) {
item(
key = FEE_SELECTOR_KEY,
) {
SendSpeedSelector(
state = state,
clickIntents = clickIntents,
modifier = Modifier.animateItemPlacement(),
)
}
}
private fun LazyListScope.topNotifications(
configs: ImmutableList<SendFeeNotification>,
modifier: Modifier = Modifier,
) {
notifications(
configs = configs.filter {
it is SendFeeNotification.Error.ExceedsBalance ||
it is SendFeeNotification.Warning.NetworkFeeUnreachable
}.toImmutableList(),
modifier = modifier,
)
}
private fun LazyListScope.middleNotifications(
configs: ImmutableList<SendFeeNotification>,
modifier: Modifier = Modifier,
) {
notifications(
configs = configs.filter {
it is SendFeeNotification.Warning.TooLow ||
it is SendFeeNotification.Warning.TooHigh
}.toImmutableList(),
modifier = modifier,
)
}
private fun LazyListScope.bottomNotifications(
configs: ImmutableList<SendFeeNotification>,
modifier: Modifier = Modifier,
) {
notifications(
configs = configs.filterIsInstance<SendFeeNotification.Warning.NetworkCoverage>().toImmutableList(),
isLast = true,
modifier = modifier,
)
}
@OptIn(ExperimentalFoundationApi::class)
private fun LazyListScope.notifications(
configs: ImmutableList<SendFeeNotification>,
modifier: Modifier = Modifier,
isLast: Boolean = false,
) {
items(
items = configs,
key = { it::class.java },
contentType = { it::class.java },
itemContent = {
val bottomPadding = if (isLast) TangemTheme.dimens.spacing12 else TangemTheme.dimens.spacing0
Notification(
config = it.config,
modifier = modifier
.padding(top = TangemTheme.dimens.spacing12)
.padding(
top = TangemTheme.dimens.spacing12,
bottom = bottomPadding,
)
.animateItemPlacement(),
containerColor = when (it) {
is SendFeeNotification.Error.ExceedsBalance,
is SendFeeNotification.Warning.NetworkFeeUnreachable,
-> TangemTheme.colors.background.primary
-> TangemTheme.colors.background.action
else -> TangemTheme.colors.button.disabled
},
iconTint = when (it) {
is SendFeeNotification.Informational -> TangemTheme.colors.icon.accent
is SendFeeNotification.Error.ExceedsBalance -> {
if (it.config.buttonsState == null) {
TangemTheme.colors.icon.warning
@ -116,20 +163,30 @@ internal fun LazyListScope.customFee(feeSendState: FeeSelectorState, modifier: M
@OptIn(ExperimentalFoundationApi::class)
internal fun LazyListScope.subtractButton(
receivedAmount: String,
isSubtract: Boolean,
isSubtractAvailable: Boolean,
state: SendStates.FeeState,
clickIntents: SendClickIntents,
modifier: Modifier = Modifier,
) {
val receivedAmount = state.receivedAmount
val isSubtract = state.isSubtract
val isSubtractAvailable = state.isSubtractAvailable
val feeSendState = state.feeSelectorState
if (isSubtractAvailable) {
item {
val feeStateContent = feeSendState as? FeeSelectorState.Content
val isCustomAvailable = feeStateContent?.customValues.isNullOrEmpty().not()
val isCustomSelected = feeStateContent?.selectedFee == FeeType.Custom
val topPadding = if (isCustomSelected && isCustomAvailable) {
TangemTheme.dimens.spacing12
} else {
TangemTheme.dimens.spacing20
}
SendSpeedSubtract(
receivingAmount = receivedAmount,
isSubtract = isSubtract,
onSelectClick = clickIntents::onSubtractSelect,
modifier = modifier
.padding(vertical = TangemTheme.dimens.spacing12)
.padding(top = topPadding)
.animateItemPlacement(),
)
}

View file

@ -4,6 +4,9 @@ import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
@ -39,6 +42,7 @@ import com.tangem.features.send.impl.R
import com.tangem.features.send.impl.presentation.state.SendStates
import com.tangem.features.send.impl.presentation.state.fee.FeeSelectorState
import com.tangem.features.send.impl.presentation.state.fee.FeeType
import com.tangem.features.send.impl.presentation.state.fee.SendFeeNotification
import com.tangem.features.send.impl.presentation.ui.common.FooterContainer
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import java.math.BigDecimal
@ -84,8 +88,8 @@ internal fun SendSpeedSelector(
amount = getCryptoReference(minimumAmount, state.isFeeApproximate),
fiatAmount = getFiatReference(minimumAmount, state.rate, state.appCurrency),
symbolLength = minimumAmount.currencySymbol.length,
isSelected = isSelected == FeeType.SLOW,
onSelect = { clickIntents.onFeeSelectorClick(FeeType.SLOW) },
isSelected = isSelected == FeeType.Slow,
onSelect = { clickIntents.onFeeSelectorClick(FeeType.Slow) },
)
val normalAmount = fees.normal.amount
SendSpeedSelectorItem(
@ -94,8 +98,8 @@ internal fun SendSpeedSelector(
amount = getCryptoReference(normalAmount, state.isFeeApproximate),
fiatAmount = getFiatReference(normalAmount, state.rate, state.appCurrency),
symbolLength = normalAmount.currencySymbol.length,
isSelected = isSelected == FeeType.MARKET,
onSelect = { clickIntents.onFeeSelectorClick(FeeType.MARKET) },
isSelected = isSelected == FeeType.Market,
onSelect = { clickIntents.onFeeSelectorClick(FeeType.Market) },
)
val priorityAmount = fees.priority.amount
SendSpeedSelectorItem(
@ -104,20 +108,25 @@ internal fun SendSpeedSelector(
amount = getCryptoReference(priorityAmount, state.isFeeApproximate),
fiatAmount = getFiatReference(priorityAmount, state.rate, state.appCurrency),
symbolLength = priorityAmount.currencySymbol.length,
isSelected = isSelected == FeeType.FAST,
onSelect = { clickIntents.onFeeSelectorClick(FeeType.FAST) },
isSelected = isSelected == FeeType.Fast,
onSelect = { clickIntents.onFeeSelectorClick(FeeType.Fast) },
showDivider = fees.normal is Fee.Ethereum,
)
AnimatedVisibility(
visible = fees.normal is Fee.Ethereum,
label = "Custom fee appearance animation",
) {
val showWarning = state.notifications.any {
it is SendFeeNotification.Warning.TooHigh ||
it is SendFeeNotification.Warning.TooLow
}
SendSpeedSelectorItem(
titleRes = R.string.common_fee_selector_option_custom,
iconRes = R.drawable.ic_edit_24,
isSelected = isSelected == FeeType.CUSTOM,
onSelect = { clickIntents.onFeeSelectorClick(FeeType.CUSTOM) },
isSelected = isSelected == FeeType.Custom,
onSelect = { clickIntents.onFeeSelectorClick(FeeType.Custom) },
showDivider = fees.normal !is Fee.Ethereum,
showWarning = showWarning,
)
}
}
@ -130,7 +139,7 @@ internal fun SendSpeedSelector(
amount = getCryptoReference(normalAmount, state.isFeeApproximate),
fiatAmount = getFiatReference(normalAmount, state.rate, state.appCurrency),
symbolLength = normalAmount.currencySymbol.length,
onSelect = { clickIntents.onFeeSelectorClick(FeeType.MARKET) },
onSelect = { clickIntents.onFeeSelectorClick(FeeType.Market) },
showDivider = false,
)
}
@ -224,6 +233,7 @@ private fun SendSpeedSelectorItem(
symbolLength: Int? = null,
isSelected: Boolean = false,
showDivider: Boolean = true,
showWarning: Boolean = false,
) {
val iconTint by animateColorAsState(
targetValue = if (isSelected) {
@ -259,7 +269,10 @@ private fun SendSpeedSelectorItem(
symbolLength = symbolLength,
textStyle = textStyle,
)
} else {
SpacerWMax()
}
WarningIcon(showWarning = showWarning)
}
if (showDivider) {
Box(
@ -340,6 +353,26 @@ private fun RowScope.SelectorValueContent(
)
}
@Composable
private fun WarningIcon(showWarning: Boolean = false) {
AnimatedVisibility(
visible = showWarning,
label = "Custom fee warning indicator",
enter = fadeIn(),
exit = fadeOut(),
) {
Image(
painter = painterResource(R.drawable.ic_alert_triangle_20),
contentDescription = null,
modifier = Modifier
.padding(
vertical = TangemTheme.dimens.spacing12,
horizontal = TangemTheme.dimens.spacing14,
),
)
}
}
//region preview
@Preview
@Composable

View file

@ -24,6 +24,7 @@ import com.tangem.core.ui.components.inputrow.InputRowRecipient
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.send.impl.R
import com.tangem.features.send.impl.presentation.analytics.EnterAddressSource
import com.tangem.features.send.impl.presentation.domain.SendRecipientListContent
import com.tangem.features.send.impl.presentation.state.SendStates
import com.tangem.features.send.impl.presentation.ui.common.FooterContainer
@ -58,7 +59,7 @@ internal fun SendRecipientContent(
title = address.label,
placeholder = address.placeholder,
onValueChange = address.onValueChange,
onPasteClick = clickIntents::onRecipientAddressValueChange,
onPasteClick = { clickIntents.onRecipientAddressValueChange(it, EnterAddressSource.PasteButton) },
isError = isError,
isLoading = isValidating,
error = address.error,
@ -73,16 +74,18 @@ internal fun SendRecipientContent(
}
uiState.memoTextField?.let { memoField ->
item(key = MEMO_FIELD_KEY) {
val placeholder = if (memoField.isEnabled) memoField.placeholder else memoField.disabledText
TextFieldWithPaste(
value = memoField.value,
label = memoField.label,
placeholder = memoField.placeholder,
placeholder = placeholder,
footer = stringResource(R.string.send_recipient_memo_footer),
onValueChange = memoField.onValueChange,
onPasteClick = clickIntents::onRecipientMemoValueChange,
onPasteClick = { clickIntents.onRecipientMemoValueChange(it, isPasted = true) },
modifier = Modifier.padding(top = TangemTheme.dimens.spacing20),
isError = memoField.isError,
error = memoField.error,
isReadOnly = !memoField.isEnabled,
)
}
}
@ -163,7 +166,9 @@ private fun LazyListScope.recipientListItem(
},
)
.background(TangemTheme.colors.background.action),
onClick = { clickIntents.onRecipientAddressValueChange(title) },
onClick = {
clickIntents.onRecipientAddressValueChange(title, EnterAddressSource.RecentAddress)
},
)
}
}
@ -201,7 +206,7 @@ private fun RecipientWalletListItem(
ListItemWithIcon(
title = wallet.title.resolveReference(),
subtitle = wallet.subtitle.resolveReference(),
onClick = { clickIntents.onRecipientAddressValueChange(title) },
onClick = { clickIntents.onRecipientAddressValueChange(title, EnterAddressSource.RecentAddress) },
)
}
if (!item.isWalletsOnly) {

View file

@ -27,11 +27,12 @@ internal fun TextFieldWithPaste(
footer: String? = null,
error: TextReference? = null,
isError: Boolean = false,
isReadOnly: Boolean = false,
) {
val (title, color) = if (isError && error != null) {
error to TangemTheme.colors.text.warning
} else {
label to TangemTheme.colors.text.secondary
val (title, color) = when {
isError && error != null -> error to TangemTheme.colors.text.warning
isReadOnly -> label to TangemTheme.colors.text.disabled
else -> label to TangemTheme.colors.text.secondary
}
FooterContainer(modifier, footer) {
Row(
@ -55,18 +56,21 @@ internal fun TextFieldWithPaste(
value = value,
placeholder = placeholder,
onValueChange = onValueChange,
readOnly = isReadOnly,
modifier = Modifier
.fillMaxWidth()
.padding(top = TangemTheme.dimens.spacing6),
)
}
PasteButton(
isPasteButtonVisible = value.isBlank(),
onClick = onPasteClick,
modifier = Modifier
.align(CenterVertically)
.padding(end = TangemTheme.dimens.spacing16),
)
if (!isReadOnly) {
PasteButton(
isPasteButtonVisible = value.isBlank(),
onClick = onPasteClick,
modifier = Modifier
.align(CenterVertically)
.padding(end = TangemTheme.dimens.spacing16),
)
}
}
}
}

View file

@ -1,5 +1,6 @@
package com.tangem.features.send.impl.presentation.ui.send
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
@ -17,6 +18,7 @@ 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 com.tangem.common.Strings
import com.tangem.core.ui.components.inputrow.InputRowDefault
import com.tangem.core.ui.components.inputrow.InputRowImage
import com.tangem.core.ui.components.inputrow.InputRowRecipientDefault
@ -65,18 +67,21 @@ internal fun SendContent(uiState: SendUiState) {
FromWallet(
walletName = amountState.walletName,
walletBalance = amountState.walletBalance.resolveReference(),
isBalanceHidden = uiState.isBalanceHidden,
)
}
AmountBlock(
amountState = amountState,
isSuccess = isSuccess,
onClick = uiState.clickIntents::showAmount,
)
RecipientBlock(
recipientState = recipientState,
isSuccess = isSuccess,
isEditingDisabled = uiState.isEditingDisabled,
onClick = uiState.clickIntents::showRecipient,
)
AmountBlock(
amountState = amountState,
isSuccess = isSuccess,
isEditingDisabled = uiState.isEditingDisabled,
onClick = uiState.clickIntents::showAmount,
)
FeeBlock(
feeState = feeState,
isSuccess = isSuccess,
@ -89,7 +94,7 @@ internal fun SendContent(uiState: SendUiState) {
}
@Composable
private fun FromWallet(walletName: String, walletBalance: String) {
private fun FromWallet(walletName: String, walletBalance: String, isBalanceHidden: Boolean) {
Column(
modifier = Modifier
.fillMaxWidth()
@ -108,20 +113,31 @@ private fun FromWallet(walletName: String, walletBalance: String) {
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.secondary,
)
Text(
text = walletBalance,
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.primary1,
modifier = Modifier
.padding(
top = TangemTheme.dimens.spacing8,
),
)
val balance = if (isBalanceHidden) Strings.STARS else walletBalance
AnimatedContent(
targetState = balance,
label = "Hide Balance Animation",
) {
Text(
text = it,
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.primary1,
modifier = Modifier
.padding(
top = TangemTheme.dimens.spacing8,
),
)
}
}
}
@Composable
private fun AmountBlock(amountState: SendStates.AmountState, isSuccess: Boolean, onClick: () -> Unit) {
private fun AmountBlock(
amountState: SendStates.AmountState,
isSuccess: Boolean,
isEditingDisabled: Boolean,
onClick: () -> Unit,
) {
val amount = amountState.amountTextField
val cryptoAmount = formatCryptoAmount(
@ -134,6 +150,11 @@ private fun AmountBlock(amountState: SendStates.AmountState, isSuccess: Boolean,
fiatCurrencyCode = (amount.fiatAmount.type as AmountType.FiatType).code,
fiatCurrencySymbol = amount.fiatAmount.currencySymbol,
)
val backgroundColor = if (isEditingDisabled) {
TangemTheme.colors.button.disabled
} else {
TangemTheme.colors.background.action
}
InputRowImage(
title = TextReference.Res(R.string.send_amount_label),
subtitle = TextReference.Str(cryptoAmount),
@ -142,21 +163,31 @@ private fun AmountBlock(amountState: SendStates.AmountState, isSuccess: Boolean,
showNetworkIcon = true,
modifier = Modifier
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors.background.action)
.clickable(enabled = !isSuccess) { onClick() },
.background(backgroundColor)
.clickable(enabled = !isSuccess && !isEditingDisabled) { onClick() },
)
}
@Composable
private fun RecipientBlock(recipientState: SendStates.RecipientState, isSuccess: Boolean, onClick: () -> Unit) {
private fun RecipientBlock(
recipientState: SendStates.RecipientState,
isSuccess: Boolean,
isEditingDisabled: Boolean,
onClick: () -> Unit,
) {
val address = recipientState.addressTextField
val memo = recipientState.memoTextField
val backgroundColor = if (isEditingDisabled) {
TangemTheme.colors.button.disabled
} else {
TangemTheme.colors.background.action
}
Column(
modifier = Modifier
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors.background.action)
.clickable(enabled = !isSuccess) { onClick() },
.background(backgroundColor)
.clickable(enabled = !isSuccess && !isEditingDisabled) { onClick() },
) {
val showMemo = memo != null && memo.value.isNotBlank()
InputRowRecipientDefault(

View file

@ -2,10 +2,11 @@ package com.tangem.features.send.impl.presentation.viewmodel
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.features.send.impl.presentation.analytics.EnterAddressSource
import com.tangem.features.send.impl.presentation.state.fee.FeeType
@Suppress("TooManyFunctions")
interface SendClickIntents {
internal interface SendClickIntents {
fun popBackStack()
@ -30,9 +31,9 @@ interface SendClickIntents {
// endregion
// region Recipient
fun onRecipientAddressValueChange(value: String)
fun onRecipientAddressValueChange(value: String, type: EnterAddressSource? = null)
fun onRecipientMemoValueChange(value: String)
fun onRecipientMemoValueChange(value: String, isPasted: Boolean = false)
// endregion
// region Fee
@ -56,6 +57,8 @@ interface SendClickIntents {
fun onExploreClick(txUrl: String)
fun onShareClick()
fun onAmountReduceClick(reducedAmount: String)
fun onAmountReduceIgnoreClick()

View file

@ -7,19 +7,18 @@ import androidx.lifecycle.*
import androidx.paging.PagingData
import arrow.core.Either
import arrow.core.getOrElse
import com.tangem.blockchain.blockchains.xrp.XrpAddressService
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.common.extensions.isZero
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.redux.LegacyAction
import com.tangem.domain.redux.ReduxStateHolder
import com.tangem.domain.tokens.*
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
import com.tangem.domain.tokens.utils.convertToAmount
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.usecase.CreateTransactionUseCase
@ -36,12 +35,19 @@ import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.domain.wallets.usecase.*
import com.tangem.features.send.api.navigation.SendRouter
import com.tangem.features.send.impl.navigation.InnerSendRouter
import com.tangem.features.send.impl.presentation.analytics.EnterAddressSource
import com.tangem.features.send.impl.presentation.analytics.SendAnalyticEvents
import com.tangem.features.send.impl.presentation.analytics.SendScreenSource
import com.tangem.features.send.impl.presentation.analytics.utils.SendOnNextScreenAnalyticSender
import com.tangem.features.send.impl.presentation.analytics.utils.SendRecipientAnalyticsSender
import com.tangem.features.send.impl.presentation.domain.AvailableWallet
import com.tangem.features.send.impl.presentation.state.*
import com.tangem.features.send.impl.presentation.state.amount.AmountStateFactory
import com.tangem.features.send.impl.presentation.state.fee.FeeNotificationFactory
import com.tangem.features.send.impl.presentation.state.fee.FeeSelectorState
import com.tangem.features.send.impl.presentation.state.fee.FeeStateFactory
import com.tangem.features.send.impl.presentation.state.fee.FeeType
import com.tangem.lib.crypto.BlockchainUtils
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
@ -74,6 +80,9 @@ internal class SendViewModel @Inject constructor(
private val walletManagersFacade: WalletManagersFacade,
private val reduxStateHolder: ReduxStateHolder,
private val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase,
private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase,
private val analyticsEventHandler: AnalyticsEventHandler,
currencyChecksRepository: CurrencyChecksRepository,
isFeeApproximateUseCase: IsFeeApproximateUseCase,
getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase,
validateWalletMemoUseCase: ValidateWalletMemoUseCase,
@ -88,6 +97,10 @@ internal class SendViewModel @Inject constructor(
private val cryptoCurrency: CryptoCurrency = savedStateHandle[SendRouter.CRYPTO_CURRENCY_KEY]
?: error("This screen can't open without `CryptoCurrency`")
private val transactionId: String? = savedStateHandle[SendRouter.TRANSACTION_ID_KEY]
private val amount: String? = savedStateHandle[SendRouter.AMOUNT_KEY]
private val destinationAddress: String? = savedStateHandle[SendRouter.DESTINATION_ADDRESS_KEY]
private val selectedAppCurrencyFlow: StateFlow<AppCurrency> = createSelectedAppCurrencyFlow()
private var innerRouter: InnerSendRouter by Delegates.notNull()
@ -103,6 +116,11 @@ internal class SendViewModel @Inject constructor(
getExplorerTransactionUrlUseCase = getExplorerTransactionUrlUseCase,
)
private val amountStateFactory = AmountStateFactory(
currentStateProvider = Provider { uiState },
cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus },
)
private val feeStateFactory = FeeStateFactory(
clickIntents = this,
currentStateProvider = Provider { uiState },
@ -132,10 +150,16 @@ internal class SendViewModel @Inject constructor(
coinCryptoCurrencyStatusProvider = Provider { coinCryptoCurrencyStatus },
currentStateProvider = Provider { uiState },
userWalletProvider = Provider { userWallet },
walletManagersFacade = walletManagersFacade,
currencyChecksRepository = currencyChecksRepository,
clickIntents = this,
)
private val sendOnNextScreenAnalyticSender by lazy(LazyThreadSafetyMode.NONE) {
SendOnNextScreenAnalyticSender(analyticsEventHandler)
}
private val sendRecipientAnalyticsSender by lazy(LazyThreadSafetyMode.NONE) {
SendRecipientAnalyticsSender(analyticsEventHandler)
}
// todo convert to StateFlow
var uiState: SendUiState by mutableStateOf(stateFactory.getInitialState())
private set
@ -158,6 +182,8 @@ internal class SendViewModel @Inject constructor(
override fun onCreate(owner: LifecycleOwner) {
subscribeOnCurrencyStatusUpdates(owner)
onStateActive()
subscribeOnBalanceHidden(owner)
analyticsEventHandler.send(SendAnalyticEvents.SendOpened)
}
fun setRouter(router: InnerSendRouter, stateRouter: StateRouter) {
@ -184,6 +210,17 @@ internal class SendViewModel @Inject constructor(
}
}
private fun subscribeOnBalanceHidden(owner: LifecycleOwner) {
getBalanceHidingSettingsUseCase()
.flowWithLifecycle(owner.lifecycle)
.conflate()
.distinctUntilChanged()
.onEach {
uiState = stateFactory.getOnHideBalanceState(isBalanceHidden = it.isBalanceHidden)
}
.launchIn(viewModelScope)
}
private fun getCurrenciesStatusUpdates(owner: LifecycleOwner, wallet: UserWallet) {
val isSingleWallet = wallet.scanResponse.walletData?.token != null && !wallet.isMultiCurrency
@ -192,11 +229,10 @@ internal class SendViewModel @Inject constructor(
.flowWithLifecycle(owner.lifecycle)
.onEach { currencyStatus ->
currencyStatus.onRight {
cryptoCurrencyStatus = it
coinCryptoCurrencyStatus = it
getWalletsAndRecent()
uiState = stateFactory.getReadyState()
updateNotifications()
onDataLoaded(
currencyStatus = it,
coinCurrencyStatus = it,
)
}
}
.flowOn(dispatchers.main)
@ -208,10 +244,10 @@ internal class SendViewModel @Inject constructor(
flow2 = getCurrencyStatusUpdates(isSingleWallet = isSingleWallet),
) { coinStatus, currencyStatus ->
if (coinStatus.isRight() && currencyStatus.isRight()) {
coinStatus.onRight { coinCryptoCurrencyStatus = it }
currencyStatus.onRight { cryptoCurrencyStatus = it }
getWalletsAndRecent()
uiState = stateFactory.getReadyState()
onDataLoaded(
currencyStatus = currencyStatus.getOrElse { error("Currency status is unreachable") },
coinCurrencyStatus = coinStatus.getOrElse { error("Coin status is unreachable") },
)
}
}.flowWithLifecycle(owner.lifecycle)
.flowOn(dispatchers.main)
@ -245,6 +281,21 @@ internal class SendViewModel @Inject constructor(
)
}
private fun onDataLoaded(currencyStatus: CryptoCurrencyStatus, coinCurrencyStatus: CryptoCurrencyStatus) {
cryptoCurrencyStatus = currencyStatus
coinCryptoCurrencyStatus = coinCurrencyStatus
if (transactionId != null && amount != null && destinationAddress != null) {
uiState = stateFactory.getReadyState(amount, destinationAddress)
stateRouter.showFee()
} else {
getWalletsAndRecent()
uiState = stateFactory.getReadyState()
stateRouter.showRecipient()
}
updateNotifications()
}
private fun getWalletsAndRecent() {
combine(
flow = getUserWallets().conflate(),
@ -281,10 +332,12 @@ internal class SendViewModel @Inject constructor(
userWalletId = wallet.walletId,
network = walletCurrency.network,
)
return@fold AvailableWallet(
name = wallet.name,
address = addresses.first().value,
)
return@fold addresses.firstOrNull()?.let {
AvailableWallet(
name = wallet.name,
address = it.value,
)
}
},
ifLeft = { null },
)
@ -352,11 +405,18 @@ internal class SendViewModel @Inject constructor(
// region screen state navigation
override fun popBackStack() = stateRouter.popBackStack()
override fun onBackClick() = stateRouter.onBackClick()
override fun onNextClick() = stateRouter.onNextClick()
override fun onBackClick() = stateRouter.onBackClick(uiState.sendState.isSuccess)
override fun onNextClick() {
val prevScreen = stateRouter.onNextClick()
sendOnNextScreenAnalyticSender.send(prevScreen, uiState)
}
override fun onPrevClick() = stateRouter.onPrevClick()
override fun onQrCodeScanClick() = innerRouter.openQrCodeScanner(cryptoCurrency.network.name)
override fun onQrCodeScanClick() {
analyticsEventHandler.send(SendAnalyticEvents.QrCodeButtonClicked)
innerRouter.openQrCodeScanner(cryptoCurrency.network.name)
}
override fun onFailedTxEmailClick(errorMessage: String) {
reduxStateHolder.dispatch(LegacyAction.SendEmailTransactionFailed(errorMessage))
@ -368,24 +428,16 @@ internal class SendViewModel @Inject constructor(
// region amount state clicks
override fun onCurrencyChangeClick(isFiat: Boolean) {
uiState = stateFactory.getOnCurrencyChangedState(isFiat)
analyticsEventHandler.send(SendAnalyticEvents.SwapCurrencyButtonClicked)
uiState = amountStateFactory.getOnCurrencyChangedState(isFiat)
}
override fun onAmountValueChange(value: String) {
uiState = stateFactory.getOnAmountValueChange(value)
uiState = amountStateFactory.getOnAmountValueChange(value)
}
override fun onMaxValueClick() {
val amountState = uiState.amountState ?: return
val amountTextField = amountState.amountTextField
val (amount, decimals) = if (amountTextField.isFiatValue) {
cryptoCurrencyStatus.value.fiatAmount to amountTextField.fiatAmount.decimals
} else {
cryptoCurrencyStatus.value.amount to amountTextField.cryptoAmount.decimals
}
if (amount != null && !amount.isZero()) {
onAmountValueChange(amount.parseBigDecimal(decimals))
}
uiState = amountStateFactory.getOnMaxAmountClick()
}
// endregion
@ -394,7 +446,7 @@ internal class SendViewModel @Inject constructor(
viewModelScope.launch(dispatchers.main) {
parseSharedAddressUseCase(address, cryptoCurrency.network).fold(
ifRight = { parsedCode ->
onRecipientAddressValueChange(parsedCode.address)
onRecipientAddressValueChange(parsedCode.address, EnterAddressSource.QRCode)
parsedCode.amount?.let { onAmountValueChange(it.toPlainString()) }
parsedCode.memo?.let { onRecipientMemoValueChange(it) }
},
@ -405,24 +457,26 @@ internal class SendViewModel @Inject constructor(
}.saveIn(qrScannerJobHolder)
}
override fun onRecipientAddressValueChange(value: String) {
uiState = stateFactory.onRecipientAddressValueChange(value)
override fun onRecipientAddressValueChange(value: String, type: EnterAddressSource?) {
viewModelScope.launch(dispatchers.main) {
uiState = stateFactory.getOnRecipientAddressValidationStarted()
if (!checkIfXrpAddressValue(value)) {
uiState = stateFactory.onRecipientAddressValueChange(value)
uiState = stateFactory.getOnRecipientAddressValidationStarted()
val isValidAddress = validateAddress(value)
uiState = stateFactory.getOnRecipientAddressValidState(value, isValidAddress)
sendRecipientAnalyticsSender.sendAddressAnalytics(type, isValidAddress)
}
}.saveIn(addressValidationJobHolder)
}
override fun onRecipientMemoValueChange(value: String) {
uiState = stateFactory.getOnRecipientMemoValueChange(value)
override fun onRecipientMemoValueChange(value: String, isPasted: Boolean) {
viewModelScope.launch(dispatchers.main) {
uiState = stateFactory.getOnRecipientAddressValidationStarted()
if (!checkIfXrpAddressValue(value)) {
uiState = stateFactory.getOnRecipientMemoValueChange(value)
uiState = stateFactory.getOnRecipientAddressValidationStarted()
val isValidAddress = validateAddress(uiState.recipientState?.addressTextField?.value.orEmpty())
uiState = stateFactory.getOnRecipientMemoValidState(value, isValidAddress)
sendRecipientAnalyticsSender.sendMemoAnalytics(isPasted)
}
}.saveIn(addressValidationJobHolder)
}
@ -435,16 +489,14 @@ internal class SendViewModel @Inject constructor(
).getOrElse { false }
}
private fun checkIfXrpAddressValue(value: String): Boolean {
if (cryptoCurrency.network.id.value == Blockchain.XRP.id && value.firstOrNull() == XRP_X_ADDRESS) {
viewModelScope.launch(dispatchers.io) {
val result = XrpAddressService.decodeXAddress(value)
onRecipientAddressValueChange(result?.address.orEmpty())
onRecipientMemoValueChange(result?.destinationTag.toString())
}
return true
}
return false
private suspend fun checkIfXrpAddressValue(value: String): Boolean {
return BlockchainUtils.decodeRippleXAddress(value, cryptoCurrency.network.id.value)?.let { decodedAddress ->
uiState = stateFactory.onRecipientAddressValueChange(value, isXAddress = true)
uiState = stateFactory.getOnXAddressMemoState()
val isValidAddress = validateAddress(decodedAddress.address)
uiState = stateFactory.getOnRecipientAddressValidState(decodedAddress.address, isValidAddress)
true
} ?: false
}
// endregion
@ -454,6 +506,9 @@ internal class SendViewModel @Inject constructor(
override fun onFeeSelectorClick(feeType: FeeType) {
uiState = feeStateFactory.onFeeSelectedState(feeType)
updateFeeNotifications()
if (feeType == FeeType.Custom) {
analyticsEventHandler.send(SendAnalyticEvents.CustomFeeButtonClicked)
}
}
override fun onCustomFeeValueChange(index: Int, value: String) {
@ -514,18 +569,35 @@ internal class SendViewModel @Inject constructor(
onCheckFeeUpdate()
}
sendIdleTimer = System.currentTimeMillis()
analyticsEventHandler.send(SendAnalyticEvents.SendButtonClicked)
}
override fun showAmount() = stateRouter.showAmount()
override fun showAmount() {
stateRouter.showAmount()
analyticsEventHandler.send(SendAnalyticEvents.ScreenReopened(SendScreenSource.Amount))
}
override fun showRecipient() = stateRouter.showRecipient()
override fun showRecipient() {
stateRouter.showRecipient()
analyticsEventHandler.send(SendAnalyticEvents.ScreenReopened(SendScreenSource.Address))
}
override fun showFee() = stateRouter.showFee()
override fun showFee() {
stateRouter.showFee()
analyticsEventHandler.send(SendAnalyticEvents.ScreenReopened(SendScreenSource.Fee))
}
override fun onExploreClick(txUrl: String) = innerRouter.openUrl(txUrl)
override fun onExploreClick(txUrl: String) {
analyticsEventHandler.send(SendAnalyticEvents.ExploreButtonClicked)
innerRouter.openUrl(txUrl)
}
override fun onShareClick() {
analyticsEventHandler.send(SendAnalyticEvents.ShareButtonClicked)
}
override fun onAmountReduceClick(reducedAmount: String) {
uiState = stateFactory.getOnAmountValueChange(reducedAmount)
uiState = amountStateFactory.getOnAmountValueChange(reducedAmount)
uiState = sendNotificationFactory.dismissHighFeeWarningState()
loadFee()
}
@ -588,6 +660,7 @@ internal class SendViewModel @Inject constructor(
uiState = stateFactory.getSendingStateUpdate(isSending = false)
uiState = stateFactory.getTransactionSendState(txData)
scheduleBalanceUpdate()
analyticsEventHandler.send(SendAnalyticEvents.TransactionScreenOpened)
},
)
}
@ -645,7 +718,6 @@ internal class SendViewModel @Inject constructor(
// endregion
companion object {
private const val XRP_X_ADDRESS = 'X'
private const val CHECK_FEE_UPDATE_DELAY = 60_000L
private const val BALANCE_UPDATE_DELAY = 10_000L
}

View file

@ -23,7 +23,6 @@ import com.tangem.datasource.crypto.DataSignatureVerifier
import com.tangem.domain.common.extensions.fromNetworkId
import com.tangem.domain.common.util.derivationStyleProvider
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.legacy.WalletsStateHolder
import com.tangem.domain.wallets.models.UserWalletId
@ -298,14 +297,6 @@ internal class DefaultSwapRepository @Inject constructor(
}
}
override suspend fun getExistentialDeposit(userWalletId: UserWalletId, network: Network): BigDecimal? {
return walletManagersFacade.getExistentialDeposit(userWalletId, network)
}
override suspend fun getDustValue(userWalletId: UserWalletId, network: Network): BigDecimal? {
return walletManagersFacade.getDustValue(userWalletId, network)
}
private fun parseTxDetails(txDetailsJson: String): TxDetails? {
return try {
txDetailsMoshiAdapter.fromJson(txDetailsJson)

View file

@ -2,7 +2,6 @@ package com.tangem.feature.swap.domain.api
import arrow.core.Either
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.feature.swap.domain.models.DataError
import com.tangem.feature.swap.domain.models.domain.*
@ -69,8 +68,4 @@ interface SwapRepository {
): Either<DataError, SwapDataModel>
fun getNativeTokenForNetwork(networkId: String): CryptoCurrency
suspend fun getExistentialDeposit(userWalletId: UserWalletId, network: Network): BigDecimal?
suspend fun getDustValue(userWalletId: UserWalletId, network: Network): BigDecimal?
}

View file

@ -14,6 +14,7 @@ import com.tangem.domain.tokens.GetCryptoCurrencyStatusesSyncUseCase
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.Quote
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
import com.tangem.domain.tokens.repository.QuotesRepository
import com.tangem.domain.tokens.utils.convertToAmount
import com.tangem.domain.transaction.error.SendTransactionError
@ -58,6 +59,7 @@ internal class SwapInteractorImpl @Inject constructor(
private val quotesRepository: QuotesRepository,
private val dispatcher: CoroutineDispatcherProvider,
private val swapTransactionRepository: SwapTransactionRepository,
private val currencyChecksRepository: CurrencyChecksRepository,
private val appCurrencyRepository: AppCurrencyRepository,
private val initialToCurrencyResolver: InitialToCurrencyResolver,
) : SwapInteractor {
@ -380,7 +382,7 @@ internal class SwapInteractorImpl @Inject constructor(
amount: SwapAmount,
fromToken: CryptoCurrency,
) {
val existentialDeposit = repository.getExistentialDeposit(userWalletId, fromToken.network)
val existentialDeposit = currencyChecksRepository.getExistentialDeposit(userWalletId, fromToken.network)
if (existentialDeposit != null) {
val nativeBalance = userWalletManager.getNativeTokenBalance(
fromToken.network.backendId,
@ -404,7 +406,7 @@ internal class SwapInteractorImpl @Inject constructor(
is TxFeeState.MultipleFeeState -> feeState.priorityFee.feeValue
is TxFeeState.SingleFeeState -> feeState.fee.feeValue
}
val dust = repository.getDustValue(userWalletId, fromTokenStatus.currency.network)
val dust = currencyChecksRepository.getDustValue(userWalletId, fromTokenStatus.currency.network)
val balance = fromTokenStatus.value.amount ?: BigDecimal.ZERO
if (dust != null &&
!balance.isNullOrZero() &&
@ -664,6 +666,7 @@ internal class SwapInteractorImpl @Inject constructor(
Fee.Aptos(
amount = feeAmount,
gasUnitPrice = fee.feeValue.toLong() / fee.gasLimit,
gasLimit = fee.gasLimit.toLong(),
)
}
else -> Fee.Common(feeAmount)

View file

@ -7,6 +7,7 @@ import com.tangem.domain.demo.IsDemoCardUseCase
import com.tangem.domain.tokens.GetCardTokensListUseCase
import com.tangem.domain.tokens.GetCryptoCurrencyStatusesSyncUseCase
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
import com.tangem.domain.tokens.repository.NetworksRepository
import com.tangem.domain.tokens.repository.QuotesRepository
import com.tangem.domain.transaction.TransactionRepository
@ -42,6 +43,7 @@ class SwapDomainModule {
quotesRepository: QuotesRepository,
swapTransactionRepository: SwapTransactionRepository,
appCurrencyRepository: AppCurrencyRepository,
currencyChecksRepository: CurrencyChecksRepository,
walletManagersFacade: WalletManagersFacade,
coroutineDispatcherProvider: CoroutineDispatcherProvider,
initialToCurrencyResolver: InitialToCurrencyResolver,
@ -59,6 +61,7 @@ class SwapDomainModule {
dispatcher = coroutineDispatcherProvider,
swapTransactionRepository = swapTransactionRepository,
appCurrencyRepository = appCurrencyRepository,
currencyChecksRepository = currencyChecksRepository,
initialToCurrencyResolver = initialToCurrencyResolver,
)
}

View file

@ -1,11 +0,0 @@
package com.tangem.features.tester.api
/**
* Tester feature toggles
*
[REDACTED_AUTHOR]
*/
interface TesterFeatureToggles {
val isDerivePublicKeysRefactoringEnabled: Boolean
}

View file

@ -27,17 +27,18 @@ dependencies {
implementation(projects.domain.appTheme)
implementation(projects.domain.appTheme.models)
/** Core modules */
implementation(project(":core:featuretoggles"))
implementation(project(":core:ui"))
/** Feature Apis */
implementation(project(":features:tester:api"))
/** Other modules */
implementation(project(":libs:crypto"))
/** Other libraries */
implementation(deps.arrow.core)
implementation(deps.timber)
/** Core modules */
implementation(projects.core.featuretoggles)
implementation(projects.core.ui)
implementation(projects.core.utils)
/** Feature Apis */
implementation(projects.features.tester.api)
/** Other modules */
implementation(projects.libs.crypto)
}

View file

@ -1,21 +0,0 @@
package com.tangem.feature.tester.di
import com.tangem.core.featuretoggle.manager.FeatureTogglesManager
import com.tangem.feature.tester.featuretoggles.DefaultTesterFeatureToggles
import com.tangem.features.tester.api.TesterFeatureToggles
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object TesterFeatureTogglesModule {
@Provides
@Singleton
fun provideTesterFeatureToggles(featureTogglesManager: FeatureTogglesManager): TesterFeatureToggles {
return DefaultTesterFeatureToggles(featureTogglesManager = featureTogglesManager)
}
}

View file

@ -1,19 +0,0 @@
package com.tangem.feature.tester.featuretoggles
import com.tangem.core.featuretoggle.manager.FeatureTogglesManager
import com.tangem.features.tester.api.TesterFeatureToggles
/**
* Default implementation of Tester feature toggles
*
* @property featureTogglesManager manager for getting information about the availability of feature toggles
*
[REDACTED_AUTHOR]
*/
internal class DefaultTesterFeatureToggles(
private val featureTogglesManager: FeatureTogglesManager,
) : TesterFeatureToggles {
override val isDerivePublicKeysRefactoringEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(name = "DERIVE_PUBLIC_KEYS_REFACTORING_ENABLED")
}

View file

@ -4,24 +4,29 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.tangem.core.featuretoggle.manager.FeatureTogglesManager
import com.tangem.core.featuretoggle.manager.MutableFeatureTogglesManager
import com.tangem.feature.tester.presentation.featuretoggles.models.TesterFeatureToggle
import com.tangem.feature.tester.presentation.featuretoggles.state.FeatureTogglesContentState
import com.tangem.feature.tester.presentation.navigation.InnerTesterRouter
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* ViewModel for screen with list of feature toggles
*
* @property featureTogglesManager manager for getting information about the availability of feature toggles
* @property dispatchers coroutine dispatchers provider
*
[REDACTED_AUTHOR]
*/
@HiltViewModel
internal class FeatureTogglesViewModel @Inject constructor(
private val featureTogglesManager: FeatureTogglesManager,
private val dispatchers: CoroutineDispatcherProvider,
) : ViewModel() {
/** Current ui state */
@ -47,9 +52,11 @@ internal class FeatureTogglesViewModel @Inject constructor(
}
private fun onToggleValueChange(name: String, isEnabled: Boolean) {
mutableFeatureTogglesManager.changeToggle(name = name, isEnabled = isEnabled)
viewModelScope.launch(dispatchers.main) {
mutableFeatureTogglesManager.changeToggle(name = name, isEnabled = isEnabled)
uiState = uiState.copy(featureToggles = mutableFeatureTogglesManager.getTesterFeatureToggles())
uiState = uiState.copy(featureToggles = mutableFeatureTogglesManager.getTesterFeatureToggles())
}
}
private fun MutableFeatureTogglesManager.getTesterFeatureToggles(): List<TesterFeatureToggle> {

View file

@ -12,7 +12,7 @@ import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsNotification
import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.txhistory.TokenDetailsPendingTxToTransactionStateConverter
import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.txhistory.TokenDetailsTxHistoryTransactionStateConverter
import com.tangem.feature.tokendetails.presentation.tokendetails.viewmodels.TokenDetailsClickIntents
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
@ -29,7 +29,7 @@ internal class TokenDetailsLoadedBalanceConverter(
) : Converter<Either<CurrencyStatusError, CryptoCurrencyStatus>, TokenDetailsState> {
private val txHistoryItemConverter by lazy {
TokenDetailsPendingTxToTransactionStateConverter(symbol, decimals, clickIntents)
TokenDetailsTxHistoryTransactionStateConverter(symbol, decimals, clickIntents)
}
override fun convert(value: Either<CurrencyStatusError, CryptoCurrencyStatus>): TokenDetailsState {

View file

@ -1,109 +0,0 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.txhistory
import com.tangem.core.ui.components.transactions.state.TransactionState
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.utils.DateTimeFormatters
import com.tangem.domain.txhistory.models.TxHistoryItem
import com.tangem.feature.tokendetails.presentation.tokendetails.viewmodels.TokenDetailsClickIntents
import com.tangem.features.tokendetails.impl.R
import com.tangem.utils.converter.Converter
import com.tangem.utils.toBriefAddressFormat
import com.tangem.utils.toFormattedCurrencyString
import org.joda.time.DateTime
import org.joda.time.DateTimeZone
// FIXME: Refactoring needed
/** Same as [TokenDetailsTxHistoryTransactionStateConverter] but with other timestamp format */
internal class TokenDetailsPendingTxToTransactionStateConverter(
private val symbol: String,
private val decimals: Int,
private val clickIntents: TokenDetailsClickIntents,
) : Converter<TxHistoryItem, TransactionState> {
override fun convert(value: TxHistoryItem): TransactionState {
return createTransactionStateItem(item = value)
}
private fun createTransactionStateItem(item: TxHistoryItem): TransactionState {
return TransactionState.Content(
txHash = item.txHash,
amount = item.getAmount(),
timestamp = item.timestampInMillis.toTimeFormat(),
status = item.status.tiUiStatus(),
direction = item.extractDirection(),
iconRes = item.extractIcon(),
title = item.extractTitle(),
subtitle = item.extractSubtitle(),
onClick = { clickIntents.onTransactionClick(item.txHash) },
)
}
private fun TxHistoryItem.extractIcon(): Int = if (status == TxHistoryItem.TransactionStatus.Failed) {
R.drawable.ic_close_24
} else {
when (type) {
is TxHistoryItem.TransactionType.Approve -> R.drawable.ic_doc_24
is TxHistoryItem.TransactionType.Operation,
is TxHistoryItem.TransactionType.Swap,
is TxHistoryItem.TransactionType.Transfer,
is TxHistoryItem.TransactionType.UnknownOperation,
-> if (isOutgoing) R.drawable.ic_arrow_up_24 else R.drawable.ic_arrow_down_24
}
}
private fun TxHistoryItem.extractTitle(): TextReference = when (val type = type) {
is TxHistoryItem.TransactionType.Approve -> resourceReference(R.string.common_approval)
is TxHistoryItem.TransactionType.Operation -> stringReference(type.name)
is TxHistoryItem.TransactionType.Swap -> resourceReference(R.string.common_swap)
is TxHistoryItem.TransactionType.Transfer -> resourceReference(R.string.common_transfer)
is TxHistoryItem.TransactionType.UnknownOperation -> resourceReference(R.string.transaction_history_operation)
}
private fun TxHistoryItem.extractSubtitle(): TextReference =
when (val interactionAddress = interactionAddressType) {
is TxHistoryItem.InteractionAddressType.Contract -> resourceReference(
id = R.string.transaction_history_contract_address,
formatArgs = wrappedList(interactionAddress.address.toBriefAddressFormat()),
)
is TxHistoryItem.InteractionAddressType.Multiple -> resourceReference(
id = if (isOutgoing) {
R.string.transaction_history_transaction_to_address
} else {
R.string.transaction_history_transaction_from_address
},
formatArgs = wrappedList(resourceReference(R.string.transaction_history_multiple_addresses)),
)
is TxHistoryItem.InteractionAddressType.User -> resourceReference(
id = if (isOutgoing) {
R.string.transaction_history_transaction_to_address
} else {
R.string.transaction_history_transaction_from_address
},
formatArgs = wrappedList(interactionAddress.address.toBriefAddressFormat()),
)
}
private fun TxHistoryItem.TransactionStatus.tiUiStatus() = when (this) {
TxHistoryItem.TransactionStatus.Confirmed -> TransactionState.Content.Status.Confirmed
TxHistoryItem.TransactionStatus.Failed -> TransactionState.Content.Status.Failed
TxHistoryItem.TransactionStatus.Unconfirmed -> TransactionState.Content.Status.Unconfirmed
}
private fun Long.toTimeFormat(): String {
return DateTimeFormatters.formatTime(time = DateTime(this, DateTimeZone.getDefault()))
}
private fun TxHistoryItem.extractDirection() =
if (isOutgoing) TransactionState.Content.Direction.OUTGOING else TransactionState.Content.Direction.INCOMING
private fun TxHistoryItem.getAmount(): String {
val prefix = when (status) {
TxHistoryItem.TransactionStatus.Failed -> ""
else -> if (isOutgoing) "-" else "+"
}
return prefix + amount.toFormattedCurrencyString(currency = symbol, decimals = decimals)
}
}

View file

@ -5,7 +5,6 @@ import com.tangem.core.ui.components.transactions.state.TransactionState
import com.tangem.core.ui.components.transactions.state.TxHistoryState
import com.tangem.core.ui.components.transactions.state.TxHistoryState.TxHistoryItemState
import com.tangem.core.ui.utils.toDateFormat
import com.tangem.core.ui.utils.toTimeFormat
import com.tangem.domain.txhistory.models.TxHistoryItem
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState
import com.tangem.feature.tokendetails.presentation.tokendetails.viewmodels.TokenDetailsClickIntents
@ -52,8 +51,7 @@ internal class TokenDetailsTxHistoryItemFlowConverter(
terminalSeparatorType = TerminalSeparatorType.SOURCE_COMPLETE,
item = TxHistoryItemState.Title(clickIntents::onExploreClick),
)
.insertGroupTitle() // method uses the raw timestamp
.formatTransactionsTimestamp() // method formats the timestamp
.insertGroupTitle()
}
}
.launchIn(CoroutineScope(Dispatchers.IO))
@ -94,28 +92,10 @@ internal class TokenDetailsTxHistoryItemFlowConverter(
}
}
/**
* Map the [PagingData] to format the [TxHistoryItemState] timestamp
*/
private fun PagingData<TxHistoryItemState>.formatTransactionsTimestamp(): PagingData<TxHistoryItemState> {
return map { txHistoryItemState ->
if (txHistoryItemState is TxHistoryItemState.Transaction &&
txHistoryItemState.state is TransactionState.Content
) {
val txContent = txHistoryItemState.state as TransactionState.Content
txHistoryItemState.copy(
state = txContent.copy(timestamp = txContent.timestamp.toLong().toTimeFormat()),
)
} else {
txHistoryItemState
}
}
}
private fun TxHistoryItemState?.getTimestamp(): Long? {
return if (this is TxHistoryItemState.Transaction && this.state is TransactionState.Content) {
val txContent = this.state as TransactionState.Content
requireNotNull(txContent.timestamp.toLongOrNull()) { "Timestamp must be Long type" }
txContent.timestamp
} else {
null
}

View file

@ -6,6 +6,7 @@ import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.utils.toTimeFormat
import com.tangem.domain.txhistory.models.TxHistoryItem
import com.tangem.feature.tokendetails.presentation.tokendetails.viewmodels.TokenDetailsClickIntents
import com.tangem.features.tokendetails.impl.R
@ -13,8 +14,6 @@ import com.tangem.utils.converter.Converter
import com.tangem.utils.toBriefAddressFormat
import com.tangem.utils.toFormattedCurrencyString
// FIXME: Refactoring needed
/** Same as [TokenDetailsPendingTxToTransactionStateConverter] but with other timestamp format */
internal class TokenDetailsTxHistoryTransactionStateConverter(
private val symbol: String,
private val decimals: Int,
@ -30,12 +29,13 @@ internal class TokenDetailsTxHistoryTransactionStateConverter(
return TransactionState.Content(
txHash = item.txHash,
amount = item.getAmount(),
timestamp = item.getRawTimestamp(),
time = item.timestampInMillis.toTimeFormat(),
status = item.status.tiUiStatus(),
direction = item.extractDirection(),
iconRes = item.extractIcon(),
title = item.extractTitle(),
subtitle = item.extractSubtitle(),
timestamp = item.timestampInMillis,
onClick = { clickIntents.onTransactionClick(item.txHash) },
)
}
@ -87,14 +87,6 @@ internal class TokenDetailsTxHistoryTransactionStateConverter(
private fun TxHistoryItem.extractDirection() = if (isOutgoing) Direction.OUTGOING else Direction.INCOMING
/**
* Get timestamp without formatting.
* It's life hack that help us to add transaction's group title to flow.
*
* @see [convert]
*/
private fun TxHistoryItem.getRawTimestamp() = this.timestampInMillis.toString()
private fun TxHistoryItem.TransactionStatus.tiUiStatus() = when (this) {
TxHistoryItem.TransactionStatus.Confirmed -> TransactionState.Content.Status.Confirmed
TxHistoryItem.TransactionStatus.Failed -> TransactionState.Content.Status.Failed

View file

@ -1,11 +0,0 @@
package com.tangem.features.wallet.featuretoggles
/**
* Wallet feature toggles
*
[REDACTED_AUTHOR]
*/
interface WalletFeatureToggles {
val isWalletsScrollingPreviewEnabled: Boolean
}

View file

@ -1,21 +0,0 @@
package com.tangem.feature.wallet.di
import com.tangem.core.featuretoggle.manager.FeatureTogglesManager
import com.tangem.feature.wallet.featuretoggles.DefaultWalletFeatureToggles
import com.tangem.features.wallet.featuretoggles.WalletFeatureToggles
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object WalletFeatureTogglesModule {
@Provides
@Singleton
fun provideWalletFeatureToggles(featureTogglesManager: FeatureTogglesManager): WalletFeatureToggles {
return DefaultWalletFeatureToggles(featureTogglesManager = featureTogglesManager)
}
}

View file

@ -2,7 +2,6 @@ package com.tangem.feature.wallet.di
import com.tangem.core.navigation.ReduxNavController
import com.tangem.feature.wallet.presentation.router.DefaultWalletRouter
import com.tangem.features.wallet.featuretoggles.WalletFeatureToggles
import com.tangem.features.wallet.navigation.WalletRouter
import dagger.Module
import dagger.Provides
@ -16,10 +15,7 @@ internal object WalletRouterModule {
@Provides
@ActivityScoped
fun provideWalletRouter(
reduxNavController: ReduxNavController,
walletFeatureToggles: WalletFeatureToggles,
): WalletRouter {
return DefaultWalletRouter(reduxNavController = reduxNavController, walletFeatureToggles = walletFeatureToggles)
fun provideWalletRouter(reduxNavController: ReduxNavController): WalletRouter {
return DefaultWalletRouter(reduxNavController = reduxNavController)
}
}

View file

@ -1,19 +0,0 @@
package com.tangem.feature.wallet.featuretoggles
import com.tangem.core.featuretoggle.manager.FeatureTogglesManager
import com.tangem.features.wallet.featuretoggles.WalletFeatureToggles
/**
* Default implementation of Wallet feature toggles
*
* @property featureTogglesManager manager for getting information about the availability of feature toggles
*
[REDACTED_AUTHOR]
*/
internal class DefaultWalletFeatureToggles(
private val featureTogglesManager: FeatureTogglesManager,
) : WalletFeatureToggles {
override val isWalletsScrollingPreviewEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(name = "WALLETS_SCROLLING_PREVIEW_ENABLED")
}

View file

@ -1,17 +1,10 @@
package com.tangem.feature.wallet.presentation.common
import androidx.paging.PagingData
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.currency.tokenicon.TokenIconState
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
import com.tangem.core.ui.components.marketprice.PriceChangeState
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.components.transactions.state.TransactionState
import com.tangem.core.ui.components.transactions.state.TxHistoryState
import com.tangem.core.ui.event.consumedEvent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemColorPalette
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.feature.wallet.impl.R
@ -19,16 +12,9 @@ import com.tangem.feature.wallet.presentation.common.state.TokenItemState
import com.tangem.feature.wallet.presentation.organizetokens.model.DraggableItem
import com.tangem.feature.wallet.presentation.organizetokens.model.OrganizeTokensListState
import com.tangem.feature.wallet.presentation.organizetokens.model.OrganizeTokensState
import com.tangem.feature.wallet.presentation.wallet.state.ActionsBottomSheetConfig
import com.tangem.feature.wallet.presentation.wallet.state.TokenActionButtonConfig
import com.tangem.feature.wallet.presentation.wallet.state.WalletMultiCurrencyState
import com.tangem.feature.wallet.presentation.wallet.state.WalletSingleCurrencyState
import com.tangem.feature.wallet.presentation.wallet.state.components.*
import com.tangem.feature.wallet.presentation.wallet.state.components.WalletTokensListState.TokensListItemState
import kotlinx.collections.immutable.persistentListOf
import com.tangem.feature.wallet.presentation.wallet.state.model.*
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.flow.MutableStateFlow
import java.util.UUID
@Suppress("LargeClass")
@ -80,14 +66,6 @@ internal object WalletPreviewData {
)
}
val walletListConfig by lazy {
WalletsListConfig(
selectedWalletIndex = 0,
wallets = wallets.values.toPersistentList(),
onWalletChange = {},
)
}
val coinIconState
get() = TokenIconState.CoinIcon(
url = null,
@ -320,144 +298,4 @@ internal object WalletPreviewData {
),
).toImmutableList(),
)
private val manageButtons by lazy {
persistentListOf(
WalletManageButton.Buy(enabled = true, onClick = {}),
WalletManageButton.Send(enabled = true, onClick = {}),
WalletManageButton.Receive(enabled = true, onClick = {}),
WalletManageButton.Sell(enabled = true, onClick = {}),
WalletManageButton.Swap(enabled = true, onClick = {}),
)
}
val multicurrencyWalletScreenState by lazy {
WalletMultiCurrencyState.Content(
onBackClick = {},
topBarConfig = topBarConfig,
walletsListConfig = walletListConfig,
tokensListState = WalletTokensListState.Content(
persistentListOf(
TokensListItemState.NetworkGroupTitle(id = 0, stringReference("Bitcoin")),
TokensListItemState.Token(
tokenItemVisibleState.copy(
id = "token_1",
titleState = TokenItemState.TitleState.Content(text = "Ethereum"),
cryptoAmountState = TokenItemState.CryptoAmountState.Content("1,89340821 ETH"),
),
),
TokensListItemState.Token(
tokenItemVisibleState.copy(
id = "token_2",
titleState = TokenItemState.TitleState.Content(text = "Ethereum"),
cryptoAmountState = TokenItemState.CryptoAmountState.Content("1,89340821 ETH"),
),
),
TokensListItemState.Token(
tokenItemVisibleState.copy(
id = "token_3",
titleState = TokenItemState.TitleState.Content(text = "Ethereum"),
cryptoAmountState = TokenItemState.CryptoAmountState.Content("1,89340821 ETH"),
),
),
TokensListItemState.Token(
tokenItemVisibleState.copy(
id = "token_4",
titleState = TokenItemState.TitleState.Content(text = "Ethereum"),
cryptoAmountState = TokenItemState.CryptoAmountState.Content("1,89340821 ETH"),
),
),
TokensListItemState.NetworkGroupTitle(id = 1, stringReference("Ethereum")),
TokensListItemState.Token(
tokenItemVisibleState.copy(
id = "token_5",
titleState = TokenItemState.TitleState.Content(text = "Ethereum"),
cryptoAmountState = TokenItemState.CryptoAmountState.Content("1,89340821 ETH"),
),
),
),
organizeTokensButton = WalletTokensListState.OrganizeTokensButtonState.Visible(isEnabled = true, {}),
),
pullToRefreshConfig = WalletPullToRefreshConfig(
isRefreshing = false,
onRefresh = {},
),
notifications = persistentListOf(
WalletNotification.Critical.DevCard,
WalletNotification.Informational.MissingAddresses(missingAddressesCount = 0, onGenerateClick = {}),
WalletNotification.Warning.NetworksUnreachable,
),
bottomSheetConfig = bottomSheet,
onManageTokensClick = {},
event = consumedEvent(),
isBalanceHidden = false,
)
}
val singleWalletScreenState by lazy {
WalletSingleCurrencyState.Content(
onBackClick = {},
topBarConfig = topBarConfig,
walletsListConfig = walletListConfig,
pullToRefreshConfig = WalletPullToRefreshConfig(
isRefreshing = false,
onRefresh = {},
),
notifications = persistentListOf(WalletNotification.Warning.NetworksUnreachable),
buttons = manageButtons,
bottomSheetConfig = bottomSheet,
marketPriceBlockState = MarketPriceBlockState.Content(
currencySymbol = "BTC",
price = "98900.12$",
priceChangeConfig = PriceChangeState.Content(
valueInPercent = "5.16%",
type = PriceChangeType.UP,
),
),
txHistoryState = TxHistoryState.Content(
contentItems = MutableStateFlow(
PagingData.from(
listOf(
TxHistoryState.TxHistoryItemState.GroupTitle(
title = "Today",
itemKey = UUID.randomUUID().toString(),
),
TxHistoryState.TxHistoryItemState.Transaction(
TransactionState.Content(
txHash = UUID.randomUUID().toString(),
amount = "-0.500913 BTC",
timestamp = "8:41",
status = TransactionState.Content.Status.Unconfirmed,
direction = TransactionState.Content.Direction.OUTGOING,
iconRes = com.tangem.core.ui.R.drawable.ic_arrow_up_24,
title = resourceReference(com.tangem.core.ui.R.string.common_transfer),
subtitle = TextReference.Str("33BddS...ga2B"),
onClick = {},
),
),
TxHistoryState.TxHistoryItemState.GroupTitle(
title = "Yesterday",
itemKey = UUID.randomUUID().toString(),
),
TxHistoryState.TxHistoryItemState.Transaction(
TransactionState.Content(
txHash = UUID.randomUUID().toString(),
amount = "-0.500913 BTC",
timestamp = "8:41",
status = TransactionState.Content.Status.Confirmed,
direction = TransactionState.Content.Direction.OUTGOING,
iconRes = com.tangem.core.ui.R.drawable.ic_arrow_up_24,
title = resourceReference(com.tangem.core.ui.R.string.common_transfer),
subtitle = TextReference.Str("33BddS...ga2B"),
onClick = {},
),
),
),
),
),
),
event = consumedEvent(),
isBalanceHidden = false,
)
}
}

View file

@ -26,17 +26,13 @@ import com.tangem.feature.wallet.presentation.WalletFragment
import com.tangem.feature.wallet.presentation.organizetokens.OrganizeTokensScreen
import com.tangem.feature.wallet.presentation.organizetokens.OrganizeTokensViewModel
import com.tangem.feature.wallet.presentation.wallet.ui.WalletScreen
import com.tangem.feature.wallet.presentation.wallet.ui.WalletScreenV2
import com.tangem.feature.wallet.presentation.wallet.viewmodels.WalletViewModel
import com.tangem.feature.wallet.presentation.wallet.viewmodels.WalletViewModelV2
import com.tangem.features.tokendetails.navigation.TokenDetailsRouter
import com.tangem.features.wallet.featuretoggles.WalletFeatureToggles
import kotlin.properties.Delegates
/** Default implementation of wallet feature router */
internal class DefaultWalletRouter(
private val reduxNavController: ReduxNavController,
private val walletFeatureToggles: WalletFeatureToggles,
) : InnerWalletRouter {
private var navController: NavHostController by Delegates.notNull()
@ -53,21 +49,12 @@ internal class DefaultWalletRouter(
startDestination = WalletRoute.Wallet.route,
) {
composable(WalletRoute.Wallet.route) {
if (walletFeatureToggles.isWalletsScrollingPreviewEnabled) {
val viewModel = hiltViewModel<WalletViewModelV2>().apply {
setWalletRouter(router = this@DefaultWalletRouter)
subscribeToLifecycle(LocalLifecycleOwner.current)
}
WalletScreenV2(state = viewModel.uiState.collectAsStateWithLifecycle().value)
} else {
val viewModel = hiltViewModel<WalletViewModel>().apply {
router = this@DefaultWalletRouter
}
LocalLifecycleOwner.current.lifecycle.addObserver(viewModel)
WalletScreen(state = viewModel.uiState)
val viewModel = hiltViewModel<WalletViewModel>().apply {
setWalletRouter(router = this@DefaultWalletRouter)
subscribeToLifecycle(LocalLifecycleOwner.current)
}
WalletScreen(state = viewModel.uiState.collectAsStateWithLifecycle().value)
}
composable(

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