Updated on 2026-08-14

This commit is contained in:
Tangem 2023-05-04 21:00:14 +08:00
parent 40209bc1c3
commit 2e797e8027
24 changed files with 1041 additions and 735 deletions

View file

@ -17,6 +17,7 @@
</inspection_tool>
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />

View file

@ -16,6 +16,7 @@ object FoundTokenConverter : Converter<CoinsResponse.Coin, FoundToken> {
id = value.id,
name = value.name,
symbol = value.symbol,
isActive = value.active,
network = value.networks.firstOrNull()?.let { network ->
FoundToken.Network(
id = network.networkId,

View file

@ -6,11 +6,18 @@ package com.tangem.tap.features.customtoken.impl.domain.models
* @property id id
* @property name name
* @property symbol symbol
* @property isActive flag that determines status of token
* @property network network
*
[REDACTED_AUTHOR]
*/
data class FoundToken(val id: String, val name: String, val symbol: String, val network: Network) {
data class FoundToken(
val id: String,
val name: String,
val symbol: String,
val isActive: Boolean,
val network: Network,
) {
/**
* Found token network

View file

@ -6,6 +6,7 @@ import android.view.View
import android.view.ViewGroup
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.core.view.WindowCompat
import androidx.fragment.app.Fragment
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.transition.TransitionInflater
@ -24,6 +25,8 @@ import dagger.hilt.android.AndroidEntryPoint
internal class AddCustomTokenFragment : Fragment() {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
activity?.window?.let { WindowCompat.setDecorFitsSystemWindows(it, true) }
with(TransitionInflater.from(requireContext())) {
enterTransition = inflateTransition(R.transition.fade)
exitTransition = inflateTransition(R.transition.fade)

View file

@ -1,8 +1,6 @@
package com.tangem.tap.features.customtoken.impl.presentation.models
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import com.tangem.blockchain.common.Blockchain
import com.tangem.tap.features.details.ui.cardsettings.TextReference
import com.tangem.wallet.R
@ -94,106 +92,94 @@ internal sealed interface AddCustomTokenInputField {
/** Label */
val label: TextReference
/** Input availability */
val isEnabled: Boolean
/** Flag that determine if current value has error */
val isError: Boolean
/** Placeholder (hint) */
val placeholder: TextReference
/** Flag that determine the processing of current value */
val isLoading: Boolean
/**
* Input field model to enter the contract address
*
* @property value current value
* @property onValueChange lambda be invoked when value is been changed
* @property isError flag that determine if current value has error
* @property keyboardOptions keyboard options
* @property label label
* @property placeholder placeholder (hint)
* @property isLoading flag that determine the processing of current value
* @property isError flag that determine if current value has error
* @property error error description
*/
data class ContactAddress(
override val value: String,
override val onValueChange: (String) -> Unit,
override val isError: Boolean,
override val isLoading: Boolean,
) : AddCustomTokenInputField {
override val isEnabled = true
override val keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next)
override val label = TextReference.Res(R.string.custom_token_contract_address_input_title)
override val placeholder = TextReference.Str(value = "0x0000000000000000000000000000000000000000")
}
override val keyboardOptions: KeyboardOptions,
override val label: TextReference,
override val placeholder: TextReference,
val isLoading: Boolean,
val isError: Boolean,
val error: TextReference? = null,
) : AddCustomTokenInputField
/**
* Input field model to enter the token name
*
* @property value current value
* @property onValueChange lambda be invoked when value is been changed
* @property keyboardOptions keyboard options
* @property label label
* @property placeholder placeholder (hint)
* @property isEnabled input availability
* @property isError flag that determine if current value has error
*/
data class TokenName(
override val value: String,
override val onValueChange: (String) -> Unit,
override val isEnabled: Boolean,
override val isError: Boolean,
) : AddCustomTokenInputField {
override val keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next)
override val label = TextReference.Res(R.string.custom_token_name_input_title)
override val placeholder = TextReference.Res(id = R.string.custom_token_name_input_placeholder)
override val isLoading = false
}
override val keyboardOptions: KeyboardOptions,
override val label: TextReference,
override val placeholder: TextReference,
val isEnabled: Boolean,
) : AddCustomTokenInputField
/**
* Input field model to enter the token symbol
*
* @property value current value
* @property onValueChange lambda be invoked when value is been changed
* @property keyboardOptions keyboard options
* @property label label
* @property placeholder placeholder (hint)
* @property isEnabled input availability
* @property isError flag that determine if current value has error
*/
data class TokenSymbol(
override val value: String,
override val onValueChange: (String) -> Unit,
override val isEnabled: Boolean,
override val isError: Boolean,
) : AddCustomTokenInputField {
override val keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next)
override val label = TextReference.Res(R.string.custom_token_token_symbol_input_title)
override val placeholder = TextReference.Res(id = R.string.custom_token_token_symbol_input_placeholder)
override val isLoading = false
}
override val keyboardOptions: KeyboardOptions,
override val label: TextReference,
override val placeholder: TextReference,
val isEnabled: Boolean,
) : AddCustomTokenInputField
/**
* Input field model to enter the token decimals
*
* @property value current value
* @property onValueChange lambda be invoked when value is been changed
* @property keyboardOptions keyboard options
* @property label label
* @property placeholder placeholder (hint)
* @property isEnabled input availability
* @property isError flag that determine if current value has error
*/
data class Decimals(
override val value: String,
override val onValueChange: (String) -> Unit,
override val isEnabled: Boolean,
override val isError: Boolean,
) : AddCustomTokenInputField {
override val keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Next)
override val label = TextReference.Res(R.string.custom_token_decimals_input_title)
override val placeholder = TextReference.Str(value = "8")
override val isLoading = false
}
override val keyboardOptions: KeyboardOptions,
override val label: TextReference,
override val placeholder: TextReference,
val isEnabled: Boolean,
) : AddCustomTokenInputField
}
/** Base selector field model of add custom token screen */
internal sealed interface AddCustomTokenSelectorField {
/** Selection availability */
val isEnabled: Boolean
/** Label string resource id */
/** Label */
val label: TextReference
/** Selected menu item */
@ -208,35 +194,34 @@ internal sealed interface AddCustomTokenSelectorField {
/**
* Network selector model
*
* @property label label
* @property selectedItem selected menu item
* @property items menu items
* @property onMenuItemClick lambda be invoked when menu item is been selected
*/
data class Network(
override val label: TextReference,
override val selectedItem: SelectorItem.Title,
override val items: List<SelectorItem.Title>,
override val onMenuItemClick: (Int) -> Unit,
) : AddCustomTokenSelectorField {
override val isEnabled = true
override val label = TextReference.Res(R.string.custom_token_network_input_title)
}
) : AddCustomTokenSelectorField
/**
* Derivation path selector model
*
* @property isEnabled selection availability
* @property label label
* @property selectedItem selected menu item
* @property items menu items
* @property onMenuItemClick lambda be invoked when menu item is been selected
* @property isEnabled selection availability
*/
data class DerivationPath(
override val isEnabled: Boolean,
override val label: TextReference,
override val selectedItem: SelectorItem.TitleWithSubtitle,
override val items: List<SelectorItem.TitleWithSubtitle>,
override val onMenuItemClick: (Int) -> Unit,
) : AddCustomTokenSelectorField {
override val label = TextReference.Res(R.string.custom_token_derivation_path_input_title)
}
val isEnabled: Boolean,
) : AddCustomTokenSelectorField
/** Base menu item model */
sealed interface SelectorItem {
@ -259,17 +244,40 @@ internal sealed interface AddCustomTokenSelectorField {
* Menu item with title ans subtitle
*
* @property title title text
* @property subtitle subtitle text
* @property blockchain blockchain
* @property subtitle subtitle text
*/
data class TitleWithSubtitle(
override val title: TextReference,
val subtitle: TextReference,
override val blockchain: Blockchain,
val subtitle: TextReference,
) : SelectorItem
}
}
/**
* Warning model of add custom token screen
*
* @property description warning description
*/
internal sealed class AddCustomTokenWarning(val description: TextReference) {
/** Potential scam warning */
object PotentialScamToken : AddCustomTokenWarning(
description = TextReference.Res(R.string.custom_token_validation_error_not_found),
)
/** Token already added warning */
object TokenAlreadyAdded : AddCustomTokenWarning(
description = TextReference.Res(R.string.custom_token_validation_error_already_added),
)
/** Unsupported Solana token warning */
object UnsupportedSolanaToken : AddCustomTokenWarning(
description = TextReference.Res(R.string.alert_manage_tokens_unsupported_message),
)
}
/**
* Floating button of add custom token screen
*

View file

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

View file

@ -9,4 +9,7 @@ internal interface CustomTokenRouter {
/** Return to last screen */
fun popBackStack()
/** Open wallet (main) screen */
fun openWalletScreen()
}

View file

@ -1,5 +1,6 @@
package com.tangem.tap.features.customtoken.impl.presentation.routers
import com.tangem.tap.common.redux.navigation.AppScreen
import com.tangem.tap.common.redux.navigation.NavigationAction
import com.tangem.tap.store
@ -9,4 +10,8 @@ internal class DefaultCustomTokenRouter : CustomTokenRouter {
override fun popBackStack() {
store.dispatch(NavigationAction.PopBackTo())
}
override fun openWalletScreen() {
store.dispatch(NavigationAction.PopBackTo(screen = AppScreen.Wallet))
}
}

View file

@ -4,8 +4,8 @@ import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTok
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenFloatingButton
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenForm
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenTestBlock
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenWarning
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokensToolbar
import com.tangem.tap.features.details.ui.cardsettings.TextReference
/**
* State holder of add custom token screen
@ -24,7 +24,7 @@ internal sealed interface AddCustomTokenStateHolder {
val form: AddCustomTokenForm
/** Warnings */
val warnings: List<TextReference>
val warnings: Set<AddCustomTokenWarning>
/** Floating button model */
val floatingButton: AddCustomTokenFloatingButton
@ -42,7 +42,7 @@ internal sealed interface AddCustomTokenStateHolder {
onBackButtonClick: () -> Unit = this.onBackButtonClick,
toolbar: AddCustomTokensToolbar = this.toolbar,
form: AddCustomTokenForm = this.form,
warnings: List<TextReference> = this.warnings,
warnings: Set<AddCustomTokenWarning> = this.warnings,
floatingButton: AddCustomTokenFloatingButton = this.floatingButton,
): AddCustomTokenStateHolder {
return when (this) {
@ -64,7 +64,7 @@ internal sealed interface AddCustomTokenStateHolder {
override val onBackButtonClick: () -> Unit,
override val toolbar: AddCustomTokensToolbar,
override val form: AddCustomTokenForm,
override val warnings: List<TextReference>,
override val warnings: Set<AddCustomTokenWarning>,
override val floatingButton: AddCustomTokenFloatingButton,
) : AddCustomTokenStateHolder
@ -83,7 +83,7 @@ internal sealed interface AddCustomTokenStateHolder {
override val onBackButtonClick: () -> Unit,
override val toolbar: AddCustomTokensToolbar,
override val form: AddCustomTokenForm,
override val warnings: List<TextReference>,
override val warnings: Set<AddCustomTokenWarning>,
override val floatingButton: AddCustomTokenFloatingButton,
val testBlock: AddCustomTokenTestBlock,
val bottomSheet: AddCustomTokenChooseTokenBottomSheet,

View file

@ -9,22 +9,21 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.FabPosition
import androidx.compose.material.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.blockchain.common.Blockchain
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.res.TangemTheme
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenFloatingButton
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenInputField
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenSelectorField
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokensToolbar
import com.tangem.tap.features.customtoken.impl.presentation.states.AddCustomTokenStateHolder
import com.tangem.tap.features.customtoken.impl.presentation.ui.components.AddCustomTokenFloatingButton
import com.tangem.tap.features.customtoken.impl.presentation.ui.components.AddCustomTokenForm
import com.tangem.tap.features.customtoken.impl.presentation.ui.components.AddCustomTokenToolbar
import com.tangem.tap.features.customtoken.impl.presentation.ui.components.AddCustomTokenWarning
import com.tangem.tap.features.details.ui.cardsettings.TextReference
import com.tangem.wallet.R
import com.tangem.tap.features.customtoken.impl.presentation.ui.components.AddCustomTokenWarnings
/**
* Add custom token content
@ -37,6 +36,7 @@ import com.tangem.wallet.R
internal fun AddCustomTokenContent(state: AddCustomTokenStateHolder.Content) {
BackHandler(onBack = state.onBackButtonClick)
var floatingButtonHeight by remember { mutableStateOf(0.dp) }
Scaffold(
topBar = {
AddCustomTokenToolbar(
@ -44,78 +44,36 @@ internal fun AddCustomTokenContent(state: AddCustomTokenStateHolder.Content) {
onBackButtonClick = state.toolbar.onBackButtonClick,
)
},
floatingActionButton = { AddCustomTokenFloatingButton(model = state.floatingButton) },
floatingActionButton = {
val density = LocalDensity.current
val verticalPadding = TangemTheme.dimens.spacing32
AddCustomTokenFloatingButton(
model = state.floatingButton,
modifier = Modifier.onSizeChanged {
floatingButtonHeight = with(density) { it.height.toDp() + verticalPadding }
},
)
},
floatingActionButtonPosition = FabPosition.Center,
) {
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(paddingValues = it)
.fillMaxSize()
.verticalScroll(rememberScrollState()),
.padding(bottom = floatingButtonHeight)
.fillMaxSize(),
) {
AddCustomTokenForm(model = state.form)
state.warnings.forEach { description ->
key(description) {
AddCustomTokenWarning(description)
}
}
AddCustomTokenWarnings(warnings = state.warnings)
}
}
}
@Preview(showSystemUi = true)
@Preview
@Composable
private fun Preview_AddCustomTokenContent() {
TangemTheme {
AddCustomTokenContent(
state = AddCustomTokenStateHolder.Content(
onBackButtonClick = {},
toolbar = AddCustomTokensToolbar(
title = TextReference.Res(R.string.add_custom_token_title),
onBackButtonClick = {},
),
form = com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenForm(
contractAddressInputField = AddCustomTokenInputField.ContactAddress(
value = "",
onValueChange = {},
isError = false,
isLoading = false,
),
networkSelectorField = AddCustomTokenSelectorField.Network(
selectedItem = AddCustomTokenSelectorField.SelectorItem.Title(
title = TextReference.Str("Avalanche"),
blockchain = Blockchain.Avalanche,
),
items = listOf(),
onMenuItemClick = {},
),
tokenNameInputField = AddCustomTokenInputField.TokenName(
value = "",
onValueChange = {},
isEnabled = false,
isError = false,
),
tokenSymbolInputField = AddCustomTokenInputField.TokenSymbol(
value = "",
onValueChange = {},
isEnabled = false,
isError = false,
),
decimalsInputField = AddCustomTokenInputField.Decimals(
value = "",
onValueChange = {},
isEnabled = false,
isError = false,
),
derivationPathSelectorField = null,
),
warnings = listOf(),
floatingButton = AddCustomTokenFloatingButton(
isEnabled = false,
onClick = {},
),
),
)
AddCustomTokenContent(state = AddCustomTokenPreviewData.createContent())
}
}

View file

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

View file

@ -1,6 +1,10 @@
package com.tangem.tap.features.customtoken.impl.presentation.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import com.tangem.core.ui.res.TangemTheme
import com.tangem.tap.features.customtoken.impl.presentation.states.AddCustomTokenStateHolder
/**
@ -10,7 +14,6 @@ import com.tangem.tap.features.customtoken.impl.presentation.states.AddCustomTok
*
[REDACTED_AUTHOR]
*/
@Suppress("UnusedPrivateMember")
@Composable
internal fun AddCustomTokenScreen(stateHolder: AddCustomTokenStateHolder) {
when (stateHolder) {
@ -18,3 +21,20 @@ internal fun AddCustomTokenScreen(stateHolder: AddCustomTokenStateHolder) {
is AddCustomTokenStateHolder.TestContent -> AddCustomTokenTestContent(state = stateHolder)
}
}
@Preview(showSystemUi = true)
@Composable
private fun Preview_AddCustomTokenScreen(
@PreviewParameter(AddCustomTokenScreenProvider::class) stateHolder: AddCustomTokenStateHolder,
) {
TangemTheme {
AddCustomTokenScreen(stateHolder)
}
}
private class AddCustomTokenScreenProvider : CollectionPreviewParameterProvider<AddCustomTokenStateHolder>(
collection = listOf(
AddCustomTokenPreviewData.createContent(),
AddCustomTokenPreviewData.createTestContent(),
),
)

View file

@ -4,6 +4,7 @@ import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
@ -19,33 +20,33 @@ import androidx.compose.material.FabPosition
import androidx.compose.material.Text
import androidx.compose.material.rememberBottomSheetScaffoldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.blockchain.common.Blockchain
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.SpacerH8
import com.tangem.core.ui.components.atoms.Hand
import com.tangem.core.ui.res.TangemTheme
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenChooseTokenBottomSheet
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenChooseTokenBottomSheet.TestTokenItem
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenFloatingButton
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenInputField
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenSelectorField
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenTestBlock
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokensToolbar
import com.tangem.tap.features.customtoken.impl.presentation.states.AddCustomTokenStateHolder
import com.tangem.tap.features.customtoken.impl.presentation.ui.components.AddCustomTokenFloatingButton
import com.tangem.tap.features.customtoken.impl.presentation.ui.components.AddCustomTokenForm
import com.tangem.tap.features.customtoken.impl.presentation.ui.components.AddCustomTokenToolbar
import com.tangem.tap.features.details.ui.cardsettings.TextReference
import com.tangem.wallet.R
import com.tangem.tap.features.customtoken.impl.presentation.ui.components.AddCustomTokenWarnings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@ -74,6 +75,7 @@ internal fun AddCustomTokenTestContent(state: AddCustomTokenStateHolder.TestCont
},
)
var floatingButtonHeight by remember { mutableStateOf(0.dp) }
BottomSheetScaffold(
sheetContent = {
SheetContent(
@ -95,7 +97,16 @@ internal fun AddCustomTokenTestContent(state: AddCustomTokenStateHolder.TestCont
},
)
},
floatingActionButton = { AddCustomTokenFloatingButton(model = state.floatingButton) },
floatingActionButton = {
val density = LocalDensity.current
val verticalPadding = TangemTheme.dimens.spacing32
AddCustomTokenFloatingButton(
model = state.floatingButton,
modifier = Modifier.onSizeChanged {
floatingButtonHeight = with(density) { it.height.toDp() + verticalPadding }
},
)
},
floatingActionButtonPosition = FabPosition.Center,
sheetBackgroundColor = TangemTheme.colors.background.secondary,
sheetPeekHeight = TangemTheme.dimens.size0,
@ -104,15 +115,19 @@ internal fun AddCustomTokenTestContent(state: AddCustomTokenStateHolder.TestCont
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(it),
.padding(paddingValues = it)
.padding(bottom = floatingButtonHeight)
.fillMaxSize(),
) {
TestBlock(
model = state.testBlock,
state.testBlock,
coroutineScope,
bottomSheetScaffoldState,
)
AddCustomTokenForm(model = state.form)
AddCustomTokenWarnings(warnings = state.warnings)
}
}
}
@ -243,69 +258,10 @@ private fun TestBlock(
}
}
@Preview(showSystemUi = true)
@Preview
@Composable
private fun Preview_AddCustomTokenTestContent() {
TangemTheme {
AddCustomTokenTestContent(
state = AddCustomTokenStateHolder.TestContent(
onBackButtonClick = {},
toolbar = AddCustomTokensToolbar(
title = TextReference.Res(R.string.add_custom_token_title),
onBackButtonClick = {},
),
form = com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenForm(
contractAddressInputField = AddCustomTokenInputField.ContactAddress(
value = "",
onValueChange = {},
isError = false,
isLoading = false,
),
networkSelectorField = AddCustomTokenSelectorField.Network(
selectedItem = AddCustomTokenSelectorField.SelectorItem.Title(
title = TextReference.Str(value = "Avalanche"),
blockchain = Blockchain.Avalanche,
),
items = listOf(),
onMenuItemClick = {},
),
tokenNameInputField = AddCustomTokenInputField.TokenName(
value = "",
onValueChange = {},
isEnabled = false,
isError = false,
),
tokenSymbolInputField = AddCustomTokenInputField.TokenSymbol(
value = "",
onValueChange = {},
isEnabled = false,
isError = false,
),
decimalsInputField = AddCustomTokenInputField.Decimals(
value = "",
onValueChange = {},
isEnabled = false,
isError = false,
),
derivationPathSelectorField = null,
),
warnings = listOf(),
floatingButton = AddCustomTokenFloatingButton(
isEnabled = false,
onClick = {},
),
testBlock = AddCustomTokenTestBlock(
chooseTokenButtonText = "Choose token",
clearButtonText = "Clear address",
resetButtonText = "Reset",
onClearAddressButtonClick = {},
onResetButtonClick = {},
),
bottomSheet = AddCustomTokenChooseTokenBottomSheet(
categoriesBlocks = listOf(),
onTestTokenClick = {},
),
),
)
AddCustomTokenTestContent(state = AddCustomTokenPreviewData.createTestContent())
}
}

View file

@ -8,6 +8,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import com.tangem.core.ui.components.PrimaryButtonIconLeft
import com.tangem.core.ui.res.TangemTheme
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenFloatingButton
@ -17,13 +19,14 @@ import com.tangem.wallet.R
* Add custom token floating button. Attached above the keyboard.
*
* @param model button model
* @param modifier modifier
*
[REDACTED_AUTHOR]
*/
@Composable
internal fun AddCustomTokenFloatingButton(model: AddCustomTokenFloatingButton) {
internal fun AddCustomTokenFloatingButton(model: AddCustomTokenFloatingButton, modifier: Modifier = Modifier) {
PrimaryButtonIconLeft(
modifier = Modifier
modifier = modifier
.imePadding()
.padding(horizontal = TangemTheme.dimens.spacing16)
.fillMaxWidth(),
@ -36,16 +39,17 @@ internal fun AddCustomTokenFloatingButton(model: AddCustomTokenFloatingButton) {
@Preview
@Composable
private fun Preview_AddCustomTokenFloatingButton_Enabled() {
private fun Preview_AddCustomTokenFloatingButton(
@PreviewParameter(AddCustomTokenFloatingButtonProvider::class) model: AddCustomTokenFloatingButton,
) {
TangemTheme {
AddCustomTokenFloatingButton(model = AddCustomTokenFloatingButton(isEnabled = true, onClick = {}))
AddCustomTokenFloatingButton(model)
}
}
@Preview
@Composable
private fun Preview_AddCustomTokenFloatingButton_Disabled() {
TangemTheme {
AddCustomTokenFloatingButton(model = AddCustomTokenFloatingButton(isEnabled = false, onClick = {}))
}
}
private class AddCustomTokenFloatingButtonProvider : CollectionPreviewParameterProvider<AddCustomTokenFloatingButton>(
listOf(
AddCustomTokenFloatingButton(isEnabled = true, onClick = {}),
AddCustomTokenFloatingButton(isEnabled = false, onClick = {}),
),
)

View file

@ -1,6 +1,10 @@
package com.tangem.tap.features.customtoken.impl.presentation.ui.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@ -14,6 +18,7 @@ import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ExposedDropdownMenuBox
import androidx.compose.material.ExposedDropdownMenuDefaults
import androidx.compose.material.LinearProgressIndicator
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
@ -26,13 +31,14 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.blockchain.common.Blockchain
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import com.tangem.core.ui.res.TangemTheme
import com.tangem.tap.common.compose.TangemTextFieldsDefault
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenForm
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenInputField
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenSelectorField
import com.tangem.tap.features.details.ui.cardsettings.TextReference
import com.tangem.tap.features.customtoken.impl.presentation.ui.AddCustomTokenPreviewData
import com.tangem.tap.features.details.ui.cardsettings.resolveReference
/**
@ -68,7 +74,37 @@ internal fun AddCustomTokenForm(model: AddCustomTokenForm) {
@Composable
private fun InputField(model: AddCustomTokenInputField) {
Column {
val isError = (model as? AddCustomTokenInputField.ContactAddress)?.isError ?: false
TextField(model, isError)
(model as? AddCustomTokenInputField.ContactAddress)?.error?.resolveReference()?.let {
AnimatedVisibility(
visible = isError,
enter = fadeIn() + slideInVertically(),
exit = slideOutVertically() + fadeOut(),
) {
Text(
text = it,
color = MaterialTheme.colors.error,
style = TangemTheme.typography.body2,
)
}
}
}
}
@Composable
private fun TextField(model: AddCustomTokenInputField, isError: Boolean) {
Box {
val isEnabled = when (model) {
is AddCustomTokenInputField.ContactAddress -> true
is AddCustomTokenInputField.Decimals -> model.isEnabled
is AddCustomTokenInputField.TokenName -> model.isEnabled
is AddCustomTokenInputField.TokenSymbol -> model.isEnabled
}
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = model.value,
@ -79,8 +115,8 @@ private fun InputField(model: AddCustomTokenInputField) {
text = model.label.resolveReference(),
style = TangemTheme.typography.caption,
color = TangemTextFieldsDefault.defaultTextFieldColors.labelColor(
enabled = model.isEnabled,
error = model.isError,
enabled = isEnabled,
error = isError,
interactionSource = remember { MutableInteractionSource() },
).value,
)
@ -90,20 +126,20 @@ private fun InputField(model: AddCustomTokenInputField) {
text = model.placeholder.resolveReference(),
style = TangemTheme.typography.body1,
color = TangemTextFieldsDefault.defaultTextFieldColors
.placeholderColor(enabled = model.isEnabled)
.placeholderColor(enabled = isEnabled)
.value,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
singleLine = true,
enabled = model.isEnabled,
isError = model.isError,
enabled = isEnabled,
isError = isError,
colors = TangemTextFieldsDefault.defaultTextFieldColors,
)
AnimatedVisibility(
visible = model.isLoading,
visible = (model as? AddCustomTokenInputField.ContactAddress)?.isLoading ?: false,
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter)
@ -124,6 +160,7 @@ private fun SelectorField(model: AddCustomTokenSelectorField) {
expanded = isExpanded,
onExpandedChange = { isExpanded = !isExpanded },
) {
val isEnabled = (model as? AddCustomTokenSelectorField.DerivationPath)?.isEnabled ?: true
OutlinedTextField(
value = when (val item = model.selectedItem) {
is AddCustomTokenSelectorField.SelectorItem.Title -> item.title
@ -132,14 +169,14 @@ private fun SelectorField(model: AddCustomTokenSelectorField) {
modifier = Modifier.fillMaxWidth(),
onValueChange = {},
readOnly = true,
enabled = model.isEnabled,
enabled = isEnabled,
label = { Text(text = model.label.resolveReference()) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = isExpanded) },
colors = TangemTextFieldsDefault.defaultTextFieldColors,
)
ExposedDropdownMenu(
expanded = isExpanded && model.isEnabled,
expanded = isExpanded && isEnabled,
onDismissRequest = { isExpanded = false },
) {
FocusRequester
@ -173,44 +210,18 @@ private fun SelectorField(model: AddCustomTokenSelectorField) {
@Preview
@Composable
private fun Preview_AddCustomTokenForm() {
private fun Preview_AddCustomTokenForm(@PreviewParameter(AddCustomTokenFormProvider::class) model: AddCustomTokenForm) {
TangemTheme {
AddCustomTokenForm(
AddCustomTokenForm(
contractAddressInputField = AddCustomTokenInputField.ContactAddress(
value = "",
onValueChange = {},
isError = false,
isLoading = false,
),
networkSelectorField = AddCustomTokenSelectorField.Network(
selectedItem = AddCustomTokenSelectorField.SelectorItem.Title(
title = TextReference.Str(value = "Avalanche"),
blockchain = Blockchain.Avalanche,
),
items = listOf(),
onMenuItemClick = {},
),
tokenNameInputField = AddCustomTokenInputField.TokenName(
value = "",
onValueChange = {},
isEnabled = false,
isError = false,
),
tokenSymbolInputField = AddCustomTokenInputField.TokenSymbol(
value = "",
onValueChange = {},
isEnabled = false,
isError = false,
),
decimalsInputField = AddCustomTokenInputField.Decimals(
value = "",
onValueChange = {},
isEnabled = false,
isError = false,
),
derivationPathSelectorField = null,
),
)
AddCustomTokenForm(model)
}
}
private class AddCustomTokenFormProvider : CollectionPreviewParameterProvider<AddCustomTokenForm>(
collection = listOf(
AddCustomTokenPreviewData.createDefaultForm(),
AddCustomTokenPreviewData.createDefaultForm().copy(derivationPathSelectorField = null),
AddCustomTokenPreviewData.createDefaultForm().let { form ->
form.copy(contractAddressInputField = form.contractAddressInputField.copy(isLoading = true))
},
),
)

View file

@ -2,38 +2,56 @@ package com.tangem.tap.features.customtoken.impl.presentation.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Card
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.sp
import com.tangem.core.ui.res.TangemColorPalette
import com.tangem.core.ui.res.TangemTheme
import com.tangem.tap.features.details.ui.cardsettings.TextReference
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenWarning
import com.tangem.tap.features.customtoken.impl.presentation.ui.AddCustomTokenPreviewData
import com.tangem.tap.features.details.ui.cardsettings.resolveReference
import com.tangem.wallet.R
/**
* Add custom token warning component
* FIXME("Incorrect typography. Replace with typography from design system")
* Add custom token warnings
*
* @param description warning description
* @param modifier modifier
* @param warnings warnings descriptions set
*
[REDACTED_AUTHOR]
*/
@Composable
internal fun AddCustomTokenWarning(description: TextReference, modifier: Modifier = Modifier) {
internal fun AddCustomTokenWarnings(warnings: Set<AddCustomTokenWarning>) {
Column(
modifier = Modifier
.padding(horizontal = TangemTheme.dimens.spacing16)
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8),
) {
warnings.forEach { warning ->
key(warning) { AddCustomTokenWarning(warning) }
}
}
}
@Composable
private fun AddCustomTokenWarning(warning: AddCustomTokenWarning) {
Card(
modifier = modifier,
modifier = Modifier.fillMaxSize(),
shape = TangemTheme.shapes.roundedCornersSmall2,
backgroundColor = TangemColorPalette.Tangerine,
contentColor = TangemColorPalette.White,
elevation = TangemTheme.dimens.elevation4,
) {
// FIXME("Incorrect typography. Replace with typography from design system")
Column(
modifier = Modifier.padding(all = TangemTheme.dimens.spacing16),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8),
@ -44,10 +62,18 @@ internal fun AddCustomTokenWarning(description: TextReference, modifier: Modifie
style = TangemTheme.typography.body2.copy(fontWeight = FontWeight.Bold),
)
Text(
text = description.resolveReference(),
text = warning.description.resolveReference(),
fontSize = 13.sp,
lineHeight = 18.sp,
)
}
}
}
@Preview
@Composable
private fun Preview_AddCustomTokenWarnings() {
TangemTheme {
AddCustomTokenWarnings(warnings = AddCustomTokenPreviewData.createWarnings())
}
}

View file

@ -0,0 +1,35 @@
package com.tangem.tap.features.customtoken.impl.presentation.viewmodels
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.tap.common.analytics.events.ManageTokens
import com.tangem.tap.features.wallet.models.Currency
/** Analytics sender for tokens list screen */
class AddCustomTokenAnalyticsSender(private val analyticsEventHandler: AnalyticsEventHandler) {
fun sendWhenScreenOpened() {
analyticsEventHandler.send(ManageTokens.CustomToken.ScreenOpened)
}
fun sendWhenAddTokenButtonClicked(currency: Currency, address: String) {
analyticsEventHandler.send(
when (currency) {
is Currency.Blockchain -> {
ManageTokens.CustomToken.TokenWasAdded.Blockchain(
derivationPath = currency.derivationPath,
blockchain = currency.blockchain,
)
}
is Currency.Token -> {
ManageTokens.CustomToken.TokenWasAdded.Token(
symbol = currency.currencySymbol,
derivationPath = currency.derivationPath,
blockchain = currency.blockchain,
contractAddress = address,
)
}
},
)
}
}

View file

@ -1,8 +1,11 @@
package com.tangem.tap.features.customtoken.impl.presentation.viewmodels
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ViewModel
@ -14,12 +17,14 @@ import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.domain.AddCustomTokenError
import com.tangem.domain.common.TapWorkarounds.derivationStyle
import com.tangem.domain.common.TapWorkarounds.isTestCard
import com.tangem.domain.common.extensions.canHandleToken
import com.tangem.domain.common.extensions.fromNetworkId
import com.tangem.domain.common.extensions.isSupportedInApp
import com.tangem.domain.common.extensions.supportedBlockchains
import com.tangem.tap.common.analytics.events.ManageTokens
import com.tangem.domain.common.extensions.toNetworkId
import com.tangem.tap.domain.model.WalletDataModel
import com.tangem.tap.features.customtoken.impl.domain.CustomTokenInteractor
import com.tangem.tap.features.customtoken.impl.domain.models.FoundToken
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenChooseTokenBottomSheet
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenChooseTokenBottomSheet.TestTokenItem
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenChooseTokenBottomSheet.TokensCategoryBlock
@ -27,8 +32,11 @@ import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTok
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenForm
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenInputField
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenSelectorField
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenSelectorField.SelectorItem
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenTestBlock
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenWarning
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokensToolbar
import com.tangem.tap.features.customtoken.impl.presentation.models.CustomTokenType
import com.tangem.tap.features.customtoken.impl.presentation.routers.CustomTokenRouter
import com.tangem.tap.features.customtoken.impl.presentation.states.AddCustomTokenStateHolder
import com.tangem.tap.features.customtoken.impl.presentation.validators.ContactAddressValidator
@ -36,6 +44,7 @@ import com.tangem.tap.features.customtoken.impl.presentation.validators.Contract
import com.tangem.tap.features.details.ui.cardsettings.TextReference
import com.tangem.tap.features.wallet.models.Currency
import com.tangem.tap.proxy.AppStateHolder
import com.tangem.tap.store
import com.tangem.utils.coroutines.AppCoroutineDispatcherProvider
import com.tangem.utils.coroutines.runCatching
import com.tangem.wallet.BuildConfig
@ -49,34 +58,37 @@ import javax.inject.Inject
/**
* ViewModel for add custom token screen
*
* @param analyticsEventHandler analytics event handler
* @param featureRouter feature router
* @property featureInteractor feature interactor
* @property dispatchers coroutine dispatchers provider
* @property reduxStateHolder redux state holder
* @property analyticsEventHandler analytics event handler
*
[REDACTED_AUTHOR]
*/
@Suppress("LargeClass")
@HiltViewModel
internal class AddCustomTokenViewModel @Inject constructor(
analyticsEventHandler: AnalyticsEventHandler,
featureRouter: CustomTokenRouter,
private val featureInteractor: CustomTokenInteractor,
private val dispatchers: AppCoroutineDispatcherProvider,
private val reduxStateHolder: AppStateHolder,
private val analyticsEventHandler: AnalyticsEventHandler,
) : ViewModel(), DefaultLifecycleObserver {
private val analyticsSender = AddCustomTokenAnalyticsSender(analyticsEventHandler)
private val actionsHandler = ActionsHandler(featureRouter)
private val testActionsHandler = TestActionsHandler()
private val formStateBuilder = FormStateBuilder()
/** Screen state */
var uiState by mutableStateOf(getInitialUiState())
private set
private var foundTokenId: String? = null
private var foundToken: FoundToken? = null
override fun onCreate(owner: LifecycleOwner) {
analyticsEventHandler.send(ManageTokens.CustomToken.ScreenOpened)
analyticsSender.sendWhenScreenOpened()
}
private fun getInitialUiState(): AddCustomTokenStateHolder {
@ -84,8 +96,8 @@ internal class AddCustomTokenViewModel @Inject constructor(
AddCustomTokenStateHolder.TestContent(
onBackButtonClick = actionsHandler::onBackButtonClick,
toolbar = createToolbar(),
form = createForm(),
warnings = listOf(),
form = formStateBuilder.createForm(),
warnings = emptySet(),
floatingButton = createFloatingButton(),
testBlock = AddCustomTokenTestBlock(
chooseTokenButtonText = "Choose token",
@ -106,8 +118,8 @@ internal class AddCustomTokenViewModel @Inject constructor(
AddCustomTokenStateHolder.Content(
onBackButtonClick = actionsHandler::onBackButtonClick,
toolbar = createToolbar(),
form = createForm(),
warnings = listOf(),
form = formStateBuilder.createForm(),
warnings = emptySet(),
floatingButton = createFloatingButton(),
)
}
@ -120,7 +132,13 @@ internal class AddCustomTokenViewModel @Inject constructor(
)
}
private fun createForm(): AddCustomTokenForm {
private fun createFloatingButton(): AddCustomTokenFloatingButton {
return AddCustomTokenFloatingButton(isEnabled = false, onClick = actionsHandler::onAddCustomTokenClick)
}
private inner class FormStateBuilder {
fun createForm(): AddCustomTokenForm {
return AddCustomTokenForm(
contractAddressInputField = createContractAddressInputField(),
networkSelectorField = createNetworkSelectorField(),
@ -131,74 +149,78 @@ internal class AddCustomTokenViewModel @Inject constructor(
)
}
fun createDerivationPathSelectorItem(blockchain: Blockchain): SelectorItem.TitleWithSubtitle {
return if (blockchain == Blockchain.Unknown) {
SelectorItem.TitleWithSubtitle(
title = TextReference.Res(R.string.custom_token_derivation_path_default),
subtitle = TextReference.Res(R.string.custom_token_derivation_path_default),
blockchain = Blockchain.Unknown,
)
} else {
SelectorItem.TitleWithSubtitle(
title = blockchain.derivationPath(DerivationStyle.LEGACY)?.rawPath?.let(TextReference::Str)
?: TextReference.Res(R.string.custom_token_derivation_path_default),
subtitle = TextReference.Str(blockchain.fullName),
blockchain = blockchain,
)
}
}
fun createNetworkSelectorItem(blockchain: Blockchain): SelectorItem.Title {
return if (blockchain == Blockchain.Unknown) {
SelectorItem.Title(
title = TextReference.Res(R.string.custom_token_network_input_not_selected),
blockchain = Blockchain.Unknown,
)
} else {
SelectorItem.Title(
title = TextReference.Str(blockchain.fullName),
blockchain = blockchain,
)
}
}
private fun createContractAddressInputField(): AddCustomTokenInputField.ContactAddress {
return AddCustomTokenInputField.ContactAddress(
value = "",
onValueChange = actionsHandler::onContactAddressValueChange,
isError = false,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
label = TextReference.Res(R.string.custom_token_contract_address_input_title),
placeholder = TextReference.Str(value = "0x0000000000000000000000000000000000000000"),
isLoading = false,
isError = false,
error = null,
)
}
private fun createNetworkSelectorField(): AddCustomTokenSelectorField.Network {
val selectorItems = getNetworkSelectorItems()
return AddCustomTokenSelectorField.Network(
label = TextReference.Res(R.string.custom_token_network_input_title),
selectedItem = requireNotNull(selectorItems.firstOrNull()),
items = selectorItems,
onMenuItemClick = {
actionsHandler.onNetworkSelectorItemClick(
selectedItem = requireNotNull(selectorItems.getOrNull(it)),
)
},
onMenuItemClick = actionsHandler::onNetworkSelectorItemClick,
)
}
private fun getNetworkSelectorItems(): List<AddCustomTokenSelectorField.SelectorItem.Title> {
val card = reduxStateHolder.scanResponse?.card
val evmBlockchains = Blockchain.values().filter { card?.isTestCard == it.isTestnet() && it.isEvm() }
val additionalBlockchains = listOf(
Blockchain.Binance,
Blockchain.BinanceTestnet,
Blockchain.Solana,
Blockchain.SolanaTestnet,
Blockchain.Tron,
Blockchain.TronTestnet,
)
return (evmBlockchains + additionalBlockchains)
.filter { card?.supportedBlockchains()?.contains(it) == true }
private fun getNetworkSelectorItems(): List<SelectorItem.Title> {
val defaultNetwork = createNetworkSelectorItem(blockchain = Blockchain.Unknown)
return listOf(defaultNetwork) + Blockchain.values()
.filter { blockchain ->
(blockchain.isEvm() || blockchain.canHandleTokens()) &&
reduxStateHolder.scanResponse?.card?.supportedBlockchains()?.contains(blockchain) == true
}
.map(::createNetworkSelectorItem)
.toMutableList()
.apply {
add(index = 0, element = createNetworkSelectorItem(blockchain = Blockchain.Unknown))
}
}
private fun createNetworkSelectorItem(blockchain: Blockchain): AddCustomTokenSelectorField.SelectorItem.Title {
return when (blockchain) {
Blockchain.Unknown -> {
AddCustomTokenSelectorField.SelectorItem.Title(
title = TextReference.Res(R.string.custom_token_network_input_not_selected),
blockchain = Blockchain.Unknown,
)
}
else -> {
AddCustomTokenSelectorField.SelectorItem.Title(
title = TextReference.Str(blockchain.fullName),
blockchain = blockchain,
)
}
}
}
private fun createTokenNameInputField(): AddCustomTokenInputField.TokenName {
return AddCustomTokenInputField.TokenName(
value = "",
onValueChange = actionsHandler::onTokenNameValueChange,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
label = TextReference.Res(R.string.custom_token_name_input_title),
placeholder = TextReference.Res(id = R.string.custom_token_name_input_placeholder),
isEnabled = false,
isError = false,
)
}
@ -206,8 +228,10 @@ internal class AddCustomTokenViewModel @Inject constructor(
return AddCustomTokenInputField.TokenSymbol(
value = "",
onValueChange = actionsHandler::onTokenSymbolValueChange,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
label = TextReference.Res(R.string.custom_token_token_symbol_input_title),
placeholder = TextReference.Res(id = R.string.custom_token_token_symbol_input_placeholder),
isEnabled = false,
isError = false,
)
}
@ -215,8 +239,10 @@ internal class AddCustomTokenViewModel @Inject constructor(
return AddCustomTokenInputField.Decimals(
value = "",
onValueChange = actionsHandler::onDecimalsValueChange,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Next),
label = TextReference.Res(R.string.custom_token_decimals_input_title),
placeholder = TextReference.Str(value = "8"),
isEnabled = false,
isError = false,
)
}
@ -225,213 +251,126 @@ internal class AddCustomTokenViewModel @Inject constructor(
val selectorItems = getDerivationPathsSelectorItems()
return AddCustomTokenSelectorField.DerivationPath(
isEnabled = true,
label = TextReference.Res(R.string.custom_token_derivation_path_input_title),
selectedItem = requireNotNull(selectorItems.firstOrNull()),
items = selectorItems,
onMenuItemClick = {
val field = requireNotNull(uiState.form.derivationPathSelectorField)
onMenuItemClick = actionsHandler::onDerivationPathSelectorItemClick,
isEnabled = true,
)
}
private fun getDerivationPathsSelectorItems(): List<SelectorItem.TitleWithSubtitle> {
val defaultDerivationPath = createDerivationPathSelectorItem(Blockchain.Unknown)
return listOf(defaultDerivationPath) + Blockchain.values()
.filter { blockchain -> blockchain.isEvm() && blockchain.isSupportedInApp() }
.sortedBy(Blockchain::fullName)
.map(::createDerivationPathSelectorItem)
}
}
private fun updateForm(address: String, selectedNetwork: Blockchain) {
viewModelScope.launch(dispatchers.main) {
runCatching(dispatchers.io) {
featureInteractor.findToken(address = address, blockchain = selectedNetwork)
}
.onSuccess { token ->
foundToken = token
uiState = uiState.copySealed(
form = uiState.form.copy(
derivationPathSelectorField = field.copy(
selectedItem = requireNotNull(selectorItems.getOrNull(it)),
contractAddressInputField = uiState.form.contractAddressInputField.copy(
isLoading = false,
),
networkSelectorField = uiState.form.networkSelectorField.copy(
selectedItem = formStateBuilder.createNetworkSelectorItem(
blockchain = Blockchain.fromNetworkId(token.network.id)
?: Blockchain.Unknown,
),
),
tokenNameInputField = uiState.form.tokenNameInputField.copy(
value = token.name,
isEnabled = false,
),
tokenSymbolInputField = uiState.form.tokenSymbolInputField.copy(
value = token.symbol,
isEnabled = false,
),
decimalsInputField = uiState.form.decimalsInputField.copy(
value = token.network.decimalCount,
isEnabled = false,
),
),
)
}
.onFailure {
foundToken = null
uiState = uiState.copySealed(
form = uiState.form.copy(
contractAddressInputField = uiState.form.contractAddressInputField.copy(
isLoading = false,
),
tokenNameInputField = uiState.form.tokenNameInputField.copy(isEnabled = true),
tokenSymbolInputField = uiState.form.tokenSymbolInputField.copy(isEnabled = true),
decimalsInputField = uiState.form.decimalsInputField.copy(isEnabled = true),
),
)
Timber.e(it)
}
updateDerivationPathSelector()
updateWarnings()
updateFloatingButton()
}
}
private fun updateDerivationPathSelector() {
val derivationPathSelectorField = uiState.form.derivationPathSelectorField ?: return
val selectedValue = derivationPathSelectorField.selectedItem.blockchain
val isSupported = selectedValue.isEvm() || !isDerivationPathSelected()
uiState = uiState.copySealed(
form = uiState.form.copy(
derivationPathSelectorField = uiState.form.derivationPathSelectorField?.copy(
isEnabled = if (derivationPathSelectorField.isEnabled != isSupported) {
isSupported
} else {
derivationPathSelectorField.isEnabled
},
selectedItem = if (isDerivationPathSelected() && !isSupported) {
formStateBuilder.createDerivationPathSelectorItem(Blockchain.Unknown)
} else {
derivationPathSelectorField.selectedItem
},
),
),
)
}
private fun isDerivationPathSelected(): Boolean {
return uiState.form.derivationPathSelectorField?.selectedItem?.blockchain != Blockchain.Unknown
}
private fun updateWarnings() {
uiState = uiState.copySealed(
warnings = buildSet {
when (getCustomTokenType()) {
CustomTokenType.TOKEN -> {
addAll(getTokenWarningSet())
}
CustomTokenType.BLOCKCHAIN -> {
if (isCustomTokenAlreadyAdded()) add(AddCustomTokenWarning.TokenAlreadyAdded)
if (isDerivationPathSelected()) add(AddCustomTokenWarning.PotentialScamToken)
}
}
},
)
}
private fun getDerivationPathsSelectorItems(): List<AddCustomTokenSelectorField.SelectorItem.TitleWithSubtitle> {
val evmBlockchains = Blockchain.values().filter {
reduxStateHolder.scanResponse?.card?.isTestCard == it.isTestnet() && it.isEvm() && it.isSupportedInApp()
}
return evmBlockchains
.sortedBy(Blockchain::fullName)
.map(::createDerivationPathSelectorItem)
.toMutableList()
.apply {
add(index = 0, element = createDerivationPathSelectorItem(Blockchain.Unknown))
}
}
private fun createDerivationPathSelectorItem(
blockchain: Blockchain,
): AddCustomTokenSelectorField.SelectorItem.TitleWithSubtitle {
return when (blockchain) {
Blockchain.Unknown -> {
AddCustomTokenSelectorField.SelectorItem.TitleWithSubtitle(
title = TextReference.Res(R.string.custom_token_derivation_path_default),
subtitle = TextReference.Res(R.string.custom_token_derivation_path_default),
blockchain = Blockchain.Unknown,
)
}
else -> {
AddCustomTokenSelectorField.SelectorItem.TitleWithSubtitle(
title = blockchain.derivationPath(DerivationStyle.LEGACY)?.rawPath?.let(TextReference::Str)
?: TextReference.Res(R.string.custom_token_derivation_path_default),
subtitle = TextReference.Str(blockchain.fullName),
blockchain = blockchain,
)
}
}
}
private fun createFloatingButton(): AddCustomTokenFloatingButton {
return AddCustomTokenFloatingButton(isEnabled = false, onClick = actionsHandler::onAddCustomTokenClick)
}
private inner class ActionsHandler(private val featureRouter: CustomTokenRouter) {
fun onBackButtonClick() {
featureRouter.popBackStack()
}
fun onAddCustomTokenClick() {
if (uiState.form.networkSelectorField.selectedItem.blockchain != Blockchain.Unknown) {
val selectedNetwork = uiState.form.networkSelectorField.selectedItem.blockchain
val currency = if (isAnyTokenFieldsFilled() || isAllTokenFieldsFilled()) {
Currency.Token(
token = Token(
name = uiState.form.tokenNameInputField.value,
symbol = uiState.form.tokenSymbolInputField.value,
contractAddress = uiState.form.contractAddressInputField.value,
decimals = uiState.form.decimalsInputField.value.toInt(),
id = foundTokenId,
),
blockchain = selectedNetwork,
derivationPath = getDerivationPath(
mainNetwork = selectedNetwork,
derivationNetwork = uiState.form.derivationPathSelectorField?.selectedItem?.blockchain,
derivationStyle = reduxStateHolder.scanResponse?.card?.derivationStyle,
)?.rawPath,
)
private fun getCustomTokenType(): CustomTokenType {
return if (isAnyTokenFieldsFilled() || isAllTokenFieldsFilled()) {
CustomTokenType.TOKEN
} else {
Currency.Blockchain(
blockchain = selectedNetwork,
derivationPath = getDerivationPath(
mainNetwork = selectedNetwork,
derivationNetwork = uiState.form.derivationPathSelectorField?.selectedItem?.blockchain,
derivationStyle = reduxStateHolder.scanResponse?.card?.derivationStyle,
)?.rawPath,
)
}
sendOnAddTokenButtonClick(currency = currency, address = uiState.form.contractAddressInputField.value)
viewModelScope.launch(dispatchers.io) {
featureInteractor.saveToken(
currency = currency,
address = uiState.form.contractAddressInputField.value,
)
}
}
}
fun onContactAddressValueChange(enteredValue: String) {
with(uiState.form) {
val selectedNetwork = networkSelectorField.selectedItem.blockchain
val isValid = ContactAddressValidator.validate(
address = enteredValue,
blockchain = selectedNetwork,
)
when (isValid) {
is ContractAddressValidatorResult.Success -> {
uiState = uiState.copySealed(
form = uiState.form.copy(
contractAddressInputField = contractAddressInputField.copy(
isError = false,
isLoading = true,
),
),
)
updateForm(address = enteredValue, selectedNetwork = selectedNetwork)
}
is ContractAddressValidatorResult.Error -> {
handleContractAddressErrorValidation(type = isValid.type)
}
}
updateDerivationPathSelector()
// TODO("[REDACTED_TASK_KEY] Update warnings")
// TODO("[REDACTED_TASK_KEY] Update floating button")
}
}
fun onNetworkSelectorItemClick(selectedItem: AddCustomTokenSelectorField.SelectorItem.Title) {
uiState = uiState.copySealed(
form = uiState.form.copy(
networkSelectorField = uiState.form.networkSelectorField.copy(selectedItem = selectedItem),
),
)
onContactAddressValueChange(uiState.form.contractAddressInputField.value)
}
fun onTokenNameValueChange(enteredValue: String) {
uiState = uiState.copySealed(
form = uiState.form.copy(
tokenNameInputField = uiState.form.tokenNameInputField.copy(value = enteredValue),
),
)
// TODO("[REDACTED_TASK_KEY] Update floating button")
}
fun onTokenSymbolValueChange(enteredValue: String) {
uiState = uiState.copySealed(
form = uiState.form.copy(
tokenSymbolInputField = uiState.form.tokenSymbolInputField.copy(value = enteredValue),
),
)
// TODO("[REDACTED_TASK_KEY] Update floating button")
}
fun onDecimalsValueChange(enteredValue: String) {
uiState = uiState.copySealed(
form = uiState.form.copy(
decimalsInputField = uiState.form.decimalsInputField.copy(value = enteredValue),
),
)
// TODO("[REDACTED_TASK_KEY] Update floating button")
}
private fun getDerivationPath(
mainNetwork: Blockchain,
derivationNetwork: Blockchain?,
derivationStyle: DerivationStyle?,
): DerivationPath? {
val network = if (derivationNetwork == Blockchain.Unknown) mainNetwork else derivationNetwork
return network?.derivationPath(
style = if (derivationNetwork == Blockchain.Unknown) derivationStyle else DerivationStyle.LEGACY,
)
}
private fun sendOnAddTokenButtonClick(currency: Currency, address: String) {
when (currency) {
is Currency.Blockchain -> {
analyticsEventHandler.send(
ManageTokens.CustomToken.TokenWasAdded.Blockchain(
derivationPath = currency.derivationPath,
blockchain = currency.blockchain,
),
)
}
is Currency.Token -> {
analyticsEventHandler.send(
ManageTokens.CustomToken.TokenWasAdded.Token(
symbol = currency.currencySymbol,
derivationPath = currency.derivationPath,
blockchain = currency.blockchain,
contractAddress = address,
),
)
}
CustomTokenType.BLOCKCHAIN
}
}
@ -449,72 +388,150 @@ internal class AddCustomTokenViewModel @Inject constructor(
}
}
private fun updateForm(address: String, selectedNetwork: Blockchain) {
viewModelScope.launch(dispatchers.main) {
runCatching(dispatchers.io) {
featureInteractor.findToken(address = address, blockchain = selectedNetwork)
private fun getTokenWarningSet(): Set<AddCustomTokenWarning> {
val networkSelectorValue = uiState.form.networkSelectorField.selectedItem.blockchain
val isContractAddressFieldEmpty = ContactAddressValidator.validate(
address = uiState.form.contractAddressInputField.value,
blockchain = networkSelectorValue,
).let {
it is ContractAddressValidatorResult.Error && it.type == AddCustomTokenError.FieldIsEmpty
}
.onSuccess { token ->
with(uiState.form) {
val isSupportedToken = if (!isNetworkSelected()) {
true
} else {
reduxStateHolder.scanResponse?.card?.canHandleToken(networkSelectorValue) ?: false
}
return buildSet {
if (!isSupportedToken && !isContractAddressFieldEmpty) add(AddCustomTokenWarning.UnsupportedSolanaToken)
if (isCustomTokenAlreadyAdded()) add(AddCustomTokenWarning.TokenAlreadyAdded)
if (foundToken == null && isAnyTokenFieldsFilled() || foundToken?.isActive == false) {
add(AddCustomTokenWarning.PotentialScamToken)
}
}
}
private fun isNetworkSelected(): Boolean {
return uiState.form.networkSelectorField.selectedItem.blockchain != Blockchain.Unknown
}
private fun updateFloatingButton() {
if (isCustomTokenAlreadyAdded()) {
uiState = uiState.copySealed(
form = copy(
contractAddressInputField = contractAddressInputField.copy(isLoading = false),
networkSelectorField = networkSelectorField.copy(
selectedItem = createNetworkSelectorItem(
blockchain = Blockchain.fromNetworkId(token.network.id)
?: Blockchain.Unknown,
),
),
tokenNameInputField = tokenNameInputField.copy(
value = token.name,
isEnabled = false,
),
tokenSymbolInputField = tokenSymbolInputField.copy(
value = token.symbol,
isEnabled = false,
),
decimalsInputField = decimalsInputField.copy(
value = token.network.decimalCount,
isEnabled = false,
warnings = uiState.warnings + AddCustomTokenWarning.TokenAlreadyAdded,
floatingButton = uiState.floatingButton.copy(isEnabled = false),
)
return
}
val state = when {
isAllTokenFieldsFilled() && isNetworkSelected() -> {
val networkSelectorValue = uiState.form.networkSelectorField.selectedItem.blockchain
val error = ContactAddressValidator.validate(
address = uiState.form.contractAddressInputField.value,
blockchain = networkSelectorValue,
)
val isSupportedToken = reduxStateHolder.scanResponse?.card
?.canHandleToken(networkSelectorValue)
?: false
uiState.copySealed(
floatingButton = uiState.floatingButton.copy(
isEnabled = error is ContractAddressValidatorResult.Success && isSupportedToken,
),
)
}
isAnyTokenFieldsFilled() -> {
uiState.copySealed(floatingButton = uiState.floatingButton.copy(isEnabled = false))
}
else -> {
uiState.copySealed(
floatingButton = uiState.floatingButton.copy(
isEnabled = if (isNetworkSelected()) !isBlockchainAlreadyAdded() else false,
),
)
}
}
.onFailure {
foundTokenId = null
Timber.e(it)
uiState = state.copySealed(
warnings = uiState.warnings - AddCustomTokenWarning.TokenAlreadyAdded,
)
}
private fun isCustomTokenAlreadyAdded(): Boolean {
return when (getCustomTokenType()) {
CustomTokenType.TOKEN -> isTokenAlreadyAdded()
CustomTokenType.BLOCKCHAIN -> isBlockchainAlreadyAdded()
}
}
private fun isTokenAlreadyAdded(): Boolean {
return store.state.walletState.walletsStores
.map { walletStore -> walletStore.walletsData.map(WalletDataModel::currency) }
.flatten()
.filterIsInstance<Currency.Token>()
.any { wrappedCurrency ->
val contractAddress = uiState.form.contractAddressInputField.value
val networkSelectorValue = uiState.form.networkSelectorField.selectedItem.blockchain
val sameId = foundToken?.id == wrappedCurrency.token.id
val sameAddress = contractAddress == wrappedCurrency.token.contractAddress
val sameBlockchain =
Blockchain.fromNetworkId(networkSelectorValue.toNetworkId()) == wrappedCurrency.blockchain
val isSameDerivationPath = getDerivationPath()?.rawPath == wrappedCurrency.derivationPath
sameId && sameAddress && sameBlockchain && isSameDerivationPath
}
}
private fun isBlockchainAlreadyAdded(): Boolean {
return store.state.walletState.walletsStores
.map { walletStore -> walletStore.walletsData.map(WalletDataModel::currency) }
.flatten()
.filterIsInstance<Currency.Blockchain>()
.any {
val networkSelectorValue = uiState.form.networkSelectorField.selectedItem.blockchain
networkSelectorValue == it.blockchain && getDerivationPath()?.rawPath == it.derivationPath
}
}
private fun handleContractAddressErrorValidation(type: AddCustomTokenError) {
with(uiState.form) {
val isNetworkSelectorFilled = networkSelectorField.selectedItem.blockchain != Blockchain.Unknown
val isAnotherTokenFieldsFilled = isAnyTokenFieldsFilled()
when {
isNetworkSelectorFilled && type == AddCustomTokenError.InvalidContractAddress -> {
// TODO("[REDACTED_TASK_KEY] Add error")
isNetworkSelected() && type == AddCustomTokenError.InvalidContractAddress -> {
val isAnotherTokenFieldsFilled = isAnyTokenFieldsFilled()
uiState = uiState.copySealed(
form = uiState.form.copy(
tokenNameInputField = tokenNameInputField.copy(isEnabled = isAnotherTokenFieldsFilled),
tokenSymbolInputField = tokenSymbolInputField.copy(
contractAddressInputField = uiState.form.contractAddressInputField.copy(
isError = true,
error = TextReference.Res(
id = R.string.custom_token_creation_error_invalid_contract_address,
),
),
tokenNameInputField = uiState.form.tokenNameInputField.copy(
isEnabled = isAnotherTokenFieldsFilled,
),
tokenSymbolInputField = uiState.form.tokenSymbolInputField.copy(
isEnabled = isAnotherTokenFieldsFilled,
),
decimalsInputField = uiState.form.decimalsInputField.copy(
isEnabled = isAnotherTokenFieldsFilled,
),
decimalsInputField = decimalsInputField.copy(isEnabled = isAnotherTokenFieldsFilled),
),
)
}
!isNetworkSelectorFilled || type == AddCustomTokenError.FieldIsEmpty -> {
!isNetworkSelected() || type == AddCustomTokenError.FieldIsEmpty -> {
uiState = uiState.copySealed(
form = uiState.form.copy(
contractAddressInputField = contractAddressInputField.copy(isError = false),
tokenNameInputField = tokenNameInputField.copy(value = "", isEnabled = false),
tokenSymbolInputField = tokenSymbolInputField.copy(value = "", isEnabled = false),
decimalsInputField = decimalsInputField.copy(value = "", isEnabled = false),
contractAddressInputField = uiState.form.contractAddressInputField.copy(isError = false),
tokenNameInputField = uiState.form.tokenNameInputField.copy(value = "", isEnabled = false),
tokenSymbolInputField = uiState.form.tokenSymbolInputField.copy(
value = "",
isEnabled = false,
),
decimalsInputField = uiState.form.decimalsInputField.copy(value = "", isEnabled = false),
),
)
}
@ -522,62 +539,185 @@ internal class AddCustomTokenViewModel @Inject constructor(
else -> Unit
}
}
private fun getDerivationPath(): DerivationPath? {
val isNotDerivationPathSelected = !isDerivationPathSelected()
val network = if (isNotDerivationPathSelected) {
uiState.form.networkSelectorField.selectedItem.blockchain
} else {
uiState.form.derivationPathSelectorField?.selectedItem?.blockchain
}
private fun updateDerivationPathSelector() {
val selectedValue = uiState.form.derivationPathSelectorField?.selectedItem?.blockchain ?: return
val isSupported = selectedValue.isEvm() || selectedValue == Blockchain.Unknown
return network?.derivationPath(
style = if (isNotDerivationPathSelected) {
reduxStateHolder.scanResponse?.card?.derivationStyle
} else {
DerivationStyle.LEGACY
},
)
}
if (selectedValue != Blockchain.Unknown && !isSupported) {
private inner class ActionsHandler(private val featureRouter: CustomTokenRouter) {
fun onBackButtonClick() {
featureRouter.popBackStack()
}
fun onContactAddressValueChange(enteredValue: String) {
uiState = uiState.copySealed(
form = uiState.form.copy(
derivationPathSelectorField = uiState.form.derivationPathSelectorField?.copy(
selectedItem = createDerivationPathSelectorItem(Blockchain.Unknown),
contractAddressInputField = uiState.form.contractAddressInputField.copy(value = enteredValue),
),
)
val selectedNetwork = uiState.form.networkSelectorField.selectedItem.blockchain
val validatorResult = ContactAddressValidator.validate(
address = enteredValue,
blockchain = selectedNetwork,
)
when (validatorResult) {
is ContractAddressValidatorResult.Success -> {
uiState = uiState.copySealed(
form = uiState.form.copy(
contractAddressInputField = uiState.form.contractAddressInputField.copy(
isError = false,
isLoading = true,
),
),
)
updateForm(address = enteredValue, selectedNetwork = selectedNetwork)
}
is ContractAddressValidatorResult.Error -> {
handleContractAddressErrorValidation(type = validatorResult.type)
updateDerivationPathSelector()
updateWarnings()
updateFloatingButton()
}
}
}
fun onNetworkSelectorItemClick(index: Int) {
uiState = uiState.copySealed(
form = uiState.form.copy(
networkSelectorField = uiState.form.networkSelectorField.copy(
selectedItem = requireNotNull(uiState.form.networkSelectorField.items.getOrNull(index)),
),
),
)
onContactAddressValueChange(uiState.form.contractAddressInputField.value)
}
fun onTokenNameValueChange(enteredValue: String) {
uiState = uiState.copySealed(
form = uiState.form.copy(
tokenNameInputField = uiState.form.tokenNameInputField.copy(value = enteredValue),
),
)
updateFloatingButton()
}
fun onTokenSymbolValueChange(enteredValue: String) {
uiState = uiState.copySealed(
form = uiState.form.copy(
tokenSymbolInputField = uiState.form.tokenSymbolInputField.copy(value = enteredValue),
),
)
updateFloatingButton()
}
fun onDecimalsValueChange(enteredValue: String) {
uiState = uiState.copySealed(
form = uiState.form.copy(
decimalsInputField = uiState.form.decimalsInputField.copy(value = enteredValue),
),
)
updateFloatingButton()
}
fun onDerivationPathSelectorItemClick(index: Int) {
val field = requireNotNull(uiState.form.derivationPathSelectorField)
uiState = uiState.copySealed(
form = uiState.form.copy(
derivationPathSelectorField = field.copy(
selectedItem = requireNotNull(field.items.getOrNull(index)),
),
),
)
}
if (uiState.form.derivationPathSelectorField?.isEnabled != isSupported) {
uiState = uiState.copySealed(
form = uiState.form.copy(
derivationPathSelectorField = uiState.form.derivationPathSelectorField?.copy(
isEnabled = isSupported,
),
fun onAddCustomTokenClick() {
if (!isNetworkSelected()) return
viewModelScope.launch(dispatchers.main) {
val address = uiState.form.contractAddressInputField.value
val currency = when (getCustomTokenType()) {
CustomTokenType.TOKEN -> {
Currency.Token(
token = Token(
name = uiState.form.tokenNameInputField.value,
symbol = uiState.form.tokenSymbolInputField.value,
contractAddress = address,
decimals = requireNotNull(uiState.form.decimalsInputField.value.toIntOrNull()),
id = foundToken?.id,
),
blockchain = uiState.form.networkSelectorField.selectedItem.blockchain,
derivationPath = getDerivationPath()?.rawPath,
)
}
CustomTokenType.BLOCKCHAIN -> {
Currency.Blockchain(
blockchain = uiState.form.networkSelectorField.selectedItem.blockchain,
derivationPath = getDerivationPath()?.rawPath,
)
}
}
analyticsSender.sendWhenAddTokenButtonClicked(currency, address)
runCatching(dispatchers.io) { featureInteractor.saveToken(currency, address) }
.onSuccess { featureRouter.openWalletScreen() }
.onFailure(Timber::e)
}
}
}
private inner class TestActionsHandler {
fun onClearAddressButtonClick() {
with(uiState.form) {
uiState = uiState.copySealed(
form = copy(
contractAddressInputField = contractAddressInputField.copy(value = ""),
tokenNameInputField = tokenNameInputField.copy(value = "", isEnabled = false),
tokenSymbolInputField = tokenSymbolInputField.copy(value = "", isEnabled = false),
decimalsInputField = decimalsInputField.copy(value = "", isEnabled = false),
form = uiState.form.copy(
contractAddressInputField = uiState.form.contractAddressInputField.copy(
value = "",
isLoading = false,
isError = false,
error = null,
),
),
)
}
}
fun onResetButtonClick() {
with(uiState.form) {
uiState = uiState.copySealed(
form = copy(
contractAddressInputField = contractAddressInputField.copy(value = ""),
form = uiState.form.copy(
contractAddressInputField = contractAddressInputField.copy(
value = "",
isLoading = false,
isError = false,
error = null,
),
networkSelectorField = networkSelectorField.copy(
selectedItem = requireNotNull(networkSelectorField.items.firstOrNull()),
selectedItem = formStateBuilder.createNetworkSelectorItem(blockchain = Blockchain.Unknown),
),
tokenNameInputField = tokenNameInputField.copy(value = "", isEnabled = false),
tokenSymbolInputField = tokenSymbolInputField.copy(value = "", isEnabled = false),
decimalsInputField = decimalsInputField.copy(value = "", isEnabled = false),
derivationPathSelectorField = derivationPathSelectorField?.copy(
selectedItem = requireNotNull(derivationPathSelectorField.items.firstOrNull()),
isEnabled = true,
selectedItem = formStateBuilder.createDerivationPathSelectorItem(Blockchain.Unknown),
),
),
)

View file

@ -174,12 +174,14 @@ fun Warnings(warnings: List<AddCustomTokenError.Warning>) {
Column {
warnings.forEachIndexed { index, item ->
val modifier = when (index) {
0 -> Modifier.padding(16.dp, 0.dp, 16.dp, 0.dp)
warnings.lastIndex -> Modifier.padding(16.dp, 8.dp, 16.dp, 16.dp)
else -> Modifier.padding(16.dp, 8.dp, 16.dp, 0.dp)
0 -> Modifier.padding(vertical = 0.dp)
warnings.lastIndex -> Modifier.padding(top = 8.dp, bottom = 16.dp)
else -> Modifier.padding(top = 8.dp, bottom = 0.dp)
}
AddCustomTokenWarning(
modifier = modifier.fillMaxWidth(),
modifier = modifier
.padding(horizontal = TangemTheme.dimens.spacing16)
.fillMaxWidth(),
warning = item,
converter = warningConverter,
)

View file

@ -1,5 +1,6 @@
package com.tangem.tap.features.tokens.impl.di
import com.tangem.tap.features.customtoken.api.featuretoggles.CustomTokenFeatureToggles
import com.tangem.tap.features.tokens.impl.presentation.router.DefaultTokensListRouter
import com.tangem.tap.features.tokens.impl.presentation.router.TokensListRouter
import dagger.Module
@ -17,5 +18,7 @@ internal object TokensListRouterModule {
@Provides
@ViewModelScoped
fun provideTokensListRouter(): TokensListRouter = DefaultTokensListRouter()
fun provideTokensListRouter(customTokenFeatureToggles: CustomTokenFeatureToggles): TokensListRouter {
return DefaultTokensListRouter(customTokenFeatureToggles)
}
}

View file

@ -3,7 +3,9 @@ package com.tangem.tap.features.tokens.impl.presentation.router
import com.tangem.tap.common.extensions.dispatchDialogShow
import com.tangem.tap.common.extensions.dispatchNotification
import com.tangem.tap.common.redux.AppDialog
import com.tangem.tap.common.redux.navigation.AppScreen
import com.tangem.tap.common.redux.navigation.NavigationAction
import com.tangem.tap.features.customtoken.api.featuretoggles.CustomTokenFeatureToggles
import com.tangem.tap.features.tokens.legacy.redux.TokensAction
import com.tangem.tap.features.wallet.redux.models.WalletDialog
import com.tangem.tap.store
@ -15,16 +17,21 @@ import com.tangem.wallet.R
*
[REDACTED_AUTHOR]
*/
internal class DefaultTokensListRouter : TokensListRouter {
internal class DefaultTokensListRouter(
private val customTokenFeatureToggles: CustomTokenFeatureToggles,
) : TokensListRouter {
override fun popBackStack() {
store.dispatch(NavigationAction.PopBackTo())
store.dispatch(TokensAction.ResetState)
}
override fun openAddCustomTokenScreen() {
if (customTokenFeatureToggles.isRedesignedScreenEnabled) {
store.dispatch(NavigationAction.NavigateTo(AppScreen.AddCustomToken))
} else {
store.dispatch(TokensAction.PrepareAndNavigateToAddCustomToken)
}
}
override fun showAddressCopiedNotification() {
store.dispatchNotification(R.string.contract_address_copied_message)

View file

@ -31,6 +31,7 @@ import com.tangem.tap.common.redux.global.GlobalAction
import com.tangem.tap.common.redux.navigation.AppScreen
import com.tangem.tap.common.redux.navigation.NavigationAction
import com.tangem.tap.domain.TapError
import com.tangem.tap.domain.model.WalletDataModel
import com.tangem.tap.domain.tokens.LoadAvailableCoinsService
import com.tangem.tap.features.wallet.models.Currency
import com.tangem.tap.proxy.redux.DaggerGraphState
@ -316,18 +317,20 @@ class TokensMiddleware {
}
}
val addedCurrencies = store.state.walletState.walletsStores.map { walletStore ->
walletStore.walletsData.map { walletData -> walletData.currency }
}.flatten().map {
when (it) {
val addedCurrencies = store.state.walletState.walletsStores
.map { walletStore -> walletStore.walletsData.map(WalletDataModel::currency) }
.flatten()
.map { currency ->
when (currency) {
is Currency.Blockchain -> DomainWrapped.Currency.Blockchain(
it.blockchain,
it.derivationPath,
currency.blockchain,
currency.derivationPath,
)
is Currency.Token -> DomainWrapped.Currency.Token(
it.token,
it.blockchain,
it.derivationPath,
currency.token,
currency.blockchain,
currency.derivationPath,
)
}
}

View file

@ -1,13 +1,12 @@
package com.tangem.domain
import com.tangem.blockchain.common.DerivationStyle
/**
[REDACTED_AUTHOR]
* Provides a temporary copies of the app module classes, data structures, etc.
*/
// TODO: refactoring: : after refactoring they should be unwrapped and moved
// to appropriate parts of module
@Deprecated("After refactoring they should be unwrapped and moved to appropriate parts of module")
sealed interface DomainWrapped {
// Mirror reflection ot the com.tangem.tap.features.wallet.redux.Currency
@ -30,10 +29,5 @@ sealed interface DomainWrapped {
) : Currency {
override val currencySymbol: String = blockchain.currency
}
fun isCustomCurrency(derivationStyle: DerivationStyle?): Boolean {
if (derivationPath == null || derivationStyle == null) return false
return derivationPath != blockchain.derivationPath(derivationStyle)?.rawPath
}
}
}

View file

@ -251,11 +251,9 @@ internal class AddCustomTokenHub : BaseStoreHub<AddCustomTokenState>("AddCustomT
when (state.getCustomTokenType()) {
CustomTokenType.Blockchain -> {
warningsRemove.add(UnsupportedSolanaToken)
if (alreadyAdded) {
warningsAdd.add(TokenAlreadyAdded)
} else {
warningsRemove.add(TokenAlreadyAdded)
}
if (alreadyAdded) warningsAdd.add(TokenAlreadyAdded) else warningsRemove.add(TokenAlreadyAdded)
if (state.derivationPathIsSelected()) {
warningsAdd.add(PotentialScamToken)
} else {
@ -266,14 +264,13 @@ internal class AddCustomTokenHub : BaseStoreHub<AddCustomTokenState>("AddCustomT
if (tokenIsSupported) {
warningsRemove.add(UnsupportedSolanaToken)
} else {
val error = ContractAddress.validateValue(ContractAddress.getFieldValue())
when (error) {
AddCustomTokenError.FieldIsEmpty -> warningsRemove.add(UnsupportedSolanaToken)
else -> {
val validationResult = ContractAddress.validateValue(ContractAddress.getFieldValue())
if (validationResult == AddCustomTokenError.FieldIsEmpty) {
warningsRemove.add(UnsupportedSolanaToken)
} else {
warningsAdd.add(UnsupportedSolanaToken)
}
}
}
if (isPersistIntoAppSavedTokensList()) {
warningsAdd.add(TokenAlreadyAdded)
@ -329,12 +326,7 @@ internal class AddCustomTokenHub : BaseStoreHub<AddCustomTokenState>("AddCustomT
// blockchain
else -> {
if (state.networkIsSelected()) {
val alreadyAdded = isBlockchainPersistIntoAppSavedTokensList()
if (alreadyAdded) {
disableAddButton()
} else {
enableAddButton()
}
if (isBlockchainPersistIntoAppSavedTokensList()) disableAddButton() else enableAddButton()
} else {
disableAddButton()
}
@ -369,18 +361,18 @@ internal class AddCustomTokenHub : BaseStoreHub<AddCustomTokenState>("AddCustomT
CustomTokenType.Token -> isTokenPersistIntoAppSavedTokensList()
}
private fun isTokenPersistIntoAppSavedTokensList(
tokenId: String? = hubState.foundToken?.id,
tokenContractAddress: String = ContractAddress.getFieldValue(),
tokenNetworkId: String = Network.getFieldValue<Blockchain>().toNetworkId(),
selectedDerivation: Blockchain = DerivationPath.getFieldValue(),
): Boolean {
private fun isTokenPersistIntoAppSavedTokensList(): Boolean {
val savedCurrencies = hubState.appSavedCurrencies ?: return false
val tokenId = hubState.foundToken?.id
val tokenContractAddress = ContractAddress.getFieldValue<String>()
val tokenNetworkId = Network.getFieldValue<Blockchain>().toNetworkId()
val selectedDerivation = DerivationPath.getFieldValue<Blockchain>()
val derivationPath = getDerivationPathFromSelectedBlockchain(selectedDerivation)
savedCurrencies.forEach { wrappedCurrency ->
when (wrappedCurrency) {
is DomainWrapped.Currency.Blockchain -> {}
is DomainWrapped.Currency.Blockchain -> Unit
is DomainWrapped.Currency.Token -> {
val sameId = tokenId == wrappedCurrency.token.id
val sameAddress = tokenContractAddress == wrappedCurrency.token.contractAddress
@ -396,14 +388,12 @@ internal class AddCustomTokenHub : BaseStoreHub<AddCustomTokenState>("AddCustomT
return false
}
private fun isBlockchainPersistIntoAppSavedTokensList(
selectedNetwork: Blockchain = Network.getFieldValue(),
selectedDerivation: Blockchain = DerivationPath.getFieldValue(),
): Boolean {
val state = hubState
val savedCurrencies = state.appSavedCurrencies ?: return false
private fun isBlockchainPersistIntoAppSavedTokensList(): Boolean {
val savedCurrencies = hubState.appSavedCurrencies ?: return false
val selectedNetwork = Network.getFieldValue<Blockchain>()
val selectedDerivation = DerivationPath.getFieldValue<Blockchain>()
val derivationPath = getDerivationPathFromSelectedBlockchain(selectedDerivation)
savedCurrencies.forEach { wrappedCurrency ->
when (wrappedCurrency) {
is DomainWrapped.Currency.Blockchain -> {
@ -411,7 +401,8 @@ internal class AddCustomTokenHub : BaseStoreHub<AddCustomTokenState>("AddCustomT
val isSameDerivationPath = derivationPath?.rawPath == wrappedCurrency.derivationPath
if (isSameBlockchain && isSameDerivationPath) return true
}
is DomainWrapped.Currency.Token -> {}
is DomainWrapped.Currency.Token -> Unit
}
}
return false