Updated on 2026-08-14

This commit is contained in:
Tangem 2023-08-29 21:42:12 +08:00
parent 537731103f
commit 6d95abf3b8
51 changed files with 19 additions and 3133 deletions

View file

@ -22,7 +22,6 @@ import com.tangem.datasource.config.ConfigManager
import com.tangem.datasource.config.FeaturesLocalLoader
import com.tangem.datasource.config.models.Config
import com.tangem.datasource.connection.NetworkConnectionManager
import com.tangem.domain.DomainLayer
import com.tangem.domain.appcurrency.repository.AppCurrencyRepository
import com.tangem.domain.card.ScanCardProcessor
import com.tangem.domain.common.LogConfig
@ -210,7 +209,6 @@ class TapApplication : Application(), ImageLoaderFactory {
activityResultCaller = foregroundActivityObserver
registerActivityLifecycleCallbacks(foregroundActivityObserver.callbacks)
DomainLayer.init()
preferencesStorage = preferencesDataSource
walletConnectRepository = WalletConnectRepository(this)

View file

@ -1,40 +0,0 @@
package com.tangem.tap.common
import timber.log.Timber
class CompositionCounter(
val id: String,
count: Int = 0,
) {
var count: Int = count
private set
fun increase(id: String): CompositionCounter {
if (this.id != id) return this
count += 1
return CompositionCounter(id, count)
}
}
class CompositionLogger(
private val recomposeViewId: String,
private val tag: String = recomposeViewId,
private var turnOnForIds: List<String> = listOf(recomposeViewId),
) {
val count: Int
get() = compositionCounter.count
private var compositionCounter: CompositionCounter = CompositionCounter(recomposeViewId)
fun nextComposition() {
compositionCounter = compositionCounter.increase(recomposeViewId)
log("")
}
fun log(message: String) {
if (!turnOnForIds.contains(recomposeViewId)) return
Timber.d("$tag[$recomposeViewId]:[${compositionCounter.count}]: $message")
}
}

View file

@ -1,277 +0,0 @@
package com.tangem.tap.common.compose
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
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.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.LinearProgressIndicator
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text
import androidx.compose.material.TextFieldColors
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.tangem.common.module.ModuleError
import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.common.form.Field
import com.tangem.tap.common.CompositionLogger
import com.tangem.tap.common.compose.extensions.stringResourceDefault
import com.tangem.tap.domain.moduleMessage.ModuleMessageConverter
/**
[REDACTED_AUTHOR]
*/
@Composable
fun OutlinedTextFieldWidget(
fieldData: Field.Data<String>,
labelId: Int? = null,
label: String = "",
placeholderId: Int? = null,
placeholder: String = "",
trailingIcon: @Composable (() -> Unit)? = null,
isEnabled: Boolean = true,
isVisible: Boolean = true,
isLoading: Boolean = false,
error: ModuleError? = null,
errorConverter: ModuleMessageConverter? = null,
debounceTextChanges: Long = 400,
visualTransformation: VisualTransformation = VisualTransformation.None,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
onTextChange: (String) -> Unit,
) {
if (!isVisible) return
Column(modifier = Modifier.animateContentSize()) {
OutlinedProgressTextField(
fieldData = fieldData,
label = stringResourceDefault(labelId, label),
placeholder = stringResourceDefault(placeholderId, placeholder),
trailingIcon = trailingIcon,
isEnabled = isEnabled,
isLoading = isLoading,
error = error,
debounce = debounceTextChanges,
visualTransformation = visualTransformation,
keyboardOptions = keyboardOptions,
onTextChange = onTextChange,
)
errorConverter?.let { AnimatedErrorView(errorConverter = it, error = error) }
}
}
@Suppress("LongMethod", "NestedBlockDepth", "MagicNumber", "MaxLineLength")
@Composable
private fun OutlinedProgressTextField(
fieldData: Field.Data<String>,
label: String = "",
placeholder: String = "",
isEnabled: Boolean = true,
isLoading: Boolean = false,
error: ModuleError? = null,
debounce: Long = 400,
visualTransformation: VisualTransformation = VisualTransformation.None,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
colors: TextFieldColors = TangemTextFieldsDefault.defaultTextFieldColors,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
trailingIcon: @Composable (() -> Unit)? = null,
onTextChange: (String) -> Unit,
) {
val logger = remember {
CompositionLogger(label, "OutlinedProgressTextField", listOf("Символ токена"))
}
logger.nextComposition()
val textValueState = remember { mutableStateOf(fieldData.value) }
val textDebouncer = valueDebouncerAsState(
initialValue = fieldData.value,
debounce = debounce,
onEmitValueReceive = {
logger.log("DEBOUNCER: onEmitValueReceived: [$it]")
logger.log("DEBOUNCER: start RECOMPOSE by new value for textValueState.value = [$it]")
textValueState.value = it
},
onValueChange = {
logger.log("DEBOUNCER: onValueChanged: >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> dispatch.toStore([$it])")
onTextChange(it)
},
)
logger.log("RECOMPOSE ---------------------------------------------------------------START [${logger.count}]")
logger.log("RECOMPOSE --data: fieldData.value: [$fieldData]")
logger.log("RECOMPOSE --data: textValueState.value: [${textValueState.value}]")
logger.log("RECOMPOSE --data: textDebouncer.emittedValue = [${textDebouncer.emittedValue}]")
logger.log("RECOMPOSE --data: textDebouncer.debounced = [${textDebouncer.debounced}]")
if (!fieldData.isUserInput) {
// initial value is not from an user
val isNotUserInput = "-- IS NOT USER INPUT"
logger.log("recompose $isNotUserInput")
if (textValueState.value == fieldData.value) {
logger.log("$isNotUserInput: внешние данные ОДИНАКОВЫ с данными в поле")
} else {
logger.log("$isNotUserInput: внешние данные РАЗЛИЧАЮТСЯ с данными в поле")
if (textDebouncer.emittedValue != textDebouncer.debounced || textDebouncer.emitsCountBeforeDebounce > 0) {
logger.log("$isNotUserInput: пользователь ВВОДИТ данные -> внешние данные игнорируем, ждем RECOMPOSE")
} else {
logger.log("$isNotUserInput: пользователь НЕ вводит данные -> пытаемся обработать внешние данные")
if (textValueState.value != textDebouncer.emittedValue ||
textValueState.value != textDebouncer.debounced
) {
logger.log("$isNotUserInput: даннные в поле не соответствуют данным из textDebouncer")
if (textDebouncer.emittedValue.isEmpty() && textDebouncer.debounced.isEmpty()) {
logger.log(
"$isNotUserInput: даннные в textDebouncer ПУСТЫ -> start RECOMPOSE новые данные для " +
"textValueState.value = [${fieldData.value}]",
)
textValueState.value = fieldData.value
} else {
logger.log(
"$isNotUserInput: даннные в textDebouncer НЕ ПУСТЫ -> start RECOMPOSE новые данные для " +
"textValueState.value = [${fieldData.value}]",
)
textValueState.value = fieldData.value
}
} else {
logger.log(
"$isNotUserInput: в пустое поле вставляются данные -> start RECOMPOSE новые данные для " +
"textValueState.value = [${fieldData.value}]",
)
textValueState.value = fieldData.value
}
}
}
}
logger.log("recompose --------------------------------------------------------------FINISH [${logger.count}]")
Box {
OutlinedTextField(
modifier = Modifier
.fillMaxWidth(),
value = textValueState.value,
onValueChange = {
logger.log("WIDGET: textDebouncer.emmit([$it])")
textDebouncer.emmit(it)
},
keyboardOptions = keyboardOptions,
label = {
Text(
text = label,
style = TangemTheme.typography.caption,
color = colors.labelColor(
enabled = isEnabled,
error = error != null,
interactionSource = interactionSource,
).value,
)
},
placeholder = {
Text(
text = placeholder,
style = TangemTheme.typography.body1,
color = colors.placeholderColor(enabled = isEnabled).value,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
trailingIcon = trailingIcon,
singleLine = true,
enabled = isEnabled,
isError = error != null,
visualTransformation = visualTransformation,
colors = colors,
interactionSource = interactionSource,
)
AnimatedVisibility(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter)
.padding(start = 6.dp, top = 0.dp, end = 6.dp, bottom = 6.dp),
visible = isLoading,
) {
LinearProgressIndicator(
color = TangemTheme.colors.icon.primary1,
)
}
}
}
@Composable
private fun AnimatedErrorView(errorConverter: ModuleMessageConverter, error: ModuleError? = null) {
AnimatedVisibility(
visible = error != null,
enter = fadeIn() + slideInVertically(),
exit = slideOutVertically() + fadeOut(),
) {
error?.let {
ErrorView(
text = errorConverter.convert(it).message,
style = TextStyle(fontSize = 14.sp),
)
}
}
}
@Preview
@Composable
private fun OutlinedTextFieldWithErrorTest() {
val context = LocalContext.current
val converter = remember { ModuleMessageConverter(context) }
class SimpleError(
override val code: Int = 1,
override val message: String = "Error message",
override val data: Any? = null,
) : ModuleError()
val modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
Column {
OutlinedTextFieldWidget(
fieldData = Field.Data("", false),
label = "First label",
placeholder = "1 placeholder",
error = null,
errorConverter = converter,
) {}
OutlinedTextFieldWidget(
fieldData = Field.Data("First", false),
label = "First label",
placeholder = "1 placeholder",
error = null,
errorConverter = converter,
) {}
OutlinedTextFieldWidget(
fieldData = Field.Data("First", false),
label = "First label",
placeholder = "1 placeholder",
isLoading = true,
error = null,
errorConverter = converter,
) {}
OutlinedTextFieldWidget(
fieldData = Field.Data("First", false),
label = "First label",
placeholder = "1 placeholder",
error = SimpleError(),
errorConverter = converter,
) {}
}
}

View file

@ -1,24 +0,0 @@
package com.tangem.tap.common.compose
import androidx.compose.foundation.layout.Column
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.sp
/**
[REDACTED_AUTHOR]
* Compose views are not typically used as a main or base view.
*/
@Composable
fun TitleSubtitle(title: String, subtitle: String) {
Column {
Text(text = title)
Text(
text = subtitle,
fontSize = 12.sp,
color = Color.Gray,
)
}
}

View file

@ -1,21 +0,0 @@
package com.tangem.tap.common.compose.extensions
import android.content.res.Resources
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
/**
[REDACTED_AUTHOR]
*/
@Composable
fun stringResourceDefault(@StringRes id: Int?, default: String = ""): String {
val resources = LocalContext.current.resources
return try {
resources.getString(requireNotNull(id))
} catch (ex: Resources.NotFoundException) {
default
} catch (ex: IllegalArgumentException) {
default
}
}

View file

@ -6,7 +6,7 @@ import com.tangem.core.navigation.AppScreen
import com.tangem.core.navigation.FragmentShareTransition
import com.tangem.feature.referral.ReferralFragment
import com.tangem.feature.swap.presentation.SwapFragment
import com.tangem.tap.features.customtoken.legacy.AddCustomTokenFragment
import com.tangem.tap.features.customtoken.impl.presentation.AddCustomTokenFragment
import com.tangem.tap.features.details.ui.appsettings.AppSettingsFragment
import com.tangem.tap.features.details.ui.cardsettings.CardSettingsFragment
import com.tangem.tap.features.details.ui.cardsettings.coderecovery.AccessCodeRecoveryFragment
@ -33,7 +33,6 @@ import com.tangem.tap.proxy.redux.DaggerGraphState
import com.tangem.tap.store
import com.tangem.wallet.R
import timber.log.Timber
import com.tangem.tap.features.customtoken.impl.presentation.AddCustomTokenFragment as RedesignedAddCustomTokenFragment
fun FragmentActivity.openFragment(
screen: AppScreen,
@ -155,18 +154,7 @@ private fun fragmentFactory(screen: AppScreen): Fragment {
AppScreen.AccessCodeRecovery -> AccessCodeRecoveryFragment()
AppScreen.Disclaimer -> DisclaimerFragment()
AppScreen.AddTokens -> TokensListFragment()
AppScreen.AddCustomToken -> {
val featureToggles = store.state.daggerGraphState.get(
getDependency = DaggerGraphState::customTokenFeatureToggles,
)
if (featureToggles.isRedesignedScreenEnabled) {
RedesignedAddCustomTokenFragment()
} else {
AddCustomTokenFragment()
}
}
AppScreen.AddCustomToken -> AddCustomTokenFragment()
AppScreen.WalletDetails -> {
val featureToggles = store.state.daggerGraphState.get(
getDependency = DaggerGraphState::tokenDetailsFeatureToggles,

View file

@ -1,6 +0,0 @@
package com.tangem.tap.common.extensions
/**
[REDACTED_AUTHOR]
*/
typealias ValueCallback<T> = (T) -> Unit

View file

@ -7,8 +7,5 @@ package com.tangem.tap.features.customtoken.api.featuretoggles
*/
interface CustomTokenFeatureToggles {
/** Availability of redesigned screen (internal feature) */
val isRedesignedScreenEnabled: Boolean
val isNewCardScanningEnabled: Boolean
}

View file

@ -14,9 +14,6 @@ internal class DefaultCustomTokenFeatureToggles(
private val featureTogglesManager: FeatureTogglesManager,
) : CustomTokenFeatureToggles {
override val isRedesignedScreenEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(name = "REDESIGNED_CUSTOM_TOKEN_SCREEN_ENABLED")
override val isNewCardScanningEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(name = "NEW_CARD_SCANNING_ENABLED")
}

View file

@ -1,79 +0,0 @@
package com.tangem.tap.features.customtoken.legacy
import android.os.Bundle
import android.view.View
import android.view.WindowManager
import androidx.appcompat.widget.Toolbar
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ComposeView
import com.tangem.core.analytics.Analytics
import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.features.addCustomToken.redux.AddCustomTokenState
import com.tangem.domain.redux.domainStore
import com.tangem.tap.common.analytics.events.ManageTokens
import com.tangem.tap.features.BaseStoreFragment
import com.tangem.tap.features.FragmentOnBackPressedHandler
import com.tangem.tap.features.addBackPressHandler
import com.tangem.tap.features.customtoken.legacy.compose.AddCustomTokenScreen
import com.tangem.tap.features.customtoken.legacy.compose.ClosePopupTrigger
import com.tangem.wallet.R
import org.rekotlin.StoreSubscriber
/**
[REDACTED_AUTHOR]
*/
class AddCustomTokenFragment : BaseStoreFragment(R.layout.view_compose_fragment), StoreSubscriber<AddCustomTokenState> {
private var state: MutableState<AddCustomTokenState> = mutableStateOf(domainStore.state.addCustomTokensState)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Analytics.send(ManageTokens.CustomToken.ScreenOpened)
}
override fun subscribeToStore() {
domainStore.subscribe(this) { state ->
state.skipRepeats { oldState, newState ->
oldState.addCustomTokensState == newState.addCustomTokensState
}.select { it.addCustomTokensState }
}
}
override fun newState(state: AddCustomTokenState) {
if (activity == null || view == null) return
this.state.value = state
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
requireActivity().window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
view.findViewById<Toolbar>(R.id.toolbar)?.setTitle(R.string.add_custom_token_title)
val closePopupTrigger = initClosingPopupTriggerEvent()
view.findViewById<ComposeView>(R.id.view_compose)?.setContent {
TangemTheme {
Box(
modifier = Modifier
.fillMaxSize(),
) {
AddCustomTokenScreen(state, closePopupTrigger)
}
}
}
}
private fun initClosingPopupTriggerEvent(): ClosePopupTrigger = ClosePopupTrigger().apply {
onCloseComplete = ::handleOnBackPressed
addBackPressHandler(
object : FragmentOnBackPressedHandler {
override fun handleOnBackPressed() = close()
},
)
}
}

View file

@ -1,208 +0,0 @@
package com.tangem.tap.features.customtoken.legacy.compose
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.PrimaryButtonIconStart
import com.tangem.core.ui.components.keyboardAsState
import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.AddCustomTokenError
import com.tangem.domain.common.form.DataField
import com.tangem.domain.common.form.FieldId
import com.tangem.domain.features.addCustomToken.CustomTokenFieldId.*
import com.tangem.domain.features.addCustomToken.redux.AddCustomTokenAction
import com.tangem.domain.features.addCustomToken.redux.AddCustomTokenState
import com.tangem.domain.features.addCustomToken.redux.ScreenState
import com.tangem.domain.features.addCustomToken.redux.ViewStates
import com.tangem.domain.redux.domainStore
import com.tangem.tap.common.compose.AddCustomTokenWarning
import com.tangem.tap.domain.moduleMessage.ModuleMessageConverter
import com.tangem.tap.features.customtoken.legacy.compose.test.TestCase
import com.tangem.tap.features.customtoken.legacy.compose.test.TestCasesList
import com.tangem.wallet.R
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun AddCustomTokenScreen(state: MutableState<AddCustomTokenState>, closePopupTrigger: ClosePopupTrigger) {
val selectedTestCase = remember { mutableStateOf(TestCase.ContractAddress) }
val bottomSheetScaffoldState = rememberBottomSheetScaffoldState(
bottomSheetState = BottomSheetState(BottomSheetValue.Collapsed),
)
val coroutineScope = rememberCoroutineScope()
val toggleBottomSheet = { coroutineScope.launch { bottomSheetScaffoldState.toggle() } }
BottomSheetScaffold(
scaffoldState = bottomSheetScaffoldState,
sheetContent = {
Surface(color = colorResource(id = R.color.lightGray5)) {
selectedTestCase.value.content(toggleBottomSheet)
}
},
sheetPeekHeight = 0.dp,
) {
Column {
TestCasesList(
onItemClick = {
selectedTestCase.value = it
toggleBottomSheet()
},
)
ScreenContent(state, closePopupTrigger)
}
}
ComposeDialogManager()
LaunchedEffect(key1 = Unit, block = { domainStore.dispatch(AddCustomTokenAction.OnCreate) })
DisposableEffect(key1 = Unit, effect = { onDispose { domainStore.dispatch(AddCustomTokenAction.OnDestroy) } })
}
@OptIn(ExperimentalMaterialApi::class)
private suspend fun BottomSheetScaffoldState.toggle() {
if (bottomSheetState.isCollapsed) {
bottomSheetState.expand()
} else {
bottomSheetState.collapse()
}
}
@Composable
private fun ScreenContent(state: MutableState<AddCustomTokenState>, closePopupTrigger: ClosePopupTrigger) {
val scaffoldState = rememberScaffoldState()
Scaffold(
scaffoldState = scaffoldState,
backgroundColor = colorResource(id = R.color.backgroundLightGray),
floatingActionButton = {
HangingOverKeyboardView(keyboardState = keyboardAsState()) {
AddButton(state)
}
},
floatingActionButtonPosition = FabPosition.Center,
) { paddings ->
Box(
modifier = Modifier
.padding(paddings)
.fillMaxSize(),
) {
LazyColumn(
contentPadding = PaddingValues(bottom = 90.dp),
) {
item {
Surface(
modifier = Modifier.padding(16.dp),
shape = MaterialTheme.shapes.small,
elevation = 4.dp,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
) {
FormFields(state, closePopupTrigger)
}
}
}
item { Warnings(state.value.warnings.toList()) }
}
}
}
}
@Composable
private fun FormFields(state: MutableState<AddCustomTokenState>, closePopupTrigger: ClosePopupTrigger) {
val context = LocalContext.current
val errorConverter = remember { ModuleMessageConverter(context) }
val stateValue = state.value
stateValue.form.fieldList.forEach { field ->
val data = ScreenFieldData.fromState(field, stateValue, errorConverter)
when (field.id) {
ContractAddress -> TokenContractAddressView(data)
Network -> TokenNetworkView(data, stateValue, closePopupTrigger)
Name -> TokenNameView(data)
Symbol -> TokenSymbolView(data)
Decimals -> TokenDecimalsView(data)
DerivationPath -> TokenDerivationPathView(data, stateValue, closePopupTrigger)
}
}
}
@Composable
fun Warnings(warnings: List<AddCustomTokenError.Warning>) {
if (warnings.isEmpty()) return
val context = LocalContext.current
val warningConverter = remember { ModuleMessageConverter(context) }
Column {
warnings.forEachIndexed { index, item ->
val modifier = when (index) {
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
.padding(horizontal = TangemTheme.dimens.spacing16)
.fillMaxWidth(),
warning = item,
converter = warningConverter,
)
}
}
}
@Composable
private fun AddButton(state: MutableState<AddCustomTokenState>) {
PrimaryButtonIconStart(
modifier = Modifier
.padding(horizontal = TangemTheme.dimens.spacing16)
.fillMaxWidth(),
text = stringResource(id = R.string.common_add),
iconResId = R.drawable.ic_plus_24,
enabled = state.value.screenState.addButton.isEnabled,
onClick = { domainStore.dispatch(AddCustomTokenAction.OnAddCustomTokenClicked) },
)
}
data class ScreenFieldData(
val field: DataField<*>,
val error: AddCustomTokenError?,
val errorConverter: ModuleMessageConverter,
val viewState: ViewStates.TokenField,
) {
companion object {
fun fromState(
field: DataField<*>,
state: AddCustomTokenState,
errorConverter: ModuleMessageConverter,
): ScreenFieldData {
return ScreenFieldData(
field = field,
error = state.getError(field.id),
errorConverter = errorConverter,
viewState = selectField(field.id, state.screenState),
)
}
private fun selectField(id: FieldId, screenState: ScreenState): ViewStates.TokenField {
return when (id) {
ContractAddress -> screenState.contractAddressField
Network -> screenState.network
Name -> screenState.name
Symbol -> screenState.symbol
Decimals -> screenState.decimals
DerivationPath -> screenState.derivationPath
else -> throw UnsupportedOperationException()
}
}
}
}

View file

@ -1,33 +0,0 @@
package com.tangem.tap.features.customtoken.legacy.compose
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.tangem.blockchain.common.Blockchain
import com.tangem.domain.common.form.Field
@Composable
fun BlockchainSpinner(
@StringRes title: Int,
itemList: List<Blockchain>,
selectedItem: Field.Data<Blockchain>,
isEnabled: Boolean = true,
textFieldConverter: (Blockchain) -> String,
dropdownItemView: @Composable ((Blockchain) -> Unit)? = null,
closePopupTrigger: ClosePopupTrigger = ClosePopupTrigger(),
onItemSelect: (Blockchain) -> Unit,
) {
OutlinedSpinner(
modifier = Modifier.fillMaxWidth(),
label = stringResource(id = title),
itemList = itemList,
selectedItem = selectedItem,
textFieldConverter = textFieldConverter,
dropdownItemView = dropdownItemView,
isEnabled = isEnabled,
onItemSelected = onItemSelect,
closePopupTrigger = closePopupTrigger,
)
}

View file

@ -1,157 +0,0 @@
package com.tangem.tap.features.customtoken.legacy.compose
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.AlertDialog
import androidx.compose.material.Button
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.tangem.core.ui.components.SpacerH16
import com.tangem.core.ui.res.TangemTheme
import com.tangem.datasource.api.tangemTech.models.CoinsResponse
import com.tangem.domain.DomainDialog
import com.tangem.domain.redux.domainStore
import com.tangem.domain.redux.global.DomainGlobalAction
import com.tangem.domain.redux.global.DomainGlobalState
import com.tangem.tap.domain.moduleMessage.ModuleMessageConverter
import com.tangem.wallet.R
import org.rekotlin.StoreSubscriber
@Composable
internal fun ComposeDialogManager() {
val dialogSate = remember { mutableStateOf<DomainDialog?>(null) }
val subscriber = remember {
object : StoreSubscriber<DomainGlobalState> {
override fun newState(state: DomainGlobalState) {
dialogSate.value = state.dialog
}
}
}
ShowTheDialog(dialogSate)
LaunchedEffect(
key1 = Unit,
block = {
domainStore.subscribe(subscriber) { state ->
state.skipRepeats { oldState, newState ->
oldState.globalState == newState.globalState
}.select { it.globalState }
}
},
)
DisposableEffect(
key1 = Unit,
effect = {
onDispose { domainStore.unsubscribe(subscriber) }
},
)
}
@Composable
private fun ShowTheDialog(dialogState: MutableState<DomainDialog?>) {
if (dialogState.value == null) return
val context = LocalContext.current
val errorConverter = remember { ModuleMessageConverter(context) }
val onDismissRequest = { domainStore.dispatch(DomainGlobalAction.ShowDialog(null)) }
when (val dialog = dialogState.value) {
is DomainDialog.DialogError -> ErrorDialog(
title = stringResource(id = R.string.common_error),
body = errorConverter.convert(dialog.error).message,
onDismissRequest,
)
is DomainDialog.SelectTokenDialog -> SelectTokenNetworkDialog(dialog, onDismissRequest)
else -> {}
}
}
/**
* Dialog with single item selection
*/
@Composable
fun SimpleDialog(
title: String,
items: List<CoinsResponse.Coin.Network>,
onSelect: (CoinsResponse.Coin.Network) -> Unit,
onDismissRequest: () -> Unit,
itemContent: @Composable (CoinsResponse.Coin.Network) -> Unit,
) {
Dialog(
properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false),
onDismissRequest = { },
) {
Surface(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) {
Column(modifier = Modifier.padding(TangemTheme.dimens.spacing22)) {
DialogTitle(title = title)
LazyColumn {
items(items = items, key = CoinsResponse.Coin.Network::networkId) { item ->
Row(
modifier = Modifier
.fillMaxWidth()
.height(56.dp)
.clickable {
onSelect(item)
onDismissRequest()
},
verticalAlignment = Alignment.CenterVertically,
) { itemContent(item) }
}
}
}
}
}
}
@Composable
private fun DialogTitle(title: String) {
Text(
text = title,
style = LocalTextStyle.provides(
TextStyle(
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
),
).value,
)
SpacerH16()
}
@Composable
fun ErrorDialog(title: String, body: String, onDismissRequest: () -> Unit) {
AlertDialog(
title = { DialogTitle(title) },
text = { Text(body) },
onDismissRequest = onDismissRequest,
confirmButton = {
Button(onClick = onDismissRequest) {
Text(text = stringResource(id = R.string.common_ok))
}
},
)
}

View file

@ -1,149 +0,0 @@
package com.tangem.tap.features.customtoken.legacy.compose
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import com.tangem.core.ui.components.SpacerH8
import com.tangem.domain.common.form.Field
import com.tangem.domain.features.addCustomToken.TokenBlockchainField
import com.tangem.domain.features.addCustomToken.TokenDerivationPathField
import com.tangem.domain.features.addCustomToken.TokenField
import com.tangem.domain.features.addCustomToken.redux.AddCustomTokenAction.OnTokenContractAddressChanged
import com.tangem.domain.features.addCustomToken.redux.AddCustomTokenAction.OnTokenDecimalsChanged
import com.tangem.domain.features.addCustomToken.redux.AddCustomTokenAction.OnTokenDerivationPathChanged
import com.tangem.domain.features.addCustomToken.redux.AddCustomTokenAction.OnTokenNameChanged
import com.tangem.domain.features.addCustomToken.redux.AddCustomTokenAction.OnTokenNetworkChanged
import com.tangem.domain.features.addCustomToken.redux.AddCustomTokenAction.OnTokenSymbolChanged
import com.tangem.domain.features.addCustomToken.redux.AddCustomTokenState
import com.tangem.domain.redux.domainStore
import com.tangem.tap.common.compose.OutlinedTextFieldWidget
import com.tangem.tap.common.compose.TitleSubtitle
import com.tangem.wallet.R
/**
[REDACTED_AUTHOR]
*/
@Composable
fun TokenContractAddressView(screenFieldData: ScreenFieldData) {
if (!screenFieldData.viewState.isVisible) return
val tokenField = screenFieldData.field as TokenField
OutlinedTextFieldWidget(
fieldData = tokenField.data,
labelId = R.string.custom_token_contract_address_input_title,
placeholder = "0x0000000000000000000000000000000000000000",
isEnabled = screenFieldData.viewState.isEnabled,
isLoading = screenFieldData.viewState.isLoading,
error = screenFieldData.error,
errorConverter = screenFieldData.errorConverter,
// trailingIcon = { PasteClearButton(showFirst = tokenField.data.value.isEmpty()) }
) {
domainStore.dispatch(OnTokenContractAddressChanged(Field.Data(it, true)))
}
SpacerH8()
}
@Composable
fun TokenNameView(screenFieldData: ScreenFieldData) {
if (!screenFieldData.viewState.isVisible) return
val tokenField = screenFieldData.field as TokenField
OutlinedTextFieldWidget(
fieldData = tokenField.data,
labelId = R.string.custom_token_name_input_title,
placeholderId = R.string.custom_token_name_input_placeholder,
isEnabled = screenFieldData.viewState.isEnabled,
error = screenFieldData.error,
errorConverter = screenFieldData.errorConverter,
) {
domainStore.dispatch(OnTokenNameChanged(Field.Data(it, true)))
}
SpacerH8()
}
@Composable
fun TokenNetworkView(
screenFieldData: ScreenFieldData,
state: AddCustomTokenState,
closePopupTrigger: ClosePopupTrigger,
) {
if (!screenFieldData.viewState.isVisible) return
val notSelected = stringResource(id = R.string.custom_token_network_input_not_selected)
val networkField = screenFieldData.field as TokenBlockchainField
BlockchainSpinner(
title = R.string.custom_token_network_input_title,
itemList = networkField.itemList,
selectedItem = networkField.data,
isEnabled = screenFieldData.viewState.isEnabled,
textFieldConverter = { state.blockchainToName(it) ?: notSelected },
closePopupTrigger = closePopupTrigger,
) { domainStore.dispatch(OnTokenNetworkChanged(Field.Data(it, true))) }
SpacerH8()
}
@Composable
fun TokenSymbolView(screenFieldData: ScreenFieldData) {
if (!screenFieldData.viewState.isVisible) return
val tokenField = screenFieldData.field as TokenField
OutlinedTextFieldWidget(
fieldData = tokenField.data,
labelId = R.string.custom_token_token_symbol_input_title,
placeholderId = R.string.custom_token_token_symbol_input_placeholder,
isEnabled = screenFieldData.viewState.isEnabled,
error = screenFieldData.error,
errorConverter = screenFieldData.errorConverter,
) { domainStore.dispatch(OnTokenSymbolChanged(Field.Data(it, true))) }
SpacerH8()
}
@Composable
fun TokenDecimalsView(screenFieldData: ScreenFieldData) {
if (!screenFieldData.viewState.isVisible) return
val tokenField = screenFieldData.field as TokenField
OutlinedTextFieldWidget(
fieldData = tokenField.data,
labelId = R.string.custom_token_decimals_input_title,
placeholder = "8",
isEnabled = screenFieldData.viewState.isEnabled,
error = screenFieldData.error,
errorConverter = screenFieldData.errorConverter,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
) { domainStore.dispatch(OnTokenDecimalsChanged(Field.Data(it, true))) }
SpacerH8()
}
@Composable
fun TokenDerivationPathView(
screenFieldData: ScreenFieldData,
state: AddCustomTokenState,
closePopupTrigger: ClosePopupTrigger,
) {
if (!screenFieldData.viewState.isVisible) return
val notSelected = stringResource(id = R.string.custom_token_derivation_path_default)
val networkField = screenFieldData.field as TokenDerivationPathField
BlockchainSpinner(
title = R.string.custom_token_derivation_path_input_title,
itemList = networkField.itemList,
selectedItem = networkField.data,
isEnabled = screenFieldData.viewState.isEnabled,
textFieldConverter = { state.blockchainToName(it) ?: notSelected },
dropdownItemView = { blockchain ->
val derivationPathName = state.blockchainToName(blockchain, true) ?: notSelected
val blockchainName = state.blockchainToName(blockchain) ?: notSelected
TitleSubtitle(derivationPathName, blockchainName)
},
closePopupTrigger = closePopupTrigger,
) { domainStore.dispatch(OnTokenDerivationPathChanged(Field.Data(it, true))) }
SpacerH8()
}

View file

@ -1,32 +0,0 @@
package com.tangem.tap.features.customtoken.legacy.compose
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.Keyboard
/**
[REDACTED_AUTHOR]
*/
@Composable
fun HangingOverKeyboardView(
modifier: Modifier = Modifier,
keyboardState: State<Keyboard>,
spaceBetweenKeyboard: Dp = 0.dp,
content: @Composable (BoxScope.() -> Unit),
) {
val padding = remember(keyboardState) {
when (val state = keyboardState.value) {
is Keyboard.Closed -> 0.dp
is Keyboard.Opened -> state.height + spaceBetweenKeyboard
}
}
Box(modifier.padding(bottom = padding)) { content() }
}

View file

@ -1,103 +0,0 @@
package com.tangem.tap.features.customtoken.legacy.compose
import android.os.Handler
import android.os.Looper
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.core.os.postDelayed
import com.tangem.blockchain.common.Blockchain
import com.tangem.common.extensions.VoidCallback
import com.tangem.domain.common.form.Field
import com.tangem.tap.common.compose.TangemTextFieldsDefault
import com.tangem.tap.common.extensions.ValueCallback
/**
[REDACTED_AUTHOR]
*/
@Suppress("MagicNumber")
@OptIn(ExperimentalMaterialApi::class)
@Composable
internal fun <T> OutlinedSpinner(
label: String,
itemList: List<T>,
selectedItem: Field.Data<T>,
onItemSelected: ValueCallback<T>,
modifier: Modifier = Modifier,
textFieldConverter: (T) -> String = { it.toString() },
dropdownItemView: @Composable ((T) -> Unit)? = null,
isEnabled: Boolean = true,
onClose: VoidCallback = {},
closePopupTrigger: ClosePopupTrigger = ClosePopupTrigger(),
) {
val rIsExpanded = remember { mutableStateOf(false) }
val stateSelectedItem = remember { mutableStateOf(selectedItem.value) }
if (!selectedItem.isUserInput) {
stateSelectedItem.value = selectedItem.value
}
val onDropDownItemSelectedInternal: (T) -> Unit = {
stateSelectedItem.value = it
rIsExpanded.value = false
onItemSelected(it)
}
val onDismissRequest = {
rIsExpanded.value = false
onClose()
}
closePopupTrigger.close = {
onDismissRequest()
Handler(Looper.getMainLooper()).postDelayed(100) {
closePopupTrigger.onCloseComplete()
}
}
ExposedDropdownMenuBox(
expanded = rIsExpanded.value,
onExpandedChange = { rIsExpanded.value = !rIsExpanded.value },
) {
OutlinedTextField(
modifier = modifier,
readOnly = true,
enabled = isEnabled,
value = textFieldConverter(stateSelectedItem.value),
onValueChange = {},
label = { Text(label) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = rIsExpanded.value) },
colors = TangemTextFieldsDefault.defaultTextFieldColors,
)
if (isEnabled) {
ExposedDropdownMenu(expanded = rIsExpanded.value, onDismissRequest = onDismissRequest) {
itemList.forEach { item ->
key(item) {
DropdownMenuItem(onClick = { onDropDownItemSelectedInternal(item) }) {
if (dropdownItemView == null) Text(textFieldConverter(item)) else dropdownItemView(item)
}
}
}
}
}
}
}
class ClosePopupTrigger {
var close: () -> Unit = {}
var onCloseComplete: () -> Unit = {}
}
@Preview
@Composable
private fun TestSpinnerPreview() {
OutlinedSpinner(
label = "Blockchain name",
itemList = listOf(Blockchain.values()),
selectedItem = Field.Data(Blockchain.Avalanche, false),
onItemSelected = {},
)
}

View file

@ -1,20 +0,0 @@
package com.tangem.tap.features.customtoken.legacy.compose
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.tangem.domain.DomainDialog
import com.tangem.tap.common.compose.TitleSubtitle
import com.tangem.wallet.R
/**
[REDACTED_AUTHOR]
*/
@Composable
fun SelectTokenNetworkDialog(dialog: DomainDialog.SelectTokenDialog, onDismissRequest: () -> Unit) {
SimpleDialog(
title = stringResource(id = R.string.custom_token_network_input_title),
items = dialog.items,
onSelect = dialog.onSelect,
onDismissRequest = onDismissRequest,
) { network -> TitleSubtitle(dialog.networkIdConverter(network.networkId), network.contractAddress ?: "") }
}

View file

@ -1,120 +0,0 @@
package com.tangem.tap.features.customtoken.legacy.compose.test
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.Button
import androidx.compose.material.Divider
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.tangem.blockchain.common.Blockchain
import com.tangem.common.extensions.VoidCallback
import com.tangem.domain.common.form.Field
import com.tangem.domain.features.addCustomToken.redux.AddCustomTokenAction
import com.tangem.domain.redux.domainStore
/**
[REDACTED_AUTHOR]
*/
@Composable
fun ContractAddressTests(onItemClick: VoidCallback) {
val casesInfo = listOf(
"USDC on ETH" to "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
"BUSD on ETH" to "0x4fabb145d64652a948d72533023f6e7a623c7c53",
"ETH on AVALANCHE" to "0xf20d962a6c8f70c731bd838a3a388d7d48fa6e15",
"USDC on ETH (invalid - cut address)" to "0xa0b86991c6218b36c1d1",
"Custom EVM" to "0x1111111111111111112111111111111111111113",
"Supported by several networks" to "0xa1faa113cbe53436df28ff0aee54275c13b40975",
"Invalid" to "!@#_ _-%%^&&*((){P P2iOWsdfFQLA",
)
CasesListContent(casesInfo, onItemClick)
}
@Composable
fun SolanaAddressTests(onItemClick: VoidCallback) {
val casesInfo = listOf(
"USDT (full)" to "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB",
"USDT (valid - 2/3 of address)" to "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8Ben",
"USDT (invalid - 1/3 of address)" to "Es9vMFrzaCERmJ",
"ETH (full)" to "2FPyTwcZLUg1MDrwsyoP4D6s1tM7hAkHYRjkNb5w6Pxk",
)
CasesListContent(casesInfo, onItemClick)
}
@Composable
private fun CasesListContent(casesList: List<Pair<String, String>>, onItemClick: VoidCallback) {
LazyColumn(
content = {
item {
Row {
ResetContractAddressButton(onItemClick)
Text("", modifier = Modifier.weight(1f))
ResetAllFieldsButton(onItemClick)
}
Divider()
}
items(casesList.size) {
val (info, address) = casesList[it]
ContractAddressButton(info, address, onItemClick)
}
},
)
}
@Composable
fun ResetAllFieldsButton(onItemClick: VoidCallback) {
ActionButton(
name = "Reset",
onClick = {
onItemClick()
domainStore.dispatch(AddCustomTokenAction.OnTokenContractAddressChanged(Field.Data("", false)))
domainStore.dispatch(AddCustomTokenAction.OnTokenNetworkChanged(Field.Data(Blockchain.Unknown, false)))
domainStore.dispatch(AddCustomTokenAction.OnTokenNameChanged(Field.Data("", false)))
domainStore.dispatch(AddCustomTokenAction.OnTokenSymbolChanged(Field.Data("", false)))
domainStore.dispatch(AddCustomTokenAction.OnTokenDecimalsChanged(Field.Data("", false)))
domainStore.dispatch(
AddCustomTokenAction.OnTokenDerivationPathChanged(
Field.Data(
Blockchain.Unknown,
false,
),
),
)
},
)
}
@Composable
fun ResetContractAddressButton(onItemClick: VoidCallback) {
ActionButton(
name = "Set empty address",
onClick = {
onItemClick()
domainStore.dispatch(AddCustomTokenAction.OnTokenContractAddressChanged(Field.Data("", false)))
},
)
}
@Composable
private fun ContractAddressButton(name: String, address: String, onItemClick: VoidCallback) {
ActionButton(
modifier = Modifier.fillMaxWidth(),
name = name,
onClick = {
onItemClick()
domainStore.dispatch(AddCustomTokenAction.OnTokenContractAddressChanged(Field.Data(address, false)))
},
)
}
@Composable
fun ActionButton(name: String, onClick: () -> Unit, modifier: Modifier = Modifier) {
Button(
modifier = modifier.padding(horizontal = 8.dp),
onClick = onClick,
) { Text(name, fontSize = 12.sp) }
}

View file

@ -1,53 +0,0 @@
package com.tangem.tap.features.customtoken.legacy.compose.test
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Button
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.unit.dp
import com.tangem.common.extensions.VoidCallback
import com.tangem.wallet.BuildConfig
import com.tangem.wallet.R
/**
[REDACTED_AUTHOR]
*/
@Composable
fun TestCasesList(onItemClick: (TestCase) -> Unit) {
if (!BuildConfig.TEST_ACTION_ENABLED) return
Surface(color = colorResource(id = R.color.lightGray5)) {
Column(Modifier.padding(horizontal = 16.dp)) {
listOf(TestCase.ContractAddress, TestCase.SolanaTokens)
.map { case -> TestCaseListItem(testCase = case, onItemClick = { onItemClick(case) }) }
}
}
}
@Composable
fun TestCaseListItem(testCase: TestCase, onItemClick: () -> Unit) {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
modifier = Modifier.weight(1f),
text = testCase.description,
)
Button(
onClick = onItemClick,
) { Text("Start") }
}
}
enum class TestCase(val description: String, val content: @Composable (VoidCallback) -> Unit) {
ContractAddress("Test contract address field", { ContractAddressTests(it) }),
Auto("Test contract address field", { ContractAddressTests(it) }),
SolanaTokens("Test Solana contract addresses", { SolanaAddressTests(it) }),
;
}

View file

@ -1,6 +1,5 @@
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
@ -18,7 +17,5 @@ internal object TokensListRouterModule {
@Provides
@ViewModelScoped
fun provideTokensListRouter(customTokenFeatureToggles: CustomTokenFeatureToggles): TokensListRouter {
return DefaultTokensListRouter(customTokenFeatureToggles)
}
fun provideTokensListRouter(): TokensListRouter = DefaultTokensListRouter()
}

View file

@ -6,8 +6,6 @@ import com.tangem.core.navigation.NavigationAction
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.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
import com.tangem.wallet.R
@ -18,20 +16,14 @@ import com.tangem.wallet.R
*
[REDACTED_AUTHOR]
*/
internal class DefaultTokensListRouter(
private val customTokenFeatureToggles: CustomTokenFeatureToggles,
) : TokensListRouter {
internal class DefaultTokensListRouter : TokensListRouter {
override fun popBackStack() {
store.dispatch(NavigationAction.PopBackTo())
}
override fun openAddCustomTokenScreen() {
if (customTokenFeatureToggles.isRedesignedScreenEnabled) {
store.dispatch(NavigationAction.NavigateTo(AppScreen.AddCustomToken))
} else {
store.dispatch(TokensAction.PrepareAndNavigateToAddCustomToken)
}
store.dispatch(NavigationAction.NavigateTo(AppScreen.AddCustomToken))
}
override fun showAddressCopiedNotification() {

View file

@ -17,7 +17,4 @@ sealed interface TokensAction : Action {
// TODO: [REDACTED_TASK_KEY] Remove this action
data class SaveChanges(val tokens: List<TokenWithBlockchain>, val blockchains: List<Blockchain>) : TokensAction
// TODO: Remove this action in 4.7 release
object PrepareAndNavigateToAddCustomToken : TokensAction
}

View file

@ -9,28 +9,19 @@ import com.tangem.common.extensions.ByteArrayKey
import com.tangem.common.extensions.guard
import com.tangem.common.extensions.toMapKey
import com.tangem.common.flatMap
import com.tangem.core.analytics.Analytics
import com.tangem.core.navigation.AppScreen
import com.tangem.core.navigation.NavigationAction
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.domain.DomainWrapped
import com.tangem.domain.common.configs.CardConfig
import com.tangem.domain.common.util.derivationStyleProvider
import com.tangem.domain.common.util.hasDerivation
import com.tangem.domain.common.util.supportsHdWallet
import com.tangem.domain.features.addCustomToken.CustomCurrency
import com.tangem.domain.features.addCustomToken.redux.AddCustomTokenAction
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.redux.domainStore
import com.tangem.operations.derivation.ExtendedPublicKeysMap
import com.tangem.tap.*
import com.tangem.tap.common.analytics.events.ManageTokens
import com.tangem.tap.common.extensions.dispatchDebugErrorNotification
import com.tangem.tap.common.extensions.dispatchOnMain
import com.tangem.tap.common.redux.AppState
import com.tangem.tap.common.redux.global.GlobalAction
import com.tangem.tap.domain.TapError
import com.tangem.tap.domain.model.WalletDataModel
import com.tangem.tap.features.wallet.models.Currency
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@ -44,7 +35,6 @@ object TokensMiddleware {
{ action ->
when (action) {
is TokensAction.SaveChanges -> handleSaveChanges(action)
is TokensAction.PrepareAndNavigateToAddCustomToken -> handleAddingCustomToken()
}
next(action)
}
@ -231,56 +221,4 @@ object TokensMiddleware {
}
walletCurrenciesManager.removeCurrencies(selectedUserWallet, currencies)
}
private fun isNeedToDerive(scanResponse: ScanResponse, currency: Currency): Boolean {
return currency.derivationPath?.let {
!scanResponse.hasDerivation(currency.blockchain, it)
} ?: false
}
private fun handleAddingCustomToken() = scope.launch {
val onAddCustomToken = fun(customCurrency: CustomCurrency) {
val scanResponse = store.state.globalState.scanResponse ?: return
fun submitAndPopBack(scanResponse: ScanResponse, currencyList: List<Currency>) {
submitAdd(scanResponse, currencyList)
// pop from the AddCustomTokenScreen
store.dispatchOnMain(NavigationAction.PopBackTo())
store.dispatchOnMain(NavigationAction.PopBackTo())
}
Analytics.send(ManageTokens.CustomToken.TokenWasAdded(customCurrency))
val currency = Currency.fromCustomCurrency(customCurrency)
val isNeedToDerive = isNeedToDerive(scanResponse, currency)
val currencyList = listOf(currency)
if (isNeedToDerive) {
deriveMissingBlockchains(scanResponse, currencyList) {
submitAndPopBack(it, currencyList)
}
} else {
submitAndPopBack(scanResponse, currencyList)
}
}
val addedCurrencies = store.state.walletState.walletsStores
.map { walletStore -> walletStore.walletsData.map(WalletDataModel::currency) }
.flatten()
.map { currency ->
when (currency) {
is Currency.Blockchain -> DomainWrapped.Currency.Blockchain(
currency.blockchain,
currency.derivationPath,
)
is Currency.Token -> DomainWrapped.Currency.Token(
currency.token,
currency.blockchain,
currency.derivationPath,
)
}
}
domainStore.dispatch(AddCustomTokenAction.Init.SetAddedCurrencies(addedCurrencies))
domainStore.dispatch(AddCustomTokenAction.Init.SetOnAddTokenCallback(onAddCustomToken))
store.dispatch(NavigationAction.NavigateTo(AppScreen.AddCustomToken))
}
}

View file

@ -1,33 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/coordinator_wallet"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/backgroundLightGray"
android:orientation="vertical">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/app_bar"
style="@style/Widget.MaterialComponents.Toolbar.Surface"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/backgroundLightGray"
android:fitsSystemWindows="true"
app:liftOnScroll="true">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
app:navigationIcon="@drawable/ic_baseline_arrow_back_24" />
</com.google.android.material.appbar.AppBarLayout>
<androidx.compose.ui.platform.ComposeView
android:id="@+id/view_compose"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -1,8 +0,0 @@
package com.tangem.common
/**
[REDACTED_AUTHOR]
*/
interface Filter<T> {
fun filter(value: T): Boolean
}

View file

@ -1,8 +0,0 @@
package com.tangem.common
/**
[REDACTED_AUTHOR]
*/
interface Validator<Data, Error> {
fun validate(data: Data? = null): Error?
}

View file

@ -17,11 +17,6 @@ abstract class ModuleError : Throwable(), ModuleMessage {
abstract val data: Any?
}
/**
* An exception marked as FbConsumeException should be submitted to Firebase.Crashlytics as a non-fatal issue.
*/
interface FbConsumeException
interface ModuleMessageConverter<ModuleMessage, R> {
fun convert(message: ModuleMessage): R
}

View file

@ -3,10 +3,6 @@
"name": "OPTIMISM_SWAP_FEATURE_ENABLED",
"version": "4.3.1"
},
{
"name": "REDESIGNED_CUSTOM_TOKEN_SCREEN_ENABLED",
"version": "4.7.0"
},
{
"name": "NEW_CARD_SCANNING_ENABLED",
"version": "4.11.0"

View file

@ -1,19 +0,0 @@
package com.tangem.domain
import com.tangem.common.extensions.VoidCallback
import com.tangem.datasource.api.tangemTech.models.CoinsResponse
/**
[REDACTED_AUTHOR]
*/
sealed interface DomainDialog {
data class DialogError(val error: DomainModuleError) : DomainDialog
data class SelectTokenDialog(
val items: List<CoinsResponse.Coin.Network>,
val networkIdConverter: (String) -> String,
val onSelect: (CoinsResponse.Coin.Network) -> Unit,
val onClose: VoidCallback = {},
) : DomainDialog
}

View file

@ -1,26 +0,0 @@
package com.tangem.domain
import com.tangem.domain.features.addCustomToken.redux.AddCustomTokenAction
import com.tangem.domain.features.addCustomToken.redux.AddCustomTokenState
import com.tangem.domain.redux.state.ActionStateLoggerImpl
/**
[REDACTED_AUTHOR]
*/
object DomainLayer {
internal val actionStateLogger = ActionStateLoggerImpl()
var onInitComplete: ((DomainModuleError?) -> Unit)? = null
fun init() {
initActionStateLogger()
onInitComplete?.invoke(null)
}
private fun initActionStateLogger() {
val factory = actionStateLogger.actionStateConvertersFactory
factory.addConverter(AddCustomTokenAction::class.java, AddCustomTokenState.Converter())
}
}

View file

@ -1,6 +1,5 @@
package com.tangem.domain
import com.tangem.common.module.FbConsumeException
import com.tangem.common.module.ModuleError
import com.tangem.common.module.ModuleErrorCode
import com.tangem.common.module.ModuleMessage
@ -36,32 +35,14 @@ sealed class AddCustomTokenError(
) {
object FieldIsEmpty : AddCustomTokenError()
object FieldIsNotEmpty : AddCustomTokenError()
object InvalidContractAddress : AddCustomTokenError()
object NetworkIsNotSelected : AddCustomTokenError()
object InvalidDecimalsCount : AddCustomTokenError()
object InvalidDerivationPath : AddCustomTokenError()
sealed class Network : AddCustomTokenError() {
object CheckAddressRequestError : Network()
}
sealed class Warning : AddCustomTokenError() {
object PotentialScamToken : Warning()
object TokenAlreadyAdded : Warning()
object UnsupportedSolanaToken : Warning()
}
data class SelectTokeNetworkError(val networkId: String) :
AddCustomTokenError(
message = "Unknown network [$networkId] should not be included in the network selection dialog.",
),
FbConsumeException
data class UnAppropriateInitialization(
val of: String,
val info: String? = null,
) : AddCustomTokenError(
message = "The [$of], must be properly initialized. Info [$info]",
)
}

View file

@ -1,33 +0,0 @@
package com.tangem.domain
/**
[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
sealed interface Currency {
val blockchain: com.tangem.blockchain.common.Blockchain
val currencySymbol: String
val derivationPath: String?
data class Token(
val token: com.tangem.blockchain.common.Token,
override val blockchain: com.tangem.blockchain.common.Blockchain,
override val derivationPath: String?,
) : Currency {
override val currencySymbol = token.symbol
}
data class Blockchain(
override val blockchain: com.tangem.blockchain.common.Blockchain,
override val derivationPath: String?,
) : Currency {
override val currencySymbol: String = blockchain.currency
}
}
}

View file

@ -1,7 +1,6 @@
package com.tangem.domain.common
import com.tangem.blockchain.common.Blockchain
import com.tangem.common.card.Card
import com.tangem.common.card.FirmwareVersion
import com.tangem.domain.models.scan.CardDTO
import java.util.*
@ -69,6 +68,4 @@ object TapWorkarounds {
fun isStart2CoinIssuer(cardIssuer: String?): Boolean {
return cardIssuer?.lowercase(Locale.US) == START_2_COIN_ISSUER
}
fun Card.getTangemNoteBlockchain(): Blockchain? = tangemNoteBatches[batchId] ?: null
}

View file

@ -1,40 +0,0 @@
package com.tangem.domain.common.form
import com.tangem.common.json.MoshiJsonConverter
/**
[REDACTED_AUTHOR]
*/
interface DataConverterVisitor<Data, Result> {
fun visit(data: Data?)
fun getConvertedData(): Result
}
interface FieldDataConverter<Result> : DataConverterVisitor<FieldData, Result>
abstract class BaseFieldDataConverter<Result> : FieldDataConverter<Result> {
private val collectIds: List<FieldId>
get() = getIdToCollect()
protected val collectedData: MutableMap<FieldId, Any?> = mutableMapOf()
override fun visit(data: Pair<FieldId, Field.Data<*>>?) {
val id = data?.first ?: return
if (collectIds.contains(id)) {
collectedData[id] = data.second.value
}
}
abstract fun getIdToCollect(): List<FieldId>
}
class FieldToJsonConverter(
private val fieldsToConvert: List<FieldId> = listOf(),
private val jsonConverter: MoshiJsonConverter,
) : BaseFieldDataConverter<String>() {
override fun getConvertedData(): String = jsonConverter.toJson(collectedData, " ")
override fun getIdToCollect(): List<FieldId> = fieldsToConvert
}

View file

@ -1,99 +0,0 @@
package com.tangem.domain.common.form
import com.tangem.blockchain.blockchains.ethereum.EthereumAddressService
import com.tangem.blockchain.blockchains.solana.SolanaAddressService
import com.tangem.blockchain.blockchains.tron.TronAddressService
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.address.AddressService
import com.tangem.common.Validator
import com.tangem.common.card.EllipticCurve
import com.tangem.domain.AddCustomTokenError
import timber.log.Timber
/**
[REDACTED_AUTHOR]
*/
interface CustomTokenValidator<T> : Validator<T, AddCustomTokenError>
class StringIsEmptyValidator : CustomTokenValidator<String> {
override fun validate(data: String?): AddCustomTokenError? {
return if (data.isNullOrEmpty()) null else AddCustomTokenError.FieldIsNotEmpty
}
}
class StringIsNotEmptyValidator : CustomTokenValidator<String> {
override fun validate(data: String?): AddCustomTokenError? {
return if (data.isNullOrEmpty()) AddCustomTokenError.FieldIsEmpty else null
}
}
class TokenContractAddressValidator : CustomTokenValidator<String> {
private var blockchain: Blockchain = Blockchain.Unknown
private val successAddressValidator = object : AddressService() {
override fun makeAddress(walletPublicKey: ByteArray, curve: EllipticCurve?): String {
throw UnsupportedOperationException()
}
override fun validate(address: String): Boolean = true
}
fun nextValidationFor(blockchain: Blockchain) {
this.blockchain = blockchain
}
override fun validate(data: String?): AddCustomTokenError? {
return when {
data.isNullOrEmpty() -> AddCustomTokenError.FieldIsEmpty
getAddressService().validate(data) -> null
else -> AddCustomTokenError.InvalidContractAddress
}
}
private fun getAddressService(): AddressService {
return when (blockchain) {
Blockchain.Unknown -> successAddressValidator
Blockchain.Binance, Blockchain.BinanceTestnet -> successAddressValidator
Blockchain.Solana, Blockchain.SolanaTestnet -> SolanaAddressService()
Blockchain.Tron, Blockchain.TronTestnet -> TronAddressService()
else -> {
if (blockchain.isEvm()) {
EthereumAddressService()
} else {
Timber.e("Throw for blockchain: ${blockchain.fullName}")
throw UnsupportedOperationException()
}
}
}
}
}
class TokenNetworkValidator : CustomTokenValidator<Blockchain> {
override fun validate(data: Blockchain?): AddCustomTokenError? {
return when (data) {
null, Blockchain.Unknown -> AddCustomTokenError.NetworkIsNotSelected
else -> null
}
}
}
class TokenNameValidator : CustomTokenValidator<String> {
override fun validate(data: String?): AddCustomTokenError? = StringIsNotEmptyValidator().validate(data)
}
class TokenSymbolValidator : CustomTokenValidator<String> {
override fun validate(data: String?): AddCustomTokenError? = StringIsNotEmptyValidator().validate(data)
}
class TokenDecimalsValidator : CustomTokenValidator<String> {
override fun validate(data: String?): AddCustomTokenError? {
val decimal = data?.toIntOrNull() ?: return AddCustomTokenError.FieldIsEmpty
return if (decimal > INVALID_DECIMALS_COUNT) AddCustomTokenError.InvalidDecimalsCount else null
}
private companion object {
const val INVALID_DECIMALS_COUNT = 30
}
}

View file

@ -1,62 +0,0 @@
package com.tangem.domain.common.form
/**
[REDACTED_AUTHOR]
*/
class Form(
fieldList: List<DataField<*>>,
) {
private val _fieldList: MutableList<DataField<*>> = fieldList.toMutableList()
val fieldList: List<DataField<*>>
get() = _fieldList.toList()
fun getField(id: FieldId): DataField<*>? = fieldList.firstOrNull { it.id == id }
fun getData(id: FieldId): Pair<FieldId, *>? = getField(id)?.getData()
fun setField(field: DataField<*>) {
val oldField = getField(field.id) ?: return
val oldIndexOfField = _fieldList.indexOf(oldField)
if (oldIndexOfField == -1) return
_fieldList.removeAt(oldIndexOfField)
_fieldList.add(oldIndexOfField, field)
}
// convert this form data whatever you want
fun visitDataConverter(converter: FieldDataConverter<*>) {
fieldList.forEach { it.visitDataConverter(converter) }
}
}
interface FieldId
interface Field<T> {
val id: FieldId
var data: Data<T>
data class Data<Data>(
val value: Data,
val isUserInput: Boolean,
)
}
typealias FieldData = Pair<FieldId, Field.Data<*>>
interface DataField<T> : Field<T> {
fun getData(): Pair<FieldId, Field.Data<T>>
fun visitDataConverter(dataConverter: FieldDataConverter<*>)
}
abstract class BaseDataField<T>(
override val id: FieldId,
override var data: Field.Data<T>,
) : DataField<T> {
override fun getData(): Pair<FieldId, Field.Data<T>> = id to data
override fun visitDataConverter(dataConverter: FieldDataConverter<*>) {
dataConverter.visit(getData())
}
}

View file

@ -1,52 +0,0 @@
package com.tangem.domain.features.addCustomToken
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.CoinsResponse
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
/**
[REDACTED_AUTHOR]
*/
class AddCustomTokenService(
private val tangemTechApi: TangemTechApi,
private val dispatchers: CoroutineDispatcherProvider,
private val supportedTokenNetworkIds: List<String>,
) {
suspend fun findToken(contractAddress: String, networkId: String?): List<CoinsResponse.Coin> {
return withContext(dispatchers.io) {
runCatching {
tangemTechApi.getCoins(
contractAddress = contractAddress,
networkIds = selectNetworksForSearch(networkId),
)
}
.fold(
onSuccess = { response ->
var coinsList = mutableListOf<CoinsResponse.Coin>()
response.coins.forEach { coin ->
val networksWithTheSameAddress = coin.networks
.filter { it.contractAddress != null || it.decimalCount != null }
.filter { it.contractAddress?.equals(contractAddress, ignoreCase = true) == true }
.filter { supportedTokenNetworkIds.contains(it.networkId) }
if (networksWithTheSameAddress.isNotEmpty()) {
val newToken = coin.copy(networks = networksWithTheSameAddress)
coinsList.add(newToken)
}
}
if (coinsList.size > 1) {
// https://tangem.slack.com/archives/GMXC6PP71/p1649672562078679
coinsList = mutableListOf(coinsList[0])
}
coinsList
},
onFailure = { emptyList() },
)
}
}
private fun selectNetworksForSearch(networkId: String?): String {
return networkId ?: supportedTokenNetworkIds.joinToString(",")
}
}

View file

@ -2,11 +2,7 @@ package com.tangem.domain.features.addCustomToken
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.Token
import com.tangem.blockchain.common.derivation.DerivationStyle
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.domain.common.form.BaseFieldDataConverter
import com.tangem.domain.common.form.FieldId
import com.tangem.domain.features.addCustomToken.redux.AddCustomTokenState
/**
[REDACTED_AUTHOR]
@ -16,65 +12,16 @@ sealed class CustomCurrency(
val derivationPath: DerivationPath?,
) {
@Deprecated("It will be removed in next releases")
class CustomBlockchain(
network: Blockchain,
derivationPath: DerivationPath?,
) : CustomCurrency(network, derivationPath) {
class Converter(
private val derivationStyle: DerivationStyle?,
) : BaseFieldDataConverter<CustomBlockchain>() {
override fun getConvertedData(): CustomBlockchain {
val mainNetwork = collectedData[CustomTokenFieldId.Network] as Blockchain
val derivationPathNetwork = collectedData[CustomTokenFieldId.DerivationPath] as Blockchain
val derivationPath = AddCustomTokenState.getDerivationPath(
mainNetwork,
derivationPathNetwork,
derivationStyle,
)
return CustomBlockchain(mainNetwork, derivationPath)
}
override fun getIdToCollect(): List<FieldId> =
listOf(CustomTokenFieldId.Network, CustomTokenFieldId.DerivationPath)
}
}
) : CustomCurrency(network, derivationPath)
@Deprecated("It will be removed in next releases")
class CustomToken(
val token: Token,
network: Blockchain,
derivationPath: DerivationPath?,
) : CustomCurrency(network, derivationPath) {
class Converter(
private val tokenId: String?,
private val derivationStyle: DerivationStyle?,
) : BaseFieldDataConverter<CustomToken>() {
override fun getConvertedData(): CustomToken {
val mainNetwork = collectedData[CustomTokenFieldId.Network] as Blockchain
val derivationPathNetwork = collectedData[CustomTokenFieldId.DerivationPath] as Blockchain
val derivationPath = AddCustomTokenState.getDerivationPath(
mainNetwork,
derivationPathNetwork,
derivationStyle,
)
val token = Token(
name = collectedData[CustomTokenFieldId.Name] as String,
symbol = collectedData[CustomTokenFieldId.Symbol] as String,
contractAddress = collectedData[CustomTokenFieldId.ContractAddress] as String,
decimals = (collectedData[CustomTokenFieldId.Decimals] as String).toInt(),
id = tokenId,
)
return CustomToken(
token,
collectedData[CustomTokenFieldId.Network] as Blockchain,
derivationPath,
)
}
override fun getIdToCollect(): List<FieldId> = CustomTokenFieldId.values().toList()
}
}
) : CustomCurrency(network, derivationPath)
}

View file

@ -1,32 +0,0 @@
package com.tangem.domain.features.addCustomToken
import com.tangem.blockchain.common.Blockchain
import com.tangem.domain.common.form.BaseDataField
import com.tangem.domain.common.form.Field
import com.tangem.domain.common.form.FieldId
/**
[REDACTED_AUTHOR]
*/
enum class CustomTokenFieldId : FieldId {
ContractAddress,
Network,
Name,
Symbol,
Decimals,
DerivationPath,
}
data class TokenField(
override val id: FieldId,
) : BaseDataField<String>(id, Field.Data("", false))
data class TokenBlockchainField(
override val id: FieldId,
val itemList: List<Blockchain>,
) : BaseDataField<Blockchain>(id, Field.Data(Blockchain.Unknown, false))
data class TokenDerivationPathField(
override val id: FieldId,
val itemList: List<Blockchain>,
) : BaseDataField<Blockchain>(id, Field.Data(Blockchain.Unknown, false))

View file

@ -1,63 +0,0 @@
package com.tangem.domain.features.addCustomToken.redux
import com.tangem.blockchain.common.Blockchain
import com.tangem.datasource.api.tangemTech.models.CoinsResponse
import com.tangem.domain.AddCustomTokenError
import com.tangem.domain.DomainWrapped
import com.tangem.domain.common.form.Field
import com.tangem.domain.common.form.FieldId
import com.tangem.domain.features.addCustomToken.CustomCurrency
import com.tangem.domain.features.addCustomToken.CustomTokenFieldId
import org.rekotlin.Action
/**
[REDACTED_AUTHOR]
*/
sealed class AddCustomTokenAction : Action {
sealed class Init : AddCustomTokenAction() {
data class SetAddedCurrencies(val addedCurrencies: List<DomainWrapped.Currency>) : AddCustomTokenAction()
data class SetOnAddTokenCallback(val callback: (CustomCurrency) -> Unit) : AddCustomTokenAction()
}
object OnCreate : AddCustomTokenAction()
object OnDestroy : AddCustomTokenAction()
// from user, ui
data class OnTokenContractAddressChanged(val contractAddress: Field.Data<String>) : AddCustomTokenAction()
data class OnTokenNetworkChanged(val blockchainNetwork: Field.Data<Blockchain>) : AddCustomTokenAction()
data class OnTokenNameChanged(val tokenName: Field.Data<String>) : AddCustomTokenAction()
data class OnTokenSymbolChanged(val tokenSymbol: Field.Data<String>) : AddCustomTokenAction()
data class OnTokenDerivationPathChanged(
val blockchainDerivationPath: Field.Data<Blockchain>,
) : AddCustomTokenAction()
data class OnTokenDecimalsChanged(val tokenDecimals: Field.Data<String>) : AddCustomTokenAction()
object OnAddCustomTokenClicked : AddCustomTokenAction()
data class SetFoundTokenInfo(val foundToken: CoinsResponse.Coin?) : AddCustomTokenAction()
// form fields
data class UpdateForm(val state: AddCustomTokenState) : AddCustomTokenAction()
sealed class FieldError : AddCustomTokenAction() {
data class Add(val id: CustomTokenFieldId, val error: AddCustomTokenError) : FieldError()
data class Remove(val id: CustomTokenFieldId) : FieldError()
}
// warnings
sealed class Warning : AddCustomTokenAction() {
data class Add(val warnings: Set<AddCustomTokenError.Warning>) : Warning()
data class Remove(val warnings: Set<AddCustomTokenError.Warning>) : Warning()
data class Replace(
val remove: Set<AddCustomTokenError.Warning>,
val add: Set<AddCustomTokenError.Warning>,
) : Warning()
}
// To change the screenState
sealed class Screen : AddCustomTokenAction() {
data class UpdateTokenFields(val pairs: List<Pair<FieldId, ViewStates.TokenField>>) : Screen()
data class UpdateAddButton(val addButton: ViewStates.AddButton) : Screen()
}
}

View file

@ -1,720 +0,0 @@
package com.tangem.domain.features.addCustomToken.redux
import android.webkit.ValueCallback
import com.tangem.blockchain.common.Blockchain
import com.tangem.common.extensions.guard
import com.tangem.datasource.api.tangemTech.models.CoinsResponse
import com.tangem.domain.AddCustomTokenError
import com.tangem.domain.AddCustomTokenError.Warning.*
import com.tangem.domain.DomainDialog
import com.tangem.domain.DomainWrapped
import com.tangem.domain.common.extensions.canHandleToken
import com.tangem.domain.common.extensions.fromNetworkId
import com.tangem.domain.common.extensions.supportedBlockchains
import com.tangem.domain.common.extensions.toNetworkId
import com.tangem.domain.common.form.*
import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.domain.common.util.derivationStyleProvider
import com.tangem.domain.features.addCustomToken.*
import com.tangem.domain.features.addCustomToken.CustomTokenFieldId.*
import com.tangem.domain.features.addCustomToken.redux.AddCustomTokenAction.*
import com.tangem.domain.features.addCustomToken.redux.AddCustomTokenState.Companion.createInitialScreenState
import com.tangem.domain.redux.BaseStoreHub
import com.tangem.domain.redux.DomainState
import com.tangem.domain.redux.ReStoreReducer
import com.tangem.domain.redux.domainStore
import com.tangem.domain.redux.extensions.dispatchOnMain
import com.tangem.domain.redux.global.DomainGlobalAction
import com.tangem.domain.redux.global.DomainGlobalState
import com.tangem.utils.coroutines.AppCoroutineDispatcherProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.rekotlin.Action
import timber.log.Timber
/**
[REDACTED_AUTHOR]
*/
@Suppress("LargeClass")
internal class AddCustomTokenHub : BaseStoreHub<AddCustomTokenState>("AddCustomTokenHub") {
private val hubState: AddCustomTokenState
get() = domainStore.state.addCustomTokensState
override fun getReducer(): ReStoreReducer<AddCustomTokenState> = AddCustomTokenReducer(globalState)
override fun getHubState(storeState: DomainState): AddCustomTokenState = hubState
override fun updateStoreState(storeState: DomainState, newHubState: AddCustomTokenState): DomainState {
return storeState.copy(addCustomTokensState = newHubState)
}
@Suppress("ComplexMethod")
override suspend fun handleAction(action: Action, storeState: DomainState, cancel: ValueCallback<Action>) {
if (action !is AddCustomTokenAction) return
when (action) {
is OnCreate -> {
hubState.appSavedCurrencies.guard {
return throwUnAppropriateInitialization("addedTokens")
}
}
is OnDestroy -> cancelAll()
is OnTokenContractAddressChanged -> {
validateContractAddressAndNotify(action.contractAddress.value)
}
is OnTokenNetworkChanged -> {
if (!action.blockchainNetwork.isUserInput) return
validateContractAddressAndNotify(ContractAddress.getFieldValue())
}
is OnTokenDerivationPathChanged -> {
updateAddButton()
}
is OnTokenNameChanged, is OnTokenSymbolChanged, is OnTokenDecimalsChanged -> {
updateAddButton()
}
is OnAddCustomTokenClicked -> {
val state = hubState
val completeData = when {
state.getCustomTokenType() == CustomTokenType.Token && state.networkIsSelected() -> {
state.gatherUserToken()
}
state.getCustomTokenType() == CustomTokenType.Blockchain && state.networkIsSelected() -> {
state.gatherBlockchain()
}
else -> null
}
if (completeData == null) {
// normally it can't be, because the AddButton must be blocked
} else {
hubScope.launch(Dispatchers.Main) {
state.onTokenAddCallback?.invoke(completeData)
}
}
}
else -> Unit
}
}
private suspend fun validateContractAddressAndNotify(contractAddress: String) {
val error = ContractAddress.validateValue(contractAddress)
if (Network.isFilled()) {
when (error) {
null -> {
// valid contract address
ContractAddress.removeError()
findTokenAndUpdateFields(contractAddress)
}
AddCustomTokenError.InvalidContractAddress -> {
ContractAddress.addError(error)
enableDisableTokenDetailFields(hubState.tokensAnyFieldsIsFilled())
}
AddCustomTokenError.FieldIsEmpty -> {
ContractAddress.removeError()
clearTokenDetailsFields()
disableTokenDetailFields()
}
else -> {}
}
} else {
// is default selection (Blockchain.Unknown)
when (error) {
null -> {
// Blockchain.Unknown has always valid contract address
ContractAddress.removeError()
findTokenAndUpdateFields(contractAddress)
}
else -> {
ContractAddress.removeError()
clearTokenDetailsFields()
disableTokenDetailFields()
}
}
}
updateDerivationPath(Network.getFieldValue())
updateWarnings()
updateAddButton()
}
private suspend fun findTokenAndUpdateFields(contractAddress: String) {
val foundTokens = requestInfoAboutToken(contractAddress)
if (foundTokens.isEmpty()) {
// token not found - it's completely custom
dispatchOnMain(SetFoundTokenInfo(null))
enableTokenDetailFields()
return
}
// foundToken - contains all info about the token
val foundToken = foundTokens[0]
dispatchOnMain(SetFoundTokenInfo(foundToken))
when {
foundToken.networks.isEmpty() -> {
Timber.e("Unexpected state -> throw to FB")
}
foundToken.networks.size == 1 -> {
// token with single contract address
val singleTokenContract = foundToken.networks[0]
fillTokenFields(foundToken, singleTokenContract)
disableTokenDetailFields()
}
else -> {
val dialog = DomainDialog.SelectTokenDialog(
items = foundToken.networks,
networkIdConverter = { networkId ->
val blockchain = Blockchain.fromNetworkId(networkId)
if (blockchain == null || blockchain == Blockchain.Unknown) {
throw AddCustomTokenError.SelectTokeNetworkError(networkId)
}
hubState.blockchainToName(blockchain) ?: ""
},
onSelect = { selectedContract ->
hubScope.launch {
// find how to connect to the upper coroutineContext and dispatch through them
fillTokenFields(foundToken, selectedContract)
disableTokenDetailFields()
}
},
)
dispatchOnMain(DomainGlobalAction.ShowDialog(dialog))
}
}
}
private suspend fun updateDerivationPath(blockchainNetwork: Blockchain) {
val state = hubState
val derivationIsSupportedByNetwork = blockchainNetwork.isEvm() || blockchainNetwork == Blockchain.Unknown
if (DerivationPath.isFilled() && !derivationIsSupportedByNetwork) {
// reset to default
val derivationField = DerivationPath.getField<TokenDerivationPathField>()
derivationField.data = derivationField.data.copy(
value = Blockchain.Unknown,
isUserInput = false,
)
state.setField(derivationField)
dispatchOnMain(UpdateForm(hubState))
}
if (state.screenState.derivationPath.isEnabled != derivationIsSupportedByNetwork) {
val action = Screen.UpdateTokenFields(
listOf(
DerivationPath to state.screenState.derivationPath.copy(
isEnabled = derivationIsSupportedByNetwork,
),
),
)
dispatchOnMain(action)
}
}
private suspend fun updateWarnings() {
val state = hubState
val warningsAdd = mutableSetOf<AddCustomTokenError.Warning>()
val warningsRemove = mutableSetOf<AddCustomTokenError.Warning>()
val tokenIsSupported = tokenIsSupported(Network.getFieldValue())
val alreadyAdded = isPersistIntoAppSavedTokensList()
when (state.getCustomTokenType()) {
CustomTokenType.Blockchain -> {
warningsRemove.add(UnsupportedSolanaToken)
if (alreadyAdded) warningsAdd.add(TokenAlreadyAdded) else warningsRemove.add(TokenAlreadyAdded)
if (state.derivationPathIsSelected()) {
warningsAdd.add(PotentialScamToken)
} else {
warningsRemove.add(PotentialScamToken)
}
}
CustomTokenType.Token -> {
if (tokenIsSupported) {
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)
} else {
warningsRemove.add(TokenAlreadyAdded)
}
if (state.foundToken == null) {
if (state.tokensAnyFieldsIsFilled()) {
warningsAdd.add(PotentialScamToken)
} else {
warningsRemove.add(PotentialScamToken)
}
} else {
if (state.foundToken.active) {
warningsRemove.add(PotentialScamToken)
} else {
warningsAdd.add(PotentialScamToken)
}
}
}
}
dispatchOnMain(
Warning.Replace(
remove = warningsRemove,
add = warningsAdd,
),
)
}
private suspend fun updateAddButton() {
if (isPersistIntoAppSavedTokensList()) {
TokenAlreadyAdded.add()
disableAddButton()
return
} else {
TokenAlreadyAdded.remove()
}
val state = hubState
when {
// token
state.tokensFieldsIsFilled() && state.networkIsSelected() -> {
val error = ContractAddress.validateValue(ContractAddress.getFieldValue<String>())
val tokenIsSupported = tokenIsSupported(Network.getFieldValue())
enableDisableAddButton(tokenIsSupported && error == null)
}
// token
state.tokensAnyFieldsIsFilled() -> {
disableAddButton()
}
// blockchain
else -> {
if (state.networkIsSelected()) {
if (isBlockchainPersistIntoAppSavedTokensList()) disableAddButton() else enableAddButton()
} else {
disableAddButton()
}
}
}
}
private suspend fun requestInfoAboutToken(contractAddress: String): List<CoinsResponse.Coin> {
val tangemTechServiceManager = requireNotNull(hubState.tangemTechServiceManager)
dispatchOnMain(Screen.UpdateTokenFields(listOf(ContractAddress to ViewStates.TokenField(isLoading = true))))
val field = hubState.getField<TokenBlockchainField>(Network)
val selectedNetworkId: String? = field.data.value.let {
if (it == Blockchain.Unknown) null else it
}?.toNetworkId()
// simulate loading effect. It would be better if the delay would only run if tokenManager.checkAddress()
// got the result faster than 500ms and the delay would only be the difference between them.
delay(timeMillis = 500)
val result = tangemTechServiceManager.findToken(contractAddress, selectedNetworkId)
dispatchOnMain(Screen.UpdateTokenFields(listOf(ContractAddress to ViewStates.TokenField(isLoading = false))))
return result
}
/**
* These are helper functions.
*/
private fun isPersistIntoAppSavedTokensList(): Boolean = when (hubState.getCustomTokenType()) {
CustomTokenType.Blockchain -> isBlockchainPersistIntoAppSavedTokensList()
CustomTokenType.Token -> isTokenPersistIntoAppSavedTokensList()
}
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 -> Unit
is DomainWrapped.Currency.Token -> {
val sameId = tokenId == wrappedCurrency.token.id
val sameAddress = tokenContractAddress == wrappedCurrency.token.contractAddress
val sameBlockchain = Blockchain.fromNetworkId(tokenNetworkId) == wrappedCurrency.blockchain
val sameDerivationPath = derivationPath?.rawPath == wrappedCurrency.derivationPath
@Suppress("ComplexCondition")
if (sameId && sameAddress && sameBlockchain && sameDerivationPath) {
return true
}
}
}
}
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 -> {
val isSameBlockchain = selectedNetwork == wrappedCurrency.blockchain
val isSameDerivationPath = derivationPath?.rawPath == wrappedCurrency.derivationPath
if (isSameBlockchain && isSameDerivationPath) return true
}
is DomainWrapped.Currency.Token -> Unit
}
}
return false
}
private fun getDerivationPathFromSelectedBlockchain(
selectedDerivationBlockchain: Blockchain,
): com.tangem.crypto.hdWallet.DerivationPath? = AddCustomTokenState.getDerivationPath(
mainNetwork = Network.getFieldValue(),
derivationNetwork = selectedDerivationBlockchain,
derivationStyle = hubState.cardDerivationStyle,
)
private suspend fun fillTokenFields(token: CoinsResponse.Coin, coinNetwork: CoinsResponse.Coin.Network) {
val blockchain = Blockchain.fromNetworkId(coinNetwork.networkId) ?: Blockchain.Unknown
Network.setFieldValue(Field.Data(blockchain, false))
Name.setFieldValue(Field.Data(token.name, false))
Symbol.setFieldValue(Field.Data(token.symbol, false))
Decimals.setFieldValue(Field.Data(coinNetwork.decimalCount.toString(), false))
dispatchOnMain(UpdateForm(hubState))
}
private suspend fun clearTokenDetailsFields() {
Name.setFieldValue(Field.Data("", false))
Symbol.setFieldValue(Field.Data("", false))
Decimals.setFieldValue(Field.Data("", false))
dispatchOnMain(UpdateForm(hubState))
}
private suspend fun enableTokenDetailFields() {
enableDisableTokenDetailFields(true)
}
private suspend fun disableTokenDetailFields() {
enableDisableTokenDetailFields(false)
}
private suspend fun enableDisableTokenDetailFields(isEnabled: Boolean) {
val state = hubState
val action = Screen.UpdateTokenFields(
listOf(
Name to state.screenState.name.copy(isEnabled = isEnabled),
Symbol to state.screenState.symbol.copy(isEnabled = isEnabled),
Decimals to state.screenState.decimals.copy(isEnabled = isEnabled),
),
)
dispatchOnMain(action)
}
private suspend fun enableAddButton() {
enableDisableAddButton(true)
}
private suspend fun disableAddButton() {
enableDisableAddButton(false)
}
private suspend fun enableDisableAddButton(isEnabled: Boolean) {
dispatchOnMain(Screen.UpdateAddButton(ViewStates.AddButton(isEnabled)))
}
private fun tokenIsSupported(blockchain: Blockchain): Boolean = when (blockchain) {
Blockchain.Unknown -> true
else -> {
val scanResponse = globalState.scanResponse
scanResponse?.card?.canHandleToken(
blockchain = blockchain,
cardTypesResolver = scanResponse.cardTypesResolver,
) ?: false
}
}
@Throws
private fun throwUnAppropriateInitialization(objName: String) {
throw AddCustomTokenError.UnAppropriateInitialization(
"AddCustomTokenHub",
"$objName must be not NULL",
)
}
private suspend fun CustomTokenFieldId.addError(error: AddCustomTokenError) {
dispatchOnMain(FieldError.Add(this, error))
}
private suspend fun CustomTokenFieldId.removeError() {
dispatchOnMain(FieldError.Remove(this))
}
private inline fun <reified T> CustomTokenFieldId.getField(): T {
val state = hubState
val value = when (this) {
ContractAddress -> state.getField<TokenField>(this)
Network -> state.getField<TokenBlockchainField>(this)
Name -> state.getField<TokenField>(this)
Symbol -> state.getField<TokenField>(this)
Decimals -> state.getField<TokenField>(this)
DerivationPath -> state.getField<TokenDerivationPathField>(this)
}
return value as T
}
private inline fun <reified T> CustomTokenFieldId.getFieldValue(): T {
val value = when (this) {
ContractAddress -> getField<TokenField>().data.value
Network -> getField<TokenBlockchainField>().data.value
Name -> getField<TokenField>().data.value
Symbol -> getField<TokenField>().data.value
Decimals -> getField<TokenField>().data.value
DerivationPath -> getField<TokenDerivationPathField>().data.value
}
return value as T
}
private fun CustomTokenFieldId.setFieldValue(fieldData: Field.Data<*>) {
when (this) {
ContractAddress -> getField<TokenField>().data = fieldData as Field.Data<String>
Network -> getField<TokenBlockchainField>().data = fieldData as Field.Data<Blockchain>
Name -> getField<TokenField>().data = fieldData as Field.Data<String>
Symbol -> getField<TokenField>().data = fieldData as Field.Data<String>
Decimals -> getField<TokenField>().data = fieldData as Field.Data<String>
DerivationPath -> getField<TokenDerivationPathField>().data = fieldData as Field.Data<Blockchain>
}
}
private fun CustomTokenFieldId.validateValue(value: Any): AddCustomTokenError? {
return when (this) {
ContractAddress -> {
val contractAddressValidator: TokenContractAddressValidator = hubState.getValidator(ContractAddress)
contractAddressValidator.nextValidationFor(Network.getFieldValue())
contractAddressValidator.validate(value as String)
}
Network, DerivationPath -> {
hubState.getValidator<TokenNetworkValidator>(Network).validate(value as Blockchain)
}
Name -> {
hubState.getValidator<TokenNameValidator>(Name).validate(value as String)
}
Symbol -> {
hubState.getValidator<TokenSymbolValidator>(Symbol).validate(value as String)
}
Decimals -> {
hubState.getValidator<TokenDecimalsValidator>(Decimals).validate(value as String)
}
}
}
private fun CustomTokenFieldId.isFilled(): Boolean {
return when (this) {
ContractAddress -> getFieldValue<String>().isNotEmpty()
Network -> getFieldValue<Blockchain>() != Blockchain.Unknown
Name -> getFieldValue<String>().isNotEmpty()
Symbol -> getFieldValue<String>().isNotEmpty()
Decimals -> getFieldValue<String>().isNotEmpty()
DerivationPath -> getFieldValue<Blockchain>() != Blockchain.Unknown
}
}
private suspend fun AddCustomTokenError.Warning.add() {
dispatchOnMain(Warning.Add(setOf(this)))
}
private suspend fun AddCustomTokenError.Warning.remove() {
dispatchOnMain(Warning.Remove(setOf(this)))
}
}
@Suppress("ComplexMethod")
private class AddCustomTokenReducer(
private val globalState: DomainGlobalState,
) : ReStoreReducer<AddCustomTokenState> {
@Suppress("LongMethod")
override fun reduceAction(action: Action, state: AddCustomTokenState): AddCustomTokenState {
return when (action) {
is Init.SetAddedCurrencies -> {
state.copy(appSavedCurrencies = action.addedCurrencies)
}
is Init.SetOnAddTokenCallback -> {
state.copy(onTokenAddCallback = action.callback)
}
is OnCreate -> {
val scanResponse = requireNotNull(globalState.scanResponse)
val card = globalState.scanResponse.card
val supportedTokenNetworkIds = card.supportedBlockchains(scanResponse.cardTypesResolver)
.filter(Blockchain::canHandleTokens)
.map(Blockchain::toNetworkId)
val tangemTechServiceManager = AddCustomTokenService(
tangemTechApi = globalState.networkServices.tangemTechService.api,
dispatchers = AppCoroutineDispatcherProvider(),
supportedTokenNetworkIds = supportedTokenNetworkIds,
)
state.copy(
cardDerivationStyle = globalState.scanResponse.derivationStyleProvider.getDerivationStyle(),
form = Form(
AddCustomTokenState.createFormFields(
cardTypesResolver = globalState.scanResponse.cardTypesResolver,
card = card,
type = CustomTokenType.Blockchain,
),
),
tangemTechServiceManager = tangemTechServiceManager,
screenState = createInitialScreenState(card.settings.isHDWalletAllowed),
)
}
is OnDestroy -> {
val scanResponse = requireNotNull(globalState.scanResponse)
val card = scanResponse.card
state.reset(scanResponse.cardTypesResolver, card)
}
is UpdateForm -> {
updateFormState(action.state)
}
is OnTokenContractAddressChanged -> {
val field: TokenField = state.getField(ContractAddress)
field.data = action.contractAddress
updateFormState(state)
}
is OnTokenNetworkChanged -> {
val field: TokenBlockchainField = state.getField(Network)
field.data = action.blockchainNetwork
updateFormState(state)
}
is OnTokenNameChanged -> {
val field: TokenField = state.getField(Name)
field.data = action.tokenName
updateFormState(state)
}
is OnTokenSymbolChanged -> {
val field: TokenField = state.getField(Symbol)
field.data = action.tokenSymbol
updateFormState(state)
}
is OnTokenDecimalsChanged -> {
val field: TokenField = state.getField(Decimals)
field.data = action.tokenDecimals
updateFormState(state)
}
is OnTokenDerivationPathChanged -> {
val field: TokenDerivationPathField = state.getField(DerivationPath)
field.data = action.blockchainDerivationPath
updateFormState(state)
}
is FieldError.Add -> {
val newMap = state.formErrors.toMutableMap().apply { this[action.id] = action.error }
state.copy(formErrors = newMap)
}
is FieldError.Remove -> {
val newMap = state.formErrors.toMutableMap().apply { remove(action.id) }
state.copy(formErrors = newMap)
}
is SetFoundTokenInfo -> {
state.copy(foundToken = action.foundToken)
}
is Warning.Add -> {
val newList = state.warnings.toMutableSet().apply { addAll(action.warnings) }
state.copy(warnings = newList.toSet())
}
is Warning.Remove -> {
val newList = state.warnings.toMutableSet().apply { removeAll(action.warnings) }
state.copy(warnings = newList.toSet())
}
is Warning.Replace -> {
val newList = state.warnings.toMutableSet().apply {
removeAll(action.remove)
addAll(action.add)
}
state.copy(warnings = newList.toSet())
}
is Screen.UpdateTokenFields -> {
var newScreenState = state.screenState
action.pairs.forEach {
newScreenState = when (it.first) {
ContractAddress -> {
if (state.screenState.contractAddressField == it.second) {
newScreenState
} else {
newScreenState.copy(contractAddressField = it.second)
}
}
Network -> {
if (state.screenState.network == it.second) {
newScreenState
} else {
newScreenState.copy(network = it.second)
}
}
Name -> {
if (state.screenState.name == it.second) {
newScreenState
} else {
newScreenState.copy(name = it.second)
}
}
Symbol -> {
if (state.screenState.symbol == it.second) {
newScreenState
} else {
newScreenState.copy(symbol = it.second)
}
}
Decimals -> {
if (state.screenState.decimals == it.second) {
newScreenState
} else {
newScreenState.copy(decimals = it.second)
}
}
DerivationPath -> {
if (state.screenState.derivationPath == it.second) {
newScreenState
} else {
newScreenState.copy(derivationPath = it.second)
}
}
else -> newScreenState
}
}
if (state.screenState == newScreenState) {
state
} else {
state.copy(screenState = newScreenState)
}
}
is Screen.UpdateAddButton -> {
val newScreenState = if (state.screenState.addButton == action.addButton) {
state.screenState
} else {
state.screenState.copy(addButton = action.addButton)
}
if (newScreenState == state.screenState) {
state
} else {
state.copy(screenState = newScreenState)
}
}
else -> state
}
}
private fun updateFormState(state: AddCustomTokenState): AddCustomTokenState {
return state.copy(form = Form(state.form.fieldList))
}
}

View file

@ -1,311 +0,0 @@
package com.tangem.domain.features.addCustomToken.redux
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.derivation.DerivationStyle
import com.tangem.common.json.MoshiJsonConverter
import com.tangem.datasource.api.tangemTech.models.CoinsResponse
import com.tangem.domain.AddCustomTokenError
import com.tangem.domain.DomainWrapped
import com.tangem.domain.common.CardTypesResolver
import com.tangem.domain.common.TapWorkarounds.isTestCard
import com.tangem.domain.common.extensions.isSupportedInApp
import com.tangem.domain.common.extensions.supportedBlockchains
import com.tangem.domain.common.extensions.supportedTokens
import com.tangem.domain.common.form.*
import com.tangem.domain.features.addCustomToken.*
import com.tangem.domain.features.addCustomToken.CustomTokenFieldId.*
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.redux.DomainState
import com.tangem.domain.redux.state.StringActionStateConverter
import org.rekotlin.Action
import org.rekotlin.StateType
data class AddCustomTokenState(
val appSavedCurrencies: List<DomainWrapped.Currency>? = null,
val onTokenAddCallback: ((CustomCurrency) -> Unit)? = null,
val cardDerivationStyle: DerivationStyle? = null,
val form: Form = Form(listOf()),
val formValidators: Map<CustomTokenFieldId, CustomTokenValidator<out Any>> = createFormValidators(),
val formErrors: Map<CustomTokenFieldId, AddCustomTokenError> = emptyMap(),
val foundToken: CoinsResponse.Coin? = null,
val warnings: Set<AddCustomTokenError.Warning> = emptySet(),
val screenState: ScreenState = createInitialScreenState(),
val tangemTechServiceManager: AddCustomTokenService? = null,
) : StateType {
inline fun <reified T> getField(id: FieldId): T = form.getField(id) as T
fun setField(field: DataField<*>) {
form.setField(field)
}
inline fun <reified T> getValidator(id: FieldId): T = formValidators[id] as T
fun getError(id: FieldId): AddCustomTokenError? = formErrors[id]
inline fun <reified T> visitDataConverter(converter: FieldDataConverter<T>): T {
form.visitDataConverter(converter)
return converter.getConvertedData()
}
fun blockchainToName(blockchain: Blockchain, isDerivationPath: Boolean = false): String? {
return when {
isDerivationPath -> blockchain.derivationPath(DerivationStyle.LEGACY)?.rawPath
else -> {
when (blockchain) {
Blockchain.Unknown -> null
else -> blockchain.fullName
}
}
}
}
// except network
fun tokensFieldsIsFilled(): Boolean {
val idsToCheck = listOf(ContractAddress, Name, Symbol, Decimals)
val fieldsToCheck = form.fieldList.filter { idsToCheck.contains(it.id) }
val validator = StringIsNotEmptyValidator()
fieldsToCheck.forEach { field ->
val error = validator.validate(field.data.value?.toString())
if (error != null) return false
}
return true
}
// except network
fun tokensAnyFieldsIsFilled(): Boolean {
val idsToCheck = listOf(ContractAddress, Name, Symbol, Decimals)
val fieldsToCheck = form.fieldList.filter { idsToCheck.contains(it.id) }
val validator = StringIsEmptyValidator()
val errorsList = fieldsToCheck.mapNotNull { field ->
validator.validate(field.data.value?.toString())
}
return errorsList.isNotEmpty()
}
fun networkIsSelected(): Boolean {
val network = getField<TokenBlockchainField>(Network)
return network.data.value != Blockchain.Unknown
}
fun derivationPathIsSelected(): Boolean {
val network = getField<TokenDerivationPathField>(DerivationPath)
return network.data.value != Blockchain.Unknown
}
fun getCustomTokenType(): CustomTokenType {
return if (tokensAnyFieldsIsFilled() || tokensFieldsIsFilled()) {
CustomTokenType.Token
} else {
CustomTokenType.Blockchain
}
}
fun gatherUserToken(): CustomCurrency.CustomToken? = try {
getToken()
} catch (ex: Exception) {
null
}
fun gatherBlockchain(): CustomCurrency.CustomBlockchain? = try {
getBlockchain()
} catch (ex: Exception) {
null
}
fun reset(cardTypesResolver: CardTypesResolver, card: CardDTO): AddCustomTokenState {
return this.copy(
appSavedCurrencies = null,
onTokenAddCallback = null,
cardDerivationStyle = null,
form = Form(createFormFields(cardTypesResolver, card, CustomTokenType.Blockchain)),
formErrors = emptyMap(),
foundToken = null,
warnings = emptySet(),
screenState = createInitialScreenState(card.settings.isHDWalletAllowed),
tangemTechServiceManager = null,
)
}
private fun getToken(): CustomCurrency.CustomToken {
return CustomCurrency.CustomToken.Converter(foundToken?.id, cardDerivationStyle)
.apply { visitDataConverter(this) }
.getConvertedData()
}
private fun getBlockchain(): CustomCurrency.CustomBlockchain {
return CustomCurrency.CustomBlockchain.Converter(cardDerivationStyle)
.apply { visitDataConverter(this) }
.getConvertedData()
}
companion object {
/**
* If an user select derivation path (derivationNetwork) as Blockchain.Unknown,
* then we should use a blockchain from the mainNetwork to determine a DerivationPath
*/
internal fun getDerivationPath(
mainNetwork: Blockchain,
derivationNetwork: Blockchain,
derivationStyle: DerivationStyle?,
): com.tangem.crypto.hdWallet.DerivationPath? {
// If we allow user to select derivations, we need to provide different derivations
// (Legacy style derivations).
// But the mainNetwork derivation depends on whether a user has a card
// with legacy derivations or new style derivations.
val derivationStyleToUse = if (derivationNetwork == Blockchain.Unknown) {
derivationStyle
} else {
DerivationStyle.LEGACY
}
return when (derivationNetwork) {
Blockchain.Unknown -> mainNetwork
else -> derivationNetwork
}.derivationPath(derivationStyleToUse)
}
internal fun createFormFields(
cardTypesResolver: CardTypesResolver,
card: CardDTO,
type: CustomTokenType,
): List<DataField<*>> {
return listOf(
TokenField(ContractAddress),
TokenBlockchainField(Network, getNetworksList(cardTypesResolver, card, type)),
TokenField(Name),
TokenField(Symbol),
TokenField(Decimals),
TokenDerivationPathField(DerivationPath, getSupportedDerivations(card)),
)
}
/**
* Serves to determine the networks (blockchains & tokens) that can be selected by Form.Networks.
* Blockchain.Unknown - is the default selection
*/
private fun getNetworksList(
cardTypesResolver: CardTypesResolver,
card: CardDTO,
type: CustomTokenType,
): List<Blockchain> {
val evmBlockchains = Blockchain.values()
.filter { it.isEvm() }
.filter { card.isTestCard == it.isTestnet() }
val additionalBlockchains = listOf(
Blockchain.Binance,
Blockchain.BinanceTestnet,
Blockchain.Solana,
Blockchain.SolanaTestnet,
Blockchain.Tron,
Blockchain.TronTestnet,
)
val supportedByCard = when (type) {
CustomTokenType.Blockchain -> card.supportedBlockchains(cardTypesResolver)
CustomTokenType.Token -> card.supportedTokens(cardTypesResolver)
}
val typedNetworksList = (evmBlockchains + additionalBlockchains)
.filter { supportedByCard.contains(it) }
.toMutableList()
val default = Blockchain.Unknown
typedNetworksList.add(0, default)
return typedNetworksList.sortByName()
}
private fun createFormValidators(): Map<CustomTokenFieldId, CustomTokenValidator<out Any>> {
return mapOf(
ContractAddress to TokenContractAddressValidator(),
Network to TokenNetworkValidator(),
Name to TokenNameValidator(),
Symbol to TokenSymbolValidator(),
Decimals to TokenDecimalsValidator(),
)
}
private fun getSupportedDerivations(card: CardDTO): List<Blockchain> {
val evmBlockchains = Blockchain.values()
.filter { card.isTestCard == it.isTestnet() && it.isEvm() }
.filter { it.isSupportedInApp() }
return (listOf(Blockchain.Unknown) + evmBlockchains).sortByName()
}
internal fun createInitialScreenState(showDerivationPathField: Boolean = false): ScreenState {
return ScreenState(
contractAddressField = ViewStates.TokenField(),
network = ViewStates.TokenField(),
name = ViewStates.TokenField(isEnabled = false),
symbol = ViewStates.TokenField(isEnabled = false),
decimals = ViewStates.TokenField(isEnabled = false),
derivationPath = ViewStates.TokenField(isVisible = showDerivationPathField),
addButton = ViewStates.AddButton(isEnabled = false),
)
}
}
class Converter : StringActionStateConverter<DomainState> {
private val jsonConverter: MoshiJsonConverter = MoshiJsonConverter.INSTANCE
private var builder: StringBuilder = StringBuilder()
override fun convert(action: Action, stateHolder: DomainState): String? {
if (action !is AddCustomTokenAction) return null
val state = stateHolder.addCustomTokensState
val fieldConverter =
FieldToJsonConverter(
listOf(
ContractAddress,
Network,
Name,
Symbol,
Decimals,
DerivationPath,
),
jsonConverter,
)
state.visitDataConverter(fieldConverter)
val errors = state.formErrors.map {
"${it.key}: ${it.value::class.java.simpleName}"
}
val warnings = state.warnings.map { it::class.java.simpleName }
printAction(action, state)
printStateValue("fields", fieldConverter.getConvertedData())
printStateValue("fieldErrors", toJson(errors))
printStateValue("warnings", toJson(warnings))
printStateValue("screenState", toJson(state.screenState))
printMessage("------------------------------------------------------")
val printed = builder.toString()
builder = StringBuilder()
return printed
}
private fun printStateValue(name: String, value: String) {
printMessage("$name: $value")
}
private fun printAction(action: AddCustomTokenAction, state: AddCustomTokenState) {
printMessage("action: $action, state: ${state::class.java.simpleName}")
}
private fun toJson(value: Any): String {
return jsonConverter.prettyPrint(value)
}
private fun printMessage(message: String) {
builder.append("$message\n")
}
}
}
private fun List<Blockchain>.sortByName(): List<Blockchain> = this.sortedBy { it.fullName }
enum class CustomTokenType {
Token, Blockchain
}

View file

@ -1,27 +0,0 @@
package com.tangem.domain.features.addCustomToken.redux
/**
[REDACTED_AUTHOR]
*/
// describes state the screen, except the form fields
data class ScreenState(
val contractAddressField: ViewStates.TokenField,
val network: ViewStates.TokenField,
val name: ViewStates.TokenField,
val symbol: ViewStates.TokenField,
val decimals: ViewStates.TokenField,
val derivationPath: ViewStates.TokenField,
val addButton: ViewStates.AddButton,
)
sealed class ViewStates {
data class TokenField(
val isLoading: Boolean = false,
val isEnabled: Boolean = true,
val isVisible: Boolean = true,
) : ViewStates()
data class AddButton(
val isEnabled: Boolean = true,
) : ViewStates()
}

View file

@ -1,13 +1,9 @@
package com.tangem.domain.redux
import com.tangem.domain.features.addCustomToken.redux.AddCustomTokenState
import com.tangem.domain.redux.global.DomainGlobalState
import org.rekotlin.StateType
/**
[REDACTED_AUTHOR]
*/
data class DomainState(
val globalState: DomainGlobalState = DomainGlobalState(),
val addCustomTokensState: AddCustomTokenState = AddCustomTokenState(),
) : StateType
data class DomainState(val globalState: DomainGlobalState = DomainGlobalState()) : StateType

View file

@ -1,7 +1,5 @@
package com.tangem.domain.redux
import com.tangem.domain.DomainLayer
import com.tangem.domain.features.addCustomToken.redux.AddCustomTokenHub
import com.tangem.domain.redux.global.DomainGlobalHub
import org.rekotlin.Action
import org.rekotlin.Store
@ -9,10 +7,7 @@ import org.rekotlin.Store
/**
[REDACTED_AUTHOR]
*/
private val RE_STORE_HUBS: List<ReStoreHub<DomainState, *>> = listOf(
DomainGlobalHub(),
AddCustomTokenHub(),
)
private val RE_STORE_HUBS: List<ReStoreHub<DomainState, *>> = listOf(DomainGlobalHub())
val domainStore = Store(
state = DomainState(),
@ -37,7 +32,6 @@ private fun reduce(action: Action, domainState: DomainState?): DomainState {
assembleReducedDomainState
}
}
DomainLayer.actionStateLogger.log(reducedStatesByAction)
return assembleReducedDomainState
}

View file

@ -1,6 +1,5 @@
package com.tangem.domain.redux.global
import com.tangem.domain.DomainDialog
import com.tangem.domain.models.scan.ScanResponse
import org.rekotlin.Action
@ -10,5 +9,4 @@ import org.rekotlin.Action
// TODO: refactoring: is alias for the GlobalAction
sealed class DomainGlobalAction : Action {
data class SaveScanNoteResponse(val scanResponse: ScanResponse) : DomainGlobalAction()
data class ShowDialog(val stateDialog: DomainDialog?) : DomainGlobalAction()
}

View file

@ -47,9 +47,6 @@ private class DomainGlobalReducer : ReStoreReducer<DomainGlobalState> {
)
state.copy(scanResponse = action.scanResponse)
}
is DomainGlobalAction.ShowDialog -> {
state.copy(dialog = action.stateDialog)
}
else -> state
}
}

View file

@ -2,7 +2,6 @@ package com.tangem.domain.redux.global
import com.tangem.datasource.api.paymentology.PaymentologyApiService
import com.tangem.datasource.api.tangemTech.TangemTechService
import com.tangem.domain.DomainDialog
import com.tangem.domain.models.scan.ScanResponse
/**
@ -14,7 +13,6 @@ data class DomainGlobalState(
val scanResponse: ScanResponse? = null,
//
val networkServices: NetworkServices = NetworkServices(),
val dialog: DomainDialog? = null,
)
data class NetworkServices(

View file

@ -1,35 +0,0 @@
package com.tangem.domain.redux.state
import com.tangem.domain.redux.DomainState
import org.rekotlin.Action
/**
[REDACTED_AUTHOR]
*/
interface StringStateConverter<StateHolder> {
fun convert(stateHolder: StateHolder): String
}
interface StringActionStateConverter<StateHolder> {
fun convert(action: Action, stateHolder: StateHolder): String?
}
class ActionStateConvertersFactory {
private val stateConverters = mutableMapOf<Class<out Action>, StringActionStateConverter<DomainState>>()
fun addConverter(classOfAction: Class<out Action>, converter: StringActionStateConverter<DomainState>) {
stateConverters[classOfAction] = converter
}
fun getConverter(action: Action): StringActionStateConverter<DomainState>? {
val converter = stateConverters.firstNotNullOfOrNull { (classOfAction, converter) ->
if (classOfAction.isAssignableFrom(action::class.java)) {
converter
} else {
null
}
} ?: return null
return converter
}
}

View file

@ -1,35 +0,0 @@
package com.tangem.domain.redux.state
import com.tangem.domain.features.BuildConfig
import com.tangem.domain.redux.DomainState
import org.rekotlin.Action
import timber.log.Timber
/**
[REDACTED_AUTHOR]
* Use it only in debug mode!
*/
internal interface ActionStateLogger {
fun log(reducedSates: List<Pair<Action, DomainState>>)
}
internal class ActionStateLoggerImpl : ActionStateLogger {
val actionStateConvertersFactory = ActionStateConvertersFactory()
override fun log(reducedSates: List<Pair<Action, DomainState>>) {
if (!BuildConfig.LOG_ENABLED) return
logStates(reducedSates)
}
private fun logStates(reducedSates: List<Pair<Action, DomainState>>) {
reducedSates.forEach { (action, domainState) ->
val messageToPrint = actionStateConvertersFactory.getConverter(action)
?.convert(action, domainState)
?: return@forEach
Timber.d(messageToPrint)
}
}
}

View file

@ -0,0 +1,8 @@
package com.tangem.domain.redux.state
/**
[REDACTED_AUTHOR]
*/
interface StringStateConverter<StateHolder> {
fun convert(stateHolder: StateHolder): String
}