From d18d87f41e98062c20bcdbb7866d0023ac770f64 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 23 Aug 2024 18:09:23 +0400 Subject: [PATCH 1/2] Updated on 2026-08-14 --- .../entity/customtoken/CustomTokenFormUM.kt | 2 + .../managetokens/ui/CustomTokenFormContent.kt | 52 +++++++++++++++---- .../managetokens/ui/ManageTokensScreen.kt | 8 +++ 3 files changed, 53 insertions(+), 9 deletions(-) diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/customtoken/CustomTokenFormUM.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/customtoken/CustomTokenFormUM.kt index 902c7de226..efa5adecf9 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/customtoken/CustomTokenFormUM.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/customtoken/CustomTokenFormUM.kt @@ -11,6 +11,7 @@ internal data class CustomTokenFormUM( val tokenForm: TokenFormUM?, val notifications: ImmutableList = persistentListOf(), val canAddToken: Boolean = false, + val isValidating: Boolean = false, val saveToken: () -> Unit, ) { @@ -32,6 +33,7 @@ internal data class TextInputFieldUM( val placeholder: TextReference, val value: String = "", val error: TextReference? = null, + val isEnabled: Boolean = true, val onValueChange: (String) -> Unit, ) diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/CustomTokenFormContent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/CustomTokenFormContent.kt index 76ae892e76..3565f4c326 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/CustomTokenFormContent.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/CustomTokenFormContent.kt @@ -11,11 +11,11 @@ import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction @@ -82,6 +82,7 @@ internal fun CustomTokenFormContent(model: CustomTokenFormUM, modifier: Modifier .fillMaxWidth(), text = stringResource(id = R.string.custom_token_add_token), enabled = model.canAddToken, + showProgress = model.isValidating, onClick = model.saveToken, ) } @@ -159,14 +160,25 @@ private fun TextField( keyboardOptions: KeyboardOptions = KeyboardOptions.Default, keyboardActions: KeyboardActions = KeyboardActions.Default, ) { + var isFocused by remember { mutableStateOf(value = false) } + InformationBlock( modifier = modifier, title = { val color by animateColorAsState( - targetValue = if (model.error != null) { - TangemTheme.colors.text.warning - } else { - TangemTheme.colors.text.tertiary + targetValue = when { + !model.isEnabled -> { + TangemTheme.colors.text.disabled + } + model.error != null -> { + TangemTheme.colors.text.warning + } + model.value.isNotBlank() || isFocused -> { + TangemTheme.colors.text.tertiary + } + else -> { + TangemTheme.colors.text.disabled + } }, label = "Field label color", ) @@ -178,14 +190,27 @@ private fun TextField( ) }, content = { + val color by animateColorAsState( + targetValue = if (model.isEnabled) { + TangemTheme.colors.text.primary1 + } else { + TangemTheme.colors.text.disabled + }, + label = "Field value color", + ) + SimpleTextField( modifier = Modifier .padding(bottom = TangemTheme.dimens.spacing12) - .fillMaxWidth(), + .fillMaxWidth() + .onFocusChanged { + isFocused = it.isFocused + }, value = model.value, + color = color, onValueChange = model.onValueChange, - readOnly = false, placeholder = model.placeholder, + readOnly = !model.isEnabled && !isFocused, singleLine = true, keyboardOptions = keyboardOptions, keyboardActions = keyboardActions, @@ -236,7 +261,16 @@ private class PreviewCustomTokenFormComponentProvider : override val values: Sequence get() = sequenceOf( - PreviewCustomTokenFormComponent(), + PreviewCustomTokenFormComponent( + tokenForm = PreviewCustomTokenFormComponent.tokenForm.copy( + contractAddress = TextInputFieldUM( + label = stringReference("Contract address"), + value = "0x1234567890", + placeholder = stringReference("0x1234567890"), + onValueChange = {}, + ), + ), + ), PreviewCustomTokenFormComponent( tokenForm = PreviewCustomTokenFormComponent.tokenForm.copy( contractAddress = TextInputFieldUM( diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/ManageTokensScreen.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/ManageTokensScreen.kt index 430896ed3a..9769590b55 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/ManageTokensScreen.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/ManageTokensScreen.kt @@ -44,8 +44,10 @@ import com.tangem.core.ui.components.rows.BlockchainRow import com.tangem.core.ui.components.rows.ChainRow import com.tangem.core.ui.components.rows.model.BlockchainRowUM import com.tangem.core.ui.components.rows.model.ChainRowUM +import com.tangem.core.ui.components.snackbar.TangemSnackbarHost import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.LocalSnackbarHostState import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.utils.WindowInsetsZero @@ -93,6 +95,12 @@ internal fun ManageTokensScreen(state: ManageTokensUM, modifier: Modifier = Modi state = state, ) }, + snackbarHost = { + TangemSnackbarHost( + modifier = Modifier.padding(all = TangemTheme.dimens.spacing16), + hostState = LocalSnackbarHostState.current, + ) + }, floatingActionButtonPosition = FabPosition.Center, floatingActionButton = { if (state is ManageTokensUM.ManageContent) { From 76300e781976a3a232909a1bf320af49d17a383a Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 23 Aug 2024 18:57:51 +0400 Subject: [PATCH 2/2] Updated on 2026-08-14 --- .../impl/DefaultCustomTokenFormComponent.kt | 129 +------- .../PreviewCustomTokenFormComponent.kt | 6 +- .../entity/customtoken/CustomTokenFormUM.kt | 4 +- .../customtoken/CustomTokenFormValues.kt | 14 + .../model/CustomTokenFormModel.kt | 311 ++++++++++++++++++ .../utils/CustomCurrencyValidator.kt | 214 ++++++++++++ .../utils/mapper/TokenFormMapper.kt | 13 + .../utils/ui/CustomCurrencyFormOperations.kt | 155 +++++++++ 8 files changed, 716 insertions(+), 130 deletions(-) create mode 100644 features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenFormModel.kt create mode 100644 features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/CustomCurrencyValidator.kt create mode 100644 features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/mapper/TokenFormMapper.kt create mode 100644 features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/ui/CustomCurrencyFormOperations.kt diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultCustomTokenFormComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultCustomTokenFormComponent.kt index 2ca0b7e1a1..f2413c6b88 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultCustomTokenFormComponent.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultCustomTokenFormComponent.kt @@ -5,33 +5,24 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.core.decompose.context.AppComponentContext -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.features.managetokens.component.CustomTokenFormComponent -import com.tangem.features.managetokens.entity.customtoken.ClickableFieldUM -import com.tangem.features.managetokens.entity.customtoken.CustomTokenFormUM -import com.tangem.features.managetokens.entity.customtoken.CustomTokenFormValues -import com.tangem.features.managetokens.entity.customtoken.TextInputFieldUM -import com.tangem.features.managetokens.impl.R +import com.tangem.features.managetokens.model.CustomTokenFormModel import com.tangem.features.managetokens.ui.CustomTokenFormContent import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.update internal class DefaultCustomTokenFormComponent @AssistedInject constructor( @Assisted context: AppComponentContext, @Assisted private val params: CustomTokenFormComponent.Params, ) : CustomTokenFormComponent, AppComponentContext by context { - private val state: MutableStateFlow = MutableStateFlow( - value = getInitialState(), - ) + private val model: CustomTokenFormModel = getOrCreateModel(params) @Composable override fun Content(modifier: Modifier) { - val state by state.collectAsStateWithLifecycle() + val state by model.state.collectAsStateWithLifecycle() CustomTokenFormContent( modifier = modifier, @@ -39,114 +30,6 @@ internal class DefaultCustomTokenFormComponent @AssistedInject constructor( ) } - private fun getInitialState(): CustomTokenFormUM { - return CustomTokenFormUM( - networkName = ClickableFieldUM( - label = resourceReference(R.string.custom_token_network_input_title), - value = params.network.name, - onClick = ::selectNetwork, - ), - tokenForm = getInitialTokenForm(), - derivationPath = ClickableFieldUM( - label = resourceReference(R.string.custom_token_derivation_path), - value = params.derivationPath.networkName, - onClick = ::selectDerivationPath, - ), - saveToken = { - // TODO: Save token: [REDACTED_JIRA] - }, - ) - } - - private fun getInitialTokenForm(): CustomTokenFormUM.TokenFormUM { - val formValues = params.formValues - - val form = CustomTokenFormUM.TokenFormUM( - contractAddress = TextInputFieldUM( - label = resourceReference(R.string.custom_token_contract_address_input_title), - placeholder = stringReference(value = "0x000000000000000000000000000..."), - onValueChange = ::updateContractAddress, - ), - name = TextInputFieldUM( - label = resourceReference(R.string.custom_token_name_input_title), - placeholder = resourceReference(R.string.custom_token_name_input_placeholder), - onValueChange = ::updateTokenName, - ), - symbol = TextInputFieldUM( - label = resourceReference(R.string.custom_token_token_symbol_input_title), - placeholder = resourceReference(R.string.custom_token_token_symbol_input_placeholder), - onValueChange = ::updateTokenSymbol, - ), - decimals = TextInputFieldUM( - label = resourceReference(R.string.custom_token_decimals_input_title), - placeholder = stringReference(value = "0"), - onValueChange = ::updateDecimals, - ), - ) - - return formValues.fillValues(form) - } - - private fun updateContractAddress(value: String) { - // TODO: Add field validation: [REDACTED_JIRA] - state.update { state -> - val tokenForm = state.tokenForm ?: return@update state - - state.copy( - tokenForm = tokenForm.copy( - contractAddress = tokenForm.contractAddress.copy(value = value), - ), - ) - } - } - - private fun updateTokenName(value: String) { - // TODO: Add field validation: [REDACTED_JIRA] - state.update { state -> - val tokenForm = state.tokenForm ?: return@update state - - state.copy( - tokenForm = tokenForm.copy( - name = tokenForm.name.copy(value = value), - ), - ) - } - } - - private fun updateTokenSymbol(value: String) { - // TODO: Add field validation: [REDACTED_JIRA] - state.update { state -> - val tokenForm = state.tokenForm ?: return@update state - - state.copy( - tokenForm = tokenForm.copy( - symbol = tokenForm.symbol.copy(value = value), - ), - ) - } - } - - private fun updateDecimals(value: String) { - // TODO: Add field validation: [REDACTED_JIRA] - state.update { state -> - val tokenForm = state.tokenForm ?: return@update state - - state.copy( - tokenForm = tokenForm.copy( - decimals = tokenForm.decimals.copy(value = value), - ), - ) - } - } - - private fun selectNetwork() { - params.onSelectNetworkClick(CustomTokenFormValues(state.value.tokenForm)) - } - - private fun selectDerivationPath() { - params.onSelectDerivationPathClick(CustomTokenFormValues(state.value.tokenForm)) - } - @AssistedFactory interface Factory : CustomTokenFormComponent.Factory { override fun create( @@ -154,8 +37,4 @@ internal class DefaultCustomTokenFormComponent @AssistedInject constructor( params: CustomTokenFormComponent.Params, ): DefaultCustomTokenFormComponent } - - private companion object { - const val FORM_VALUES_KEY = "form_values" - } } \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewCustomTokenFormComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewCustomTokenFormComponent.kt index 1c69d487de..fa909e5257 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewCustomTokenFormComponent.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewCustomTokenFormComponent.kt @@ -11,7 +11,7 @@ import com.tangem.features.managetokens.entity.customtoken.CustomTokenFormUM import com.tangem.features.managetokens.entity.customtoken.TextInputFieldUM import com.tangem.features.managetokens.impl.R import com.tangem.features.managetokens.ui.CustomTokenFormContent -import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.persistentListOf internal class PreviewCustomTokenFormComponent( @@ -19,7 +19,7 @@ internal class PreviewCustomTokenFormComponent( derivationPath: ClickableFieldUM = PreviewCustomTokenFormComponent.derivationPath, canAddToken: Boolean = false, tokenForm: CustomTokenFormUM.TokenFormUM? = PreviewCustomTokenFormComponent.tokenForm, - notifications: ImmutableList = PreviewCustomTokenFormComponent.notifications, + notifications: PersistentList = PreviewCustomTokenFormComponent.notifications, ) : CustomTokenFormComponent { private val previewState = CustomTokenFormUM( @@ -73,7 +73,7 @@ internal class PreviewCustomTokenFormComponent( onValueChange = {}, ), ) - val notifications: ImmutableList = persistentListOf( + val notifications: PersistentList = persistentListOf( CustomTokenFormUM.NotificationUM( id = "1", config = NotificationConfig( diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/customtoken/CustomTokenFormUM.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/customtoken/CustomTokenFormUM.kt index efa5adecf9..1fbd8e4de3 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/customtoken/CustomTokenFormUM.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/customtoken/CustomTokenFormUM.kt @@ -2,14 +2,14 @@ package com.tangem.features.managetokens.entity.customtoken import com.tangem.core.ui.components.notifications.NotificationConfig import com.tangem.core.ui.extensions.TextReference -import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.persistentListOf internal data class CustomTokenFormUM( val networkName: ClickableFieldUM, val derivationPath: ClickableFieldUM, val tokenForm: TokenFormUM?, - val notifications: ImmutableList = persistentListOf(), + val notifications: PersistentList = persistentListOf(), val canAddToken: Boolean = false, val isValidating: Boolean = false, val saveToken: () -> Unit, diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/customtoken/CustomTokenFormValues.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/customtoken/CustomTokenFormValues.kt index 1551b4bc30..983eb8e61a 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/customtoken/CustomTokenFormValues.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/customtoken/CustomTokenFormValues.kt @@ -1,5 +1,6 @@ package com.tangem.features.managetokens.entity.customtoken +import com.tangem.domain.managetokens.model.AddCustomTokenForm import com.tangem.features.managetokens.entity.customtoken.CustomTokenFormUM.TokenFormUM import kotlinx.serialization.Serializable @@ -28,4 +29,17 @@ internal value class CustomTokenFormValues private constructor(private val value symbol = to.symbol.copy(value = values.getOrElse(index = 2) { "" }), decimals = to.decimals.copy(value = values.getOrElse(index = 3) { "" }), ) + + fun toDomainModel(): AddCustomTokenForm.Raw? { + return if (values.isEmpty()) { + null + } else { + AddCustomTokenForm.Raw( + contractAddress = values.getOrElse(index = 0) { "" }, + name = values.getOrElse(index = 1) { "" }, + symbol = values.getOrElse(index = 2) { "" }, + decimals = values.getOrElse(index = 3) { "" }, + ) + } + } } \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenFormModel.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenFormModel.kt new file mode 100644 index 0000000000..0565adb15e --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenFormModel.kt @@ -0,0 +1,311 @@ +package com.tangem.features.managetokens.model + +import androidx.compose.ui.res.stringResource +import com.tangem.core.decompose.di.ComponentScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.ui.components.SimpleOkDialog +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.message.ContentMessage +import com.tangem.domain.managetokens.model.exceptoin.CustomTokenFormValidationException +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.features.managetokens.component.CustomTokenFormComponent +import com.tangem.features.managetokens.entity.customtoken.ClickableFieldUM +import com.tangem.features.managetokens.entity.customtoken.CustomTokenFormUM +import com.tangem.features.managetokens.entity.customtoken.CustomTokenFormValues +import com.tangem.features.managetokens.entity.customtoken.TextInputFieldUM +import com.tangem.features.managetokens.impl.R +import com.tangem.features.managetokens.utils.CustomCurrencyValidator +import com.tangem.features.managetokens.utils.mapper.mapToDomainModel +import com.tangem.features.managetokens.utils.ui.* +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@ComponentScoped +internal class CustomTokenFormModel @Inject constructor( + override val dispatchers: CoroutineDispatcherProvider, + private val customCurrencyValidator: CustomCurrencyValidator, + private val messageSender: UiMessageSender, + paramsContainer: ParamsContainer, +) : Model() { + + private val params: CustomTokenFormComponent.Params = paramsContainer.require() + + val state: MutableStateFlow = MutableStateFlow( + value = getInitialState(), + ) + + init { + observeValidatorUpdates() + + if (params.network.canHandleTokens) { + observeTokenFormUpdates() + } else { + modelScope.launch { + customCurrencyValidator.createCoin( + userWalletId = params.userWalletId, + networkId = params.network.id, + derivationPath = params.derivationPath.value, + ) + } + } + } + + private fun getInitialState(): CustomTokenFormUM { + return CustomTokenFormUM( + networkName = ClickableFieldUM( + label = resourceReference(R.string.custom_token_network_input_title), + value = params.network.name, + onClick = ::selectNetwork, + ), + tokenForm = if (params.network.canHandleTokens) { + getInitialTokenForm() + } else { + null + }, + derivationPath = ClickableFieldUM( + label = resourceReference(R.string.custom_token_derivation_path), + value = if (params.derivationPath.id == params.network.id) { + resourceReference(R.string.custom_token_derivation_path_default) + } else { + params.derivationPath.networkName + }, + onClick = ::selectDerivationPath, + ), + saveToken = { + // TODO: Save token: [REDACTED_JIRA] + val dialog = ContentMessage { onDismiss -> + SimpleOkDialog( + message = "Not yet implemented", + onDismissDialog = onDismiss, + ) + } + + messageSender.send(dialog) + }, + ) + } + + @OptIn(FlowPreview::class) + private fun observeTokenFormUpdates() { + state + .sample(periodMillis = 1_000) + .transform { + val values = it.tokenForm?.mapToDomainModel() + + if (values != null) { + emit(values) + } + } + .distinctUntilChanged() + .onEach { formValues -> + customCurrencyValidator.validateForm( + userWalletId = params.userWalletId, + networkId = params.network.id, + derivationPath = params.derivationPath.value, + formValues = formValues, + ) + } + .launchIn(modelScope) + } + + private fun observeValidatorUpdates() = modelScope.launch { + customCurrencyValidator.consumeUpdates { validatorState -> + when (validatorState) { + is CustomCurrencyValidator.State.NotStarted -> Unit + is CustomCurrencyValidator.State.SearchingToken -> updateStateWithSearching() + is CustomCurrencyValidator.State.UnexpectedException -> showErrorDialog() + is CustomCurrencyValidator.State.FormValidationException -> updateStateWithExceptions( + exceptions = validatorState.exceptions, + ) + is CustomCurrencyValidator.State.TokenNotFound -> updateStateWithNotFoundNotification() + is CustomCurrencyValidator.State.Validated -> updateStateWithCurrency( + currency = validatorState.currency, + fillForm = validatorState.fillForm, + isAlreadyAdded = validatorState.isAlreadyAdded, + isCustom = validatorState.isCustom, + ) + } + } + } + + private fun updateStateWithCurrency( + currency: CryptoCurrency, + fillForm: Boolean, + isAlreadyAdded: Boolean, + isCustom: Boolean, + ) { + state.update { state -> + var updatedState = state + .updateWithProgress( + showProgress = false, + canAddToken = !isAlreadyAdded, + clearNotifications = true, + clearFieldErrors = true, + disableSecondaryFields = !isCustom, + ) + + if (fillForm) { + updatedState = updatedState.updateWithCurrency(currency) + } + + if (isAlreadyAdded) { + updatedState = updatedState.updateWithCurrencyAlreadyAddedNotification() + } + + if (isCustom) { + updatedState = updatedState.updateWithCurrencyNotFoundNotification() + } + + updatedState + } + } + + private fun updateStateWithNotFoundNotification() { + state.update { state -> + state + .updateWithProgress( + showProgress = false, + canAddToken = false, + clearNotifications = true, + clearFieldErrors = true, + ) + .updateWithCurrencyNotFoundNotification() + } + } + + private fun updateStateWithSearching() { + state.update { state -> + state.updateWithProgress( + showProgress = true, + canAddToken = false, + clearNotifications = false, + clearFieldErrors = false, + ) + } + } + + private fun showErrorDialog() { + val dialog = ContentMessage { onDismiss -> + SimpleOkDialog( + message = stringResource(R.string.common_unknown_error), + onDismissDialog = onDismiss, + ) + } + + messageSender.send(dialog) + } + + private fun updateStateWithExceptions(exceptions: List) { + state.update { state -> + val validatedState = state + .updateWithProgress( + showProgress = false, + canAddToken = false, + clearNotifications = true, + clearFieldErrors = true, + ) + + exceptions.fold(validatedState) { stateAcc, exception -> + when (exception) { + is CustomTokenFormValidationException.ContractAddress -> { + stateAcc.updateWithContractAddressException(exception) + } + is CustomTokenFormValidationException.Decimals -> { + stateAcc.updateWithDecimalsException(exception) + } + is CustomTokenFormValidationException.EmptyName -> { + stateAcc // No need to display error + } + is CustomTokenFormValidationException.EmptySymbol -> { + stateAcc // No need to display error + } + is CustomTokenFormValidationException.DataError -> { + Timber.e(exception.cause, "Unable to validate custom currency") + stateAcc + } + } + } + } + } + + private fun getInitialTokenForm(): CustomTokenFormUM.TokenFormUM { + val formValues = params.formValues + + val form = CustomTokenFormUM.TokenFormUM( + contractAddress = TextInputFieldUM( + label = resourceReference(R.string.custom_token_contract_address_input_title), + placeholder = stringReference(CONTRACT_ADDRESS_PLACEHOLDER), + onValueChange = ::updateContractAddress, + ), + name = TextInputFieldUM( + label = resourceReference(R.string.custom_token_name_input_title), + placeholder = resourceReference(R.string.custom_token_name_input_placeholder), + onValueChange = ::updateTokenName, + ), + symbol = TextInputFieldUM( + label = resourceReference(R.string.custom_token_token_symbol_input_title), + placeholder = resourceReference(R.string.custom_token_token_symbol_input_placeholder), + onValueChange = ::updateTokenSymbol, + ), + decimals = TextInputFieldUM( + label = resourceReference(R.string.custom_token_decimals_input_title), + placeholder = stringReference(DECIMALS_PLACEHOLDER), + onValueChange = ::updateDecimals, + ), + ) + + return formValues.fillValues(form) + } + + private fun updateContractAddress(value: String) { + state.update { state -> + state.updateTokenForm { + copy(contractAddress = contractAddress.updateValue(value)) + } + } + } + + private fun updateTokenName(value: String) { + state.update { state -> + state.updateTokenForm { + copy(name = name.updateValue(value)) + } + } + } + + private fun updateTokenSymbol(value: String) { + state.update { state -> + state.updateTokenForm { + copy(symbol = symbol.updateValue(value)) + } + } + } + + private fun updateDecimals(value: String) { + state.update { state -> + state.updateTokenForm { + copy(decimals = decimals.updateValue(value)) + } + } + } + + private fun selectNetwork() { + params.onSelectNetworkClick(CustomTokenFormValues(state.value.tokenForm)) + } + + private fun selectDerivationPath() { + params.onSelectDerivationPathClick(CustomTokenFormValues(state.value.tokenForm)) + } + + private companion object { + const val CONTRACT_ADDRESS_PLACEHOLDER = "0x000000000000000000000000000..." + const val DECIMALS_PLACEHOLDER = "0" + } +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/CustomCurrencyValidator.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/CustomCurrencyValidator.kt new file mode 100644 index 0000000000..373b4640c7 --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/CustomCurrencyValidator.kt @@ -0,0 +1,214 @@ +package com.tangem.features.managetokens.utils + +import arrow.core.getOrElse +import com.tangem.core.decompose.di.ComponentScoped +import com.tangem.domain.managetokens.CheckIsCurrencyNotAddedUseCase +import com.tangem.domain.managetokens.CreateCurrencyUseCase +import com.tangem.domain.managetokens.FindTokenUseCase +import com.tangem.domain.managetokens.ValidateTokenFormUseCase +import com.tangem.domain.managetokens.model.AddCustomTokenForm +import com.tangem.domain.managetokens.model.exceptoin.CustomTokenFormValidationException +import com.tangem.domain.managetokens.model.exceptoin.FindTokenException +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.wallets.models.UserWalletId +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collectLatest +import timber.log.Timber +import javax.inject.Inject + +@ComponentScoped +internal class CustomCurrencyValidator @Inject constructor( + private val validateTokenFormUseCase: ValidateTokenFormUseCase, + private val createCustomCurrencyUseCase: CreateCurrencyUseCase, + private val findTokenUseCase: FindTokenUseCase, + private val checkIsCurrencyNotAddedUseCase: CheckIsCurrencyNotAddedUseCase, +) { + + private val state: MutableStateFlow = MutableStateFlow(State.NotStarted) + + suspend fun consumeUpdates(block: suspend (State) -> Unit) { + state.collectLatest { s -> + block(s) + } + } + + suspend fun validateForm( + userWalletId: UserWalletId, + networkId: Network.ID, + derivationPath: Network.DerivationPath, + formValues: AddCustomTokenForm.Raw, + ) { + val result = validateTokenFormUseCase( + networkId = networkId, + formValues = formValues, + ) + + val validatedForm = result.getOrElse { e -> + state.value = State.FormValidationException(e) + return + } + + when (validatedForm) { + is AddCustomTokenForm.Validated.All -> { + findOrCreateCurrency(userWalletId, networkId, derivationPath, validatedForm) + } + is AddCustomTokenForm.Validated.ContractAddress -> { + findToken(userWalletId, networkId, derivationPath, validatedForm) + } + } + } + + suspend fun createCoin(userWalletId: UserWalletId, networkId: Network.ID, derivationPath: Network.DerivationPath) { + createCurrency(userWalletId, networkId, derivationPath, validatedForm = null) + } + + private suspend fun findOrCreateCurrency( + userWalletId: UserWalletId, + networkId: Network.ID, + derivationPath: Network.DerivationPath, + validatedForm: AddCustomTokenForm.Validated.All, + ) { + state.value = State.SearchingToken + + val foundToken = findTokenUseCase( + userWalletId = userWalletId, + contractAddress = validatedForm.contractAddress, + networkId = networkId, + derivationPath = derivationPath, + ).getOrElse { e -> + when (e) { + is FindTokenException.DataError -> { + Timber.e(e.cause, "Unable to find custom currency") + state.value = State.UnexpectedException(e.cause) + return + } + is FindTokenException.NotFound -> { + null + } + } + } + + if (foundToken != null) { + updateStateToValidated(userWalletId, foundToken, fillForm = true, isCustom = false) + } else { + createCurrency(userWalletId, networkId, derivationPath, validatedForm) + } + } + + private suspend fun findToken( + userWalletId: UserWalletId, + networkId: Network.ID, + derivationPath: Network.DerivationPath, + validatedForm: AddCustomTokenForm.Validated.ContractAddress, + ) { + state.value = State.SearchingToken + + val token = findTokenUseCase( + userWalletId = userWalletId, + contractAddress = validatedForm.contractAddress, + networkId = networkId, + derivationPath = derivationPath, + ).getOrElse { e -> + state.value = when (e) { + is FindTokenException.DataError -> { + Timber.e(e.cause, "Unable to find custom currency") + State.UnexpectedException(e.cause) + } + is FindTokenException.NotFound -> { + State.TokenNotFound + } + } + + return + } + + updateStateToValidated(userWalletId, token, fillForm = true, isCustom = false) + } + + private suspend fun createCurrency( + userWalletId: UserWalletId, + networkId: Network.ID, + derivationPath: Network.DerivationPath, + validatedForm: AddCustomTokenForm.Validated.All?, + ) { + val currency = createCustomCurrencyUseCase( + networkId = networkId, + derivationPath = derivationPath, + formValues = validatedForm, + ).getOrElse { e -> + Timber.e(e, "Unable to create custom currency") + state.value = State.UnexpectedException(e) + return + } + + updateStateToValidated(userWalletId, currency, fillForm = false, isCustom = validatedForm != null) + } + + private suspend fun updateStateToValidated( + userWalletId: UserWalletId, + currency: CryptoCurrency, + fillForm: Boolean, + isCustom: Boolean, + ) { + val isNotAdded = checkIsCurrencyNotAddedUseCase( + userWalletId = userWalletId, + networkId = currency.network.id, + derivationPath = currency.network.derivationPath, + contractAddress = when (currency) { + is CryptoCurrency.Coin -> null + is CryptoCurrency.Token -> currency.contractAddress + }, + ).getOrElse { e -> + Timber.e(e, "Unable to check if currency is already added") + state.value = State.UnexpectedException(e) + + return + } + + state.value = State.Validated( + currency = currency, + fillForm = fillForm, + isAlreadyAdded = !isNotAdded, + isCustom = isCustom, + ) + } + + sealed class State { + + abstract val isFinished: Boolean + + data object NotStarted : State() { + override val isFinished: Boolean = false + } + + data object SearchingToken : State() { + override val isFinished: Boolean = false + } + + data class Validated( + val currency: CryptoCurrency, + val fillForm: Boolean, + val isAlreadyAdded: Boolean, + val isCustom: Boolean, + ) : State() { + override val isFinished: Boolean = true + } + + data class FormValidationException( + val exceptions: List, + ) : State() { + override val isFinished: Boolean = true + } + + data object TokenNotFound : State() { + override val isFinished: Boolean = true + } + + data class UnexpectedException( + val cause: Throwable, + ) : State() { + override val isFinished: Boolean = true + } + } +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/mapper/TokenFormMapper.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/mapper/TokenFormMapper.kt new file mode 100644 index 0000000000..fd9a7bd140 --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/mapper/TokenFormMapper.kt @@ -0,0 +1,13 @@ +package com.tangem.features.managetokens.utils.mapper + +import com.tangem.domain.managetokens.model.AddCustomTokenForm +import com.tangem.features.managetokens.entity.customtoken.CustomTokenFormUM + +internal fun CustomTokenFormUM.TokenFormUM.mapToDomainModel(): AddCustomTokenForm.Raw { + return AddCustomTokenForm.Raw( + contractAddress = contractAddress.value, + symbol = symbol.value, + name = name.value, + decimals = decimals.value, + ) +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/ui/CustomCurrencyFormOperations.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/ui/CustomCurrencyFormOperations.kt new file mode 100644 index 0000000000..71ecd3cd8d --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/ui/CustomCurrencyFormOperations.kt @@ -0,0 +1,155 @@ +package com.tangem.features.managetokens.utils.ui + +import com.tangem.core.ui.components.notifications.NotificationConfig +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.domain.managetokens.ValidateTokenFormUseCase +import com.tangem.domain.managetokens.model.exceptoin.CustomTokenFormValidationException +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.features.managetokens.entity.customtoken.CustomTokenFormUM +import com.tangem.features.managetokens.entity.customtoken.TextInputFieldUM +import com.tangem.features.managetokens.impl.R +import kotlinx.collections.immutable.mutate +import kotlinx.collections.immutable.persistentListOf + +internal fun CustomTokenFormUM.updateTokenForm( + block: CustomTokenFormUM.TokenFormUM.() -> CustomTokenFormUM.TokenFormUM, +): CustomTokenFormUM { + val form = tokenForm ?: return this + + val updatedForm = form.block() + + return copy(tokenForm = updatedForm) +} + +internal fun TextInputFieldUM.updateValue( + value: String = this.value, + error: TextReference? = this.error, + isEnabled: Boolean = this.isEnabled, + clearError: Boolean = false, +): TextInputFieldUM { + return copy( + value = value, + error = if (clearError) null else error, + isEnabled = isEnabled, + ) +} + +internal fun CustomTokenFormUM.updateWithProgress( + showProgress: Boolean, + canAddToken: Boolean = this.canAddToken, + clearNotifications: Boolean = false, + clearFieldErrors: Boolean = false, + disableSecondaryFields: Boolean = false, +): CustomTokenFormUM { + return copy( + isValidating = showProgress, + canAddToken = canAddToken, + notifications = if (clearNotifications) persistentListOf() else notifications, + ).updateTokenForm { + copy( + contractAddress = contractAddress.updateValue( + clearError = clearFieldErrors, + ), + name = name.updateValue( + isEnabled = !showProgress && !disableSecondaryFields, + clearError = clearFieldErrors, + ), + symbol = symbol.updateValue( + isEnabled = !showProgress && !disableSecondaryFields, + clearError = clearFieldErrors, + ), + decimals = decimals.updateValue( + isEnabled = !showProgress && !disableSecondaryFields, + clearError = clearFieldErrors, + ), + ) + } +} + +internal fun CustomTokenFormUM.updateWithCurrency(currency: CryptoCurrency): CustomTokenFormUM { + return updateTokenForm { + copy( + contractAddress = contractAddress.updateValue(error = null), + name = name.updateValue(currency.name), + symbol = symbol.updateValue(currency.symbol), + decimals = decimals.updateValue(currency.decimals.toString()), + ) + } +} + +internal fun CustomTokenFormUM.updateWithContractAddressException( + exception: CustomTokenFormValidationException.ContractAddress, +): CustomTokenFormUM { + return updateTokenForm { + copy( + contractAddress = contractAddress.updateValue( + error = when (exception) { + CustomTokenFormValidationException.ContractAddress.Empty -> { + null + } + CustomTokenFormValidationException.ContractAddress.Invalid -> { + resourceReference(R.string.custom_token_creation_error_invalid_contract_address) + } + }, + ), + ) + } +} + +internal fun CustomTokenFormUM.updateWithDecimalsException( + exception: CustomTokenFormValidationException.Decimals, +): CustomTokenFormUM { + return updateTokenForm { + copy( + decimals = decimals.updateValue( + error = when (exception) { + is CustomTokenFormValidationException.Decimals.Empty -> { + null + } + is CustomTokenFormValidationException.Decimals.Invalid -> { + resourceReference( + R.string.custom_token_creation_error_wrong_decimals, + wrappedList(ValidateTokenFormUseCase.MAX_DECIMALS), + ) + } + }, + ), + ) + } +} + +internal fun CustomTokenFormUM.updateWithCurrencyNotFoundNotification(): CustomTokenFormUM { + val notification = CustomTokenFormUM.NotificationUM( + id = "currency_not_found", + config = NotificationConfig( + title = resourceReference(R.string.custom_token_validation_error_not_found_title), + subtitle = resourceReference(R.string.custom_token_validation_error_not_found_description), + iconResId = R.drawable.img_attention_20, + ), + ) + + return copy( + notifications = notifications.mutate { + it.add(notification) + }, + ) +} + +internal fun CustomTokenFormUM.updateWithCurrencyAlreadyAddedNotification(): CustomTokenFormUM { + val notification = CustomTokenFormUM.NotificationUM( + id = "currency_already_added", + config = NotificationConfig( + title = resourceReference(R.string.custom_token_creation_error_token_already_exist_title), + subtitle = resourceReference(R.string.custom_token_creation_error_token_already_exist_message), + iconResId = R.drawable.img_attention_20, + ), + ) + + return copy( + notifications = notifications.mutate { + it.add(notification) + }, + ) +} \ No newline at end of file