Updated on 2026-08-14

This commit is contained in:
Tangem 2025-03-18 13:37:16 +05:00
parent d7c4218f92
commit 5efb1956c7
30 changed files with 1768 additions and 5 deletions

View file

@ -12,8 +12,4 @@ interface Transformer<S> {
fun <T> MutableStateFlow<T>.update(transformer: Transformer<T>) {
update { transformer.transform(this.value) }
}
fun <T> MutableStateFlow<T>.updateAll(vararg transformers: Transformer<T>) {
transformers.forEach { transformer -> update { transformer.transform(this.value) } }
}

View file

@ -0,0 +1,20 @@
package com.tangem.features.send.v2.api
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.wallets.models.UserWalletId
interface SendComponent : ComposableContentComponent {
data class Params(
val userWalletId: UserWalletId,
val currency: CryptoCurrency,
val transactionId: String? = null,
val amount: String? = null,
val tag: String? = null,
val destinationAddress: String? = null,
)
interface Factory : ComponentFactory<Params, SendComponent>
}

View file

@ -3,7 +3,7 @@ package com.tangem.features.send.v2
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.features.send.v2.api.SendFeatureToggles
class DefaultSendFeatureToggles(
internal class DefaultSendFeatureToggles(
private val featureToggles: FeatureTogglesManager,
) : SendFeatureToggles {
override val isSendV2Enabled: Boolean

View file

@ -0,0 +1,20 @@
package com.tangem.features.send.v2.di
import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.send.v2.subcomponents.destination.model.SendDestinationModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
@Module
@InstallIn(ModelComponent::class)
internal interface SendModelModule {
@Binds
@IntoMap
@ClassKey(SendDestinationModel::class)
fun provideSendDestinationModel(model: SendDestinationModel): Model
}

View file

@ -0,0 +1,105 @@
package com.tangem.features.send.v2.send
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.arkivanov.decompose.router.stack.StackNavigation
import com.arkivanov.decompose.router.stack.childStack
import com.arkivanov.decompose.router.stack.pop
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.decompose.navigation.inner.InnerRouter
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.features.send.v2.api.SendComponent
import com.tangem.features.send.v2.send.analytics.SendAnalyticEvents
import com.tangem.features.send.v2.send.model.SendModel
import com.tangem.features.send.v2.subcomponents.destination.SendDestinationComponent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filterIsInstance
internal class DefaultSendComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted private val params: SendComponent.Params,
) : SendComponent, AppComponentContext by appComponentContext {
private val stackNavigation = StackNavigation<SendRoute>()
private val innerRouter = InnerRouter<SendRoute>(
stackNavigation = stackNavigation,
popCallback = { onChildBack() },
)
private val initialRoute = SendRoute.Empty
private val currentRoute = MutableStateFlow<SendRoute>(initialRoute)
private val model: SendModel = getOrCreateModel(params = params, router = innerRouter)
private val childStack = childStack(
key = "sendInnerStack",
source = stackNavigation,
serializer = null,
initialConfiguration = initialRoute,
handleBackButton = true,
childFactory = { configuration, factoryContext ->
createChild(
configuration,
childByContext(
componentContext = factoryContext,
router = innerRouter,
),
)
},
)
@Composable
override fun Content(modifier: Modifier) {
BackHandler(onBack = ::onChildBack)
}
private fun createChild(route: SendRoute, factoryContext: AppComponentContext) = when (route) {
SendRoute.Empty -> getStubComponent()
is SendRoute.Destination -> getDestinationComponent(factoryContext, route)
is SendRoute.Amount -> getAmountComponent()
is SendRoute.Fee -> getFeeComponent()
SendRoute.Confirm -> getConfirmComponent()
}
private fun getDestinationComponent(factoryContext: AppComponentContext, route: SendRoute) =
SendDestinationComponent(
appComponentContext = factoryContext,
params = SendDestinationComponent.Params(
state = model.uiState.value.destinationUM,
currentRoute = currentRoute.filterIsInstance<SendRoute.Destination>(),
analyticsCategoryName = SendAnalyticEvents.SEND_CATEGORY,
userWallet = model.userWallet,
cryptoCurrencyStatus = model.cryptoCurrencyStatus,
callback = model,
isEditMode = route.isEditMode,
),
)
private fun getAmountComponent() = getStubComponent() // todo
private fun getFeeComponent() = getStubComponent() // todo
private fun getConfirmComponent() = getStubComponent() // todo
private fun getStubComponent() = ComposableContentComponent { }
private fun onChildBack() {
if (childStack.value.active.configuration == SendRoute.Empty || childStack.value.backStack.isEmpty()) {
router.pop()
} else {
stackNavigation.pop()
}
}
@AssistedFactory
interface Factory : SendComponent.Factory {
override fun create(context: AppComponentContext, params: SendComponent.Params): DefaultSendComponent
}
}

View file

@ -0,0 +1,35 @@
package com.tangem.features.send.v2.send
import com.tangem.core.decompose.navigation.Route
import kotlinx.serialization.Serializable
@Serializable
internal sealed class SendRoute : Route {
abstract val isEditMode: Boolean
@Serializable
data object Empty : SendRoute() {
override val isEditMode = false
}
@Serializable
data object Confirm : SendRoute() {
override val isEditMode: Boolean = false
}
@Serializable
data class Destination(
override val isEditMode: Boolean,
) : SendRoute()
@Serializable
data class Amount(
override val isEditMode: Boolean,
) : SendRoute()
@Serializable
data class Fee(
override val isEditMode: Boolean = true,
) : SendRoute()
}

View file

@ -0,0 +1,43 @@
package com.tangem.features.send.v2.send.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN_PARAM
/**
* Send screen analytics
*/
internal sealed class SendAnalyticEvents(
event: String,
params: Map<String, String> = mapOf(),
) : AnalyticsEvent(category = SEND_CATEGORY, event = event, params = params) {
/** Recipient address screen opened */
data object AddressScreenOpened : SendAnalyticEvents(event = "Address Screen Opened")
/** Amount screen opened */
data object AmountScreenOpened : SendAnalyticEvents(event = "Amount Screen Opened")
/** Fee screen opened */
data object FeeScreenOpened : SendAnalyticEvents(event = "Fee Screen Opened")
/** Confirmation screen opened */
data object ConfirmationScreenOpened : SendAnalyticEvents(event = "Confirm Screen Opened")
/** If transaction delays notification is present */
data class NoticeTransactionDelays(
val token: String,
) : SendAnalyticEvents(
event = "Notice - Transaction Delays Are Possible",
params = mapOf(TOKEN_PARAM to token),
)
/** If error occurs during send transactions */
data class TransactionError(val token: String) : SendAnalyticEvents(
event = "Error - Transaction Rejected",
params = mapOf(TOKEN_PARAM to token),
)
companion object {
const val SEND_CATEGORY = "Token / Send"
}
}

View file

@ -0,0 +1,40 @@
package com.tangem.features.send.v2.send.model
import androidx.compose.runtime.Stable
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.features.send.v2.send.ui.state.SendUM
import com.tangem.features.send.v2.subcomponents.destination.SendDestinationComponent
import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import javax.inject.Inject
import kotlin.properties.Delegates
@Stable
@ModelScoped
@Suppress("LongParameterList")
internal class SendModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
) : Model(), SendDestinationComponent.ModelCallback {
private val _uiState = MutableStateFlow(initialState())
val uiState = _uiState.asStateFlow()
var userWallet: UserWallet by Delegates.notNull()
var cryptoCurrencyStatus: CryptoCurrencyStatus by Delegates.notNull()
private fun initialState(): SendUM = SendUM(
amountState = AmountState.Empty(),
destinationUM = DestinationUM.Empty(),
)
override fun onDestinationResult(destinationUM: DestinationUM) {
_uiState.update { it.copy(destinationUM = destinationUM) }
}
}

View file

@ -0,0 +1,9 @@
package com.tangem.features.send.v2.send.ui.state
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationUM
internal data class SendUM(
val amountState: AmountState,
val destinationUM: DestinationUM,
)

View file

@ -0,0 +1,44 @@
package com.tangem.features.send.v2.subcomponents.destination
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.features.send.v2.send.SendRoute
import com.tangem.features.send.v2.subcomponents.destination.model.SendDestinationModel
import com.tangem.features.send.v2.subcomponents.destination.ui.SendDestinationContent
import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationUM
import kotlinx.coroutines.flow.Flow
internal class SendDestinationComponent(
appComponentContext: AppComponentContext,
private val params: Params,
) : ComposableContentComponent, AppComponentContext by appComponentContext {
private val model: SendDestinationModel = getOrCreateModel(params = params, router = router)
@Composable
override fun Content(modifier: Modifier) {
val state = model.uiState.collectAsStateWithLifecycle()
SendDestinationContent(state = state.value, model, isBalanceHidden = false)
}
data class Params(
val state: DestinationUM,
val analyticsCategoryName: String,
val userWallet: UserWallet,
val cryptoCurrencyStatus: CryptoCurrencyStatus,
val callback: ModelCallback,
val currentRoute: Flow<SendRoute.Destination>,
val isEditMode: Boolean,
)
interface ModelCallback {
fun onDestinationResult(destinationUM: DestinationUM)
}
}

View file

@ -0,0 +1,12 @@
package com.tangem.features.send.v2.subcomponents.destination.analytics
internal enum class EnterAddressSource {
QRCode,
PasteButton,
RecentAddress,
InputField,
;
val isPasted: Boolean
get() = this != InputField
}

View file

@ -0,0 +1,37 @@
package com.tangem.features.send.v2.subcomponents.destination.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam.Key.SOURCE
import com.tangem.core.analytics.models.AnalyticsParam.Key.VALIDATION
internal sealed class SendDestinationAnalyticEvents(
category: String,
event: String,
params: Map<String, String> = mapOf(),
) : AnalyticsEvent(category = category, event = event, params = params) {
abstract val categoryName: String
/** Address to send entered */
data class AddressEntered(
override val categoryName: String,
val source: EnterAddressSource,
val isValid: Boolean,
) : SendDestinationAnalyticEvents(
category = categoryName,
event = "Address Entered",
params = mapOf(
SOURCE to source.name,
VALIDATION to if (isValid) "Success" else "Fail",
),
)
/** Qr Code button clicked */
data class QrCodeButtonClicked(
override val categoryName: String,
) :
SendDestinationAnalyticEvents(
category = categoryName,
event = "Button - QR Code",
)
}

View file

@ -0,0 +1,12 @@
package com.tangem.features.send.v2.subcomponents.destination.model
import com.tangem.features.send.v2.subcomponents.destination.analytics.EnterAddressSource
internal interface SendDestinationClickIntents {
fun onRecipientAddressValueChange(value: String, type: EnterAddressSource)
fun onRecipientMemoValueChange(value: String, isValuePasted: Boolean = false)
fun onQrCodeScanClick()
}

View file

@ -0,0 +1,306 @@
package com.tangem.features.send.v2.subcomponents.destination.model
import androidx.compose.runtime.Stable
import arrow.core.getOrElse
import com.tangem.common.routing.AppRoute
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.navigation.Router
import com.tangem.domain.qrscanning.models.SourceType
import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase
import com.tangem.domain.qrscanning.usecases.ParseQrCodeUseCase
import com.tangem.domain.tokens.GetCryptoCurrencyUseCase
import com.tangem.domain.tokens.GetNetworkAddressesUseCase
import com.tangem.domain.transaction.usecase.IsUtxoConsolidationAvailableUseCase
import com.tangem.domain.transaction.usecase.ValidateWalletAddressUseCase
import com.tangem.domain.transaction.usecase.ValidateWalletMemoUseCase
import com.tangem.domain.txhistory.usecase.GetFixedTxHistoryItemsUseCase
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.send.v2.send.SendRoute
import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationUM
import com.tangem.features.send.v2.subcomponents.destination.SendDestinationComponent
import com.tangem.features.send.v2.subcomponents.destination.analytics.EnterAddressSource
import com.tangem.features.send.v2.subcomponents.destination.analytics.SendDestinationAnalyticEvents
import com.tangem.features.send.v2.subcomponents.destination.model.transformers.*
import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationWalletUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
import com.tangem.utils.transformer.update
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
@Stable
@ModelScoped
@Suppress("LongParameterList")
internal class SendDestinationModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val router: Router,
private val validateWalletAddressUseCase: ValidateWalletAddressUseCase,
private val validateWalletMemoUseCase: ValidateWalletMemoUseCase,
private val getWalletsUseCase: GetWalletsUseCase,
private val getCryptoCurrencyUseCase: GetCryptoCurrencyUseCase,
private val getNetworkAddressesUseCase: GetNetworkAddressesUseCase,
private val getFixedTxHistoryItemsUseCase: GetFixedTxHistoryItemsUseCase,
private val isUtxoConsolidationAvailableUseCase: IsUtxoConsolidationAvailableUseCase,
private val listenToQrScanningUseCase: ListenToQrScanningUseCase,
private val parseQrCodeUseCase: ParseQrCodeUseCase,
private val analyticsEventHandler: AnalyticsEventHandler,
) : Model(), SendDestinationClickIntents {
private val params: SendDestinationComponent.Params = paramsContainer.require()
private val _uiState = MutableStateFlow(params.state)
val uiState = _uiState.asStateFlow()
private val analyticsCategoryName = params.analyticsCategoryName
private val userWallet = params.userWallet
private val cryptoCurrencyStatus = params.cryptoCurrencyStatus
private val cryptoCurrency = cryptoCurrencyStatus.currency
private var validationJobHolder = JobHolder()
init {
configDestinationNavigation()
initialState()
getWalletsAndRecent()
subscribeOnQRScannerResult()
}
private fun initialState() {
if (uiState.value is DestinationUM.Empty) {
_uiState.update(
SendDestinationInitialStateTransformer(
cryptoCurrencyStatus = cryptoCurrencyStatus,
onAddressChange = ::onRecipientAddressValueChange,
onMemoChange = ::onRecipientMemoValueChange,
),
)
}
}
fun updateState(state: DestinationUM) {
if (state !is DestinationUM.Empty) {
_uiState.value = state
}
}
override fun onRecipientAddressValueChange(value: String, type: EnterAddressSource) {
_uiState.update(
SendDestinationAddressTransformer(
address = value,
isPasted = type.isPasted,
),
)
val memo = (uiState.value as? DestinationUM.Content)?.memoTextField?.value
validate(address = value, memo = memo, type)
}
override fun onRecipientMemoValueChange(value: String, isValuePasted: Boolean) {
_uiState.update(
SendDestinationMemoTransformer(
memo = value,
isPasted = isValuePasted,
),
)
val address = (uiState.value as? DestinationUM.Content)?.addressTextField?.value.orEmpty()
validate(address = address, memo = value)
}
override fun onQrCodeScanClick() {
analyticsEventHandler.send(SendDestinationAnalyticEvents.QrCodeButtonClicked(analyticsCategoryName))
router.push(
AppRoute.QrScanning(
source = AppRoute.QrScanning.Source.SEND,
networkName = cryptoCurrency.network.name,
),
)
}
private fun subscribeOnQRScannerResult() {
listenToQrScanningUseCase(SourceType.SEND)
.getOrElse { emptyFlow() }
.onEach(::onQrCodeScanned)
.launchIn(modelScope)
}
private fun onQrCodeScanned(address: String) {
parseQrCodeUseCase(address, cryptoCurrency).fold(
ifRight = { parsedCode ->
onRecipientAddressValueChange(parsedCode.address, EnterAddressSource.QRCode)
parsedCode.memo?.let { onRecipientMemoValueChange(it) }
},
ifLeft = {
onRecipientAddressValueChange(address, EnterAddressSource.QRCode)
},
)
}
private fun getWalletsAndRecent() {
combine(
flow = getWalletsUseCase().conflate().map {
it.toAvailableWallets()
},
flow2 = getFixedTxHistoryItemsUseCase(
userWalletId = userWallet.walletId,
currency = cryptoCurrency,
pageSize = RECENT_TX_SIZE,
).getOrElse { flowOf(emptyList()) }.conflate(),
) { destinationWalletList, txHistoryList ->
val isUtxoConsolidationAvailable = isUtxoConsolidationAvailableUseCase.invokeSync(
userWalletId = userWallet.walletId,
network = cryptoCurrency.network,
)
_uiState.update(
SendDestinationRecentListTransformer(
cryptoCurrencyStatus = cryptoCurrencyStatus,
isUtxoConsolidationAvailable = isUtxoConsolidationAvailable,
destinationWalletList = destinationWalletList,
txHistoryList,
),
)
}.launchIn(modelScope)
}
private suspend fun List<UserWallet>.toAvailableWallets(): List<DestinationWalletUM> {
return coroutineScope {
val cryptoCurrencyNetwork = cryptoCurrency.network
return@coroutineScope filterNot { it.isLocked }
.map { wallet ->
async {
val addresses = if (!wallet.isMultiCurrency) {
getCryptoCurrencyUseCase(wallet.walletId).getOrNull()?.let {
if (it.network.id == cryptoCurrencyNetwork.id) {
getNetworkAddressesUseCase.invokeSync(wallet.walletId, it.network)
} else {
null
}
}
} else {
getNetworkAddressesUseCase.invokeSync(wallet.walletId, cryptoCurrencyNetwork)
}
wallet to addresses
}
}.awaitAll()
.asSequence()
.mapNotNull { (wallet, addresses) ->
addresses?.map { (cryptoCurrency, address) ->
DestinationWalletUM(
name = wallet.name,
address = address,
cryptoCurrency = cryptoCurrency,
userWalletId = wallet.walletId,
)
}
}.flatten()
.toList()
}
}
private fun validate(address: String, memo: String?, type: EnterAddressSource? = null) {
modelScope.launch {
_uiState.update(SendDestinationValidationStartedTransformer)
val addressValidationResult = validateWalletAddressUseCase(
userWalletId = userWallet.walletId,
network = cryptoCurrency.network,
address = address,
currencyAddress = cryptoCurrencyStatus.value.networkAddress?.availableAddresses,
)
val memoValidationResult = validateWalletMemoUseCase(
memo = memo.orEmpty(),
network = cryptoCurrency.network,
)
type?.let {
analyticsEventHandler.send(
SendDestinationAnalyticEvents.AddressEntered(
categoryName = analyticsCategoryName,
source = it,
isValid = addressValidationResult.isRight(),
),
)
}
_uiState.update(SendDestinationValidationResultTransformer(addressValidationResult, memoValidationResult))
autoNextFromRecipient(type, addressValidationResult.isRight(), memoValidationResult.isRight())
}.saveIn(validationJobHolder)
}
private fun autoNextFromRecipient(type: EnterAddressSource?, isValidAddress: Boolean, isValidMemo: Boolean) {
val isRecent = type == EnterAddressSource.RecentAddress
if (isRecent && isValidAddress && isValidMemo) onNextClick()
}
private fun saveResult() {
params.callback.onDestinationResult(uiState.value)
}
private fun onNextClick() {
saveResult()
if (params.isEditMode) {
router.pop()
} else {
router.push(SendRoute.Amount(isEditMode = false))
}
}
private fun configDestinationNavigation() {
combine(
flow = uiState,
flow2 = params.currentRoute,
transform = { state, route -> state to route },
).onEach {
// todo
// params.callback.onNavigationResult(
// NavigationUM.Content(
// title = resourceReference(R.string.send_recipient_label),
// subtitle = null,
// backIconRes = if (route.isEditMode) {
// R.drawable.ic_back_24
// } else {
// R.drawable.ic_close_24
// },
// backIconClick = {
// if (route.isEditMode) {
// router.pop()
// } else {
// appRouter.pop()
// }
// },
// additionalIconRes = R.drawable.ic_qrcode_scan_24,
// additionalIconClick = {
// router.push(
// AppRoute.QrScanning(
// source = AppRoute.QrScanning.Source.SEND,
// networkName = cryptoCurrency.network.name,
// ),
// )
// },
// primaryButton = ButtonsUM.PrimaryButtonUM(
// text = if (route.isEditMode) {
// resourceReference(R.string.common_continue)
// } else {
// resourceReference(R.string.common_next)
// },
// isEnabled = state.isPrimaryButtonEnabled,
// onClick = ::onNextClick,
// ),
// prevButton = null,
// secondaryPairButtonsUM = null,
// ),
// )
}.launchIn(modelScope)
}
private companion object {
const val RECENT_TX_SIZE = 100
}
}

View file

@ -0,0 +1,92 @@
package com.tangem.features.send.v2.subcomponents.destination.model.converters
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.utils.DateTimeFormatters
import com.tangem.core.ui.utils.toDateFormatWithTodayYesterday
import com.tangem.core.ui.utils.toTimeFormat
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.txhistory.models.TxHistoryItem
import com.tangem.features.send.v2.impl.R
import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationRecipientListUM
import com.tangem.features.send.v2.subcomponents.destination.model.transformers.RECENT_DEFAULT_COUNT
import com.tangem.features.send.v2.subcomponents.destination.model.transformers.RECENT_KEY_TAG
import com.tangem.features.send.v2.subcomponents.destination.model.transformers.emptyListState
import com.tangem.utils.converter.Converter
import com.tangem.utils.extensions.isZero
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
internal class SendRecipientHistoryListConverter(
private val cryptoCurrency: CryptoCurrency,
) : Converter<List<TxHistoryItem>, ImmutableList<DestinationRecipientListUM>> {
override fun convert(value: List<TxHistoryItem>): ImmutableList<DestinationRecipientListUM> {
return value.filterRecipients(cryptoCurrency).ifEmpty {
emptyListState(RECENT_KEY_TAG, RECENT_DEFAULT_COUNT)
}
}
private fun List<TxHistoryItem>.filterRecipients(cryptoCurrency: CryptoCurrency) = this.filter { item ->
val isTransfer = item.type == TxHistoryItem.TransactionType.Transfer
val isNotContract = item.interactionAddressType is TxHistoryItem.InteractionAddressType.User
val isSingleAddress = if (item.isOutgoing) {
item.destinationType is TxHistoryItem.DestinationType.Single
} else {
item.sourceType is TxHistoryItem.SourceType.Single
}
val notZero = !item.amount.isZero()
isTransfer && isSingleAddress && isNotContract && item.isOutgoing && notZero
}
.take(RECENT_LIST_SIZE)
.mapIndexed { index, tx ->
DestinationRecipientListUM(
id = "${RECENT_KEY_TAG}$index",
title = tx.extractAddress(),
subtitle = stringReference(tx.getAmount(cryptoCurrency).trim()),
timestamp = tx.extractTimestamp(),
subtitleEndOffset = cryptoCurrency.symbol.length,
subtitleIconRes = tx.extractIconRes(),
)
}.toPersistentList()
private fun TxHistoryItem.extractAddress(): TextReference = if (isOutgoing) {
when (val destination = destinationType) {
is TxHistoryItem.DestinationType.Multiple -> resourceReference(
R.string.transaction_history_multiple_addresses,
)
is TxHistoryItem.DestinationType.Single -> stringReference(destination.addressType.address)
}
} else {
when (val source = sourceType) {
is TxHistoryItem.SourceType.Multiple -> resourceReference(R.string.transaction_history_multiple_addresses)
is TxHistoryItem.SourceType.Single -> stringReference(source.address)
}
}
private fun TxHistoryItem.extractIconRes() = if (isOutgoing) {
R.drawable.ic_arrow_up_24
} else {
R.drawable.ic_arrow_down_24
}
private fun TxHistoryItem.getAmount(cryptoCurrency: CryptoCurrency): String {
return amount.format { crypto(cryptoCurrency) }
}
private fun TxHistoryItem.extractTimestamp(): TextReference {
val date = timestampInMillis.toDateFormatWithTodayYesterday(
formatter = DateTimeFormatters.dateDDMMYYYY,
)
val time = timestampInMillis.toTimeFormat()
return TextReference.Res(R.string.send_date_format, wrappedList(date, time))
}
companion object {
private const val RECENT_LIST_SIZE = 10
}
}

View file

@ -0,0 +1,67 @@
package com.tangem.features.send.v2.subcomponents.destination.model.converters
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationRecipientListUM
import com.tangem.features.send.v2.subcomponents.destination.model.transformers.WALLET_DEFAULT_COUNT
import com.tangem.features.send.v2.subcomponents.destination.model.transformers.WALLET_KEY_TAG
import com.tangem.features.send.v2.subcomponents.destination.model.transformers.emptyListState
import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationWalletUM
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.toPersistentList
internal class SendRecipientWalletListConverter(
private val cryptoCurrencyStatus: CryptoCurrencyStatus,
private val isUtxoConsolidationAvailable: Boolean,
) :
Converter<List<DestinationWalletUM?>, PersistentList<DestinationRecipientListUM>> {
override fun convert(value: List<DestinationWalletUM?>): PersistentList<DestinationRecipientListUM> {
return value.filterWallets().ifEmpty {
emptyListState(WALLET_KEY_TAG, WALLET_DEFAULT_COUNT)
}
}
private fun List<DestinationWalletUM?>.filterWallets(): PersistentList<DestinationRecipientListUM> {
var walletsCounter = 0
val currentAddress: String = runCatching {
cryptoCurrencyStatus.value.networkAddress?.defaultAddress?.value
}.getOrNull().orEmpty()
return this.filterNotNull()
.filter {
val isCoin = it.cryptoCurrency is CryptoCurrency.Coin
val isNotSameAddress = it.address != currentAddress
val isNotBlankAddress = it.address.isNotBlank()
isNotBlankAddress && isCoin && (isNotSameAddress || isUtxoConsolidationAvailable)
}
.groupBy { item -> item.name }
.values.map { wallets ->
val groupedByWallet = wallets.groupBy { it.userWalletId }
var i = 0
groupedByWallet
.flatMap { item ->
item.value.map { wallet ->
val name = if (groupedByWallet.size > 1) {
"${wallet.name} ${++i}"
} else {
wallet.name
}
DestinationRecipientListUM(
id = "${WALLET_KEY_TAG}${walletsCounter++}",
title = stringReference(wallet.address),
subtitle = stringReference(name),
address = wallet.address,
userWalletId = wallet.userWalletId,
network = wallet.cryptoCurrency.network,
)
}
}
}
.flatten()
.toPersistentList()
}
}

View file

@ -0,0 +1,32 @@
package com.tangem.features.send.v2.subcomponents.destination.model.transformers
import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationRecipientListUM
import kotlinx.collections.immutable.toPersistentList
internal const val WALLET_DEFAULT_COUNT = 1
internal const val RECENT_DEFAULT_COUNT = 3
internal const val WALLET_KEY_TAG = "wallet"
internal const val RECENT_KEY_TAG = "recent"
internal fun loadingListState(tag: String, count: Int) = buildList {
repeat(count) {
add(
DestinationRecipientListUM(
id = "$tag$it",
isLoading = true,
),
)
}
}.toPersistentList()
internal fun emptyListState(tag: String, count: Int) = buildList {
repeat(count) {
add(
DestinationRecipientListUM(
id = "$tag$it",
isLoading = false,
isVisible = false,
),
)
}
}.toPersistentList()

View file

@ -0,0 +1,18 @@
package com.tangem.features.send.v2.subcomponents.destination.model.transformers
import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationUM
import com.tangem.utils.transformer.Transformer
internal class SendDestinationAddressTransformer(
private val address: String,
private val isPasted: Boolean,
) : Transformer<DestinationUM> {
override fun transform(prevState: DestinationUM): DestinationUM {
val state = prevState as? DestinationUM.Content ?: return prevState
return state.copy(
addressTextField = state.addressTextField.copy(value = address, isValuePasted = isPasted),
)
}
}

View file

@ -0,0 +1,62 @@
package com.tangem.features.send.v2.subcomponents.destination.model.transformers
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.Network
import com.tangem.features.send.v2.impl.R
import com.tangem.features.send.v2.subcomponents.destination.analytics.EnterAddressSource
import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationTextFieldUM
import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationUM
import com.tangem.utils.transformer.Transformer
internal class SendDestinationInitialStateTransformer(
val cryptoCurrencyStatus: CryptoCurrencyStatus,
val onAddressChange: (String, EnterAddressSource) -> Unit,
val onMemoChange: (String, Boolean) -> Unit,
) : Transformer<DestinationUM> {
override fun transform(prevState: DestinationUM): DestinationUM {
val memoType = when (cryptoCurrencyStatus.currency.network.transactionExtrasType) {
Network.TransactionExtrasType.NONE -> null
Network.TransactionExtrasType.MEMO -> R.string.send_extras_hint_memo
Network.TransactionExtrasType.DESTINATION_TAG -> R.string.send_destination_tag_field
}
return DestinationUM.Content(
isPrimaryButtonEnabled = false,
addressTextField = DestinationTextFieldUM.RecipientAddress(
value = "",
onValueChange = { onAddressChange(it, EnterAddressSource.InputField) },
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Next,
keyboardType = KeyboardType.Text,
),
error = null,
placeholder = resourceReference(R.string.send_enter_address_field),
label = resourceReference(R.string.send_recipient),
isValuePasted = false,
),
memoTextField = memoType?.let {
DestinationTextFieldUM.RecipientMemo(
value = "",
onValueChange = { onMemoChange(it, false) },
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done,
keyboardType = KeyboardType.Text,
),
placeholder = resourceReference(R.string.send_optional_field),
label = resourceReference(memoType),
error = resourceReference(R.string.send_memo_destination_tag_error),
disabledText = resourceReference(R.string.send_additional_field_already_included),
isEnabled = true,
isValuePasted = false,
)
},
wallets = loadingListState(WALLET_KEY_TAG, WALLET_DEFAULT_COUNT),
recent = loadingListState(RECENT_KEY_TAG, RECENT_DEFAULT_COUNT),
networkName = cryptoCurrencyStatus.currency.network.name,
isValidating = false,
)
}
}

View file

@ -0,0 +1,16 @@
package com.tangem.features.send.v2.subcomponents.destination.model.transformers
import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationUM
import com.tangem.utils.transformer.Transformer
internal class SendDestinationMemoTransformer(
private val memo: String,
private val isPasted: Boolean,
) : Transformer<DestinationUM> {
override fun transform(prevState: DestinationUM): DestinationUM {
val state = prevState as? DestinationUM.Content ?: return prevState
return state.copy(
memoTextField = state.memoTextField?.copy(value = memo, isValuePasted = isPasted),
)
}
}

View file

@ -0,0 +1,30 @@
package com.tangem.features.send.v2.subcomponents.destination.model.transformers
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.txhistory.models.TxHistoryItem
import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationUM
import com.tangem.features.send.v2.subcomponents.destination.model.converters.SendRecipientHistoryListConverter
import com.tangem.features.send.v2.subcomponents.destination.model.converters.SendRecipientWalletListConverter
import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationWalletUM
import com.tangem.utils.transformer.Transformer
internal class SendDestinationRecentListTransformer(
private val cryptoCurrencyStatus: CryptoCurrencyStatus,
private val isUtxoConsolidationAvailable: Boolean,
private val destinationWalletList: List<DestinationWalletUM>,
private val txHistoryList: List<TxHistoryItem>,
) : Transformer<DestinationUM> {
override fun transform(prevState: DestinationUM): DestinationUM {
val state = prevState as? DestinationUM.Content ?: return prevState
return state.copy(
wallets = SendRecipientWalletListConverter(
cryptoCurrencyStatus,
isUtxoConsolidationAvailable,
).convert(destinationWalletList),
recent = SendRecipientHistoryListConverter(
cryptoCurrency = cryptoCurrencyStatus.currency,
).convert(txHistoryList),
)
}
}

View file

@ -0,0 +1,49 @@
package com.tangem.features.send.v2.subcomponents.destination.model.transformers
import arrow.core.Either
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.transaction.error.AddressValidation
import com.tangem.domain.transaction.error.AddressValidationResult
import com.tangem.domain.transaction.error.ValidateMemoError
import com.tangem.features.send.v2.impl.R
import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationUM
import com.tangem.utils.transformer.Transformer
internal class SendDestinationValidationResultTransformer(
private val addressValidationResult: AddressValidationResult,
private val memoValidationResult: Either<ValidateMemoError, Unit>,
) : Transformer<DestinationUM> {
override fun transform(prevState: DestinationUM): DestinationUM {
val state = prevState as? DestinationUM.Content ?: return prevState
val isValidAddress = addressValidationResult.isRight()
val isValidMemo = memoValidationResult.isRight()
val addressErrorText = addressValidationResult.mapLeft {
when (it) {
is AddressValidation.Error.DataError,
AddressValidation.Error.InvalidAddress,
-> R.string.send_recipient_address_error
AddressValidation.Error.AddressInWallet -> R.string.send_error_address_same_as_wallet
}
}.leftOrNull()
return state.copy(
isValidating = false,
isPrimaryButtonEnabled = isValidAddress && isValidMemo,
addressTextField = state.addressTextField.copy(
error = addressErrorText?.let(::resourceReference),
isError = state.addressTextField.value.isNotEmpty() && !isValidAddress,
),
memoTextField = state.memoTextField?.copy(
isError = state.memoTextField.value.isNotEmpty() && !isValidMemo,
isEnabled = !shouldDisableMemo(),
),
)
}
/** Ripple X-Address contains memo, so memo field is unnecessary */
private fun shouldDisableMemo(): Boolean {
return addressValidationResult.isRight { it == AddressValidation.Success.ValidXAddress }
}
}

View file

@ -0,0 +1,12 @@
package com.tangem.features.send.v2.subcomponents.destination.model.transformers
import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationUM
import com.tangem.utils.transformer.Transformer
internal object SendDestinationValidationStartedTransformer : Transformer<DestinationUM> {
override fun transform(prevState: DestinationUM): DestinationUM {
val state = prevState as? DestinationUM.Content ?: return prevState
return state.copy(isValidating = true)
}
}

View file

@ -0,0 +1,245 @@
package com.tangem.features.send.v2.subcomponents.destination.ui
import android.content.res.Configuration
import androidx.annotation.DrawableRes
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.CircleShimmer
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.atoms.text.EllipsisText
import com.tangem.core.ui.components.atoms.text.TextEllipsis
import com.tangem.core.ui.components.icons.identicon.IdentIcon
import com.tangem.core.ui.extensions.rememberHapticFeedback
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.send.v2.impl.R
/**
* Row item with title and subtitle
*
* @param title title
* @param subtitle subtitle
* @param onClick click listener
* @param modifier modifier
* @param info info
* @param subtitleEndOffset offset for subtitle ellipsis
* @param subtitleIconRes icon
*/
@Composable
fun ListItemWithIcon(
title: String,
subtitle: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
info: String? = null,
subtitleEndOffset: Int = 0,
@DrawableRes subtitleIconRes: Int? = null,
isLoading: Boolean = false,
) {
AnimatedContent(
targetState = isLoading,
label = "Recent List Content Animation",
transitionSpec = { fadeIn().togetherWith(fadeOut()) },
) { isLoadingState ->
if (isLoadingState) {
ListItemLoading(modifier = modifier)
} else {
ListItemWithIcon(
title = title,
subtitle = subtitle,
onClick = onClick,
info = info,
subtitleEndOffset = subtitleEndOffset,
subtitleIconRes = subtitleIconRes,
modifier = modifier,
)
}
}
}
@Composable
private fun ListItemWithIcon(
title: String,
subtitle: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
info: String? = null,
subtitleEndOffset: Int = 0,
@DrawableRes subtitleIconRes: Int? = null,
) {
val hapticFeedback = rememberHapticFeedback(state = title, onAction = onClick)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.fillMaxWidth()
.clickable { hapticFeedback() }
.padding(horizontal = 12.dp),
) {
IdentIcon(
address = title,
modifier = Modifier
.padding(vertical = 8.dp)
.size(40.dp)
.clip(RoundedCornerShape(20.dp)),
)
Column(
modifier = Modifier
.height(36.dp)
.padding(start = 12.dp),
verticalArrangement = Arrangement.SpaceBetween,
) {
EllipsisText(
text = title,
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Justify,
ellipsis = TextEllipsis.Middle,
modifier = Modifier,
)
Row {
if (subtitleIconRes != null) {
Icon(
painter = painterResource(id = subtitleIconRes),
contentDescription = null,
tint = TangemTheme.colors.icon.informative,
modifier = Modifier
.padding(end = 2.dp)
.size(16.dp)
.background(TangemTheme.colors.background.tertiary, CircleShape)
.padding(2.dp),
)
}
val (text, offset) = remember(subtitle, info) {
if (info != null) {
val suffix = ", $info"
subtitle + suffix to suffix.length + subtitleEndOffset
} else {
subtitle to 0
}
}
EllipsisText(
text = text,
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
ellipsis = TextEllipsis.OffsetEnd(offsetEnd = offset),
)
}
}
}
}
@Composable
private fun ListItemLoading(modifier: Modifier = Modifier) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 12.dp),
) {
CircleShimmer(
modifier = Modifier
.padding(vertical = 8.dp)
.size(40.dp),
)
Column(
modifier = Modifier
.height(36.dp)
.padding(start = 12.dp),
verticalArrangement = Arrangement.SpaceBetween,
) {
RectangleShimmer(
radius = 3.dp,
modifier = Modifier.size(
width = 70.dp,
height = 12.dp,
),
)
RectangleShimmer(
radius = 3.dp,
modifier = Modifier.size(
width = 52.dp,
height = 12.dp,
),
)
}
}
}
// region preview
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun ListItemWithIconPreview(
@PreviewParameter(ListItemWithIconPreviewProvider::class) config: ListItemWithIconPreviewConfig,
) {
TangemThemePreview {
ListItemWithIcon(
title = config.title,
subtitle = config.subtitle,
subtitleEndOffset = config.subtitleEndOffset,
subtitleIconRes = config.iconRes,
onClick = {},
isLoading = config.isLoading,
)
}
}
private data class ListItemWithIconPreviewConfig(
val title: String,
val subtitle: String,
val info: String? = null,
val subtitleEndOffset: Int = 0,
val iconRes: Int? = null,
val isLoading: Boolean = false,
)
private class ListItemWithIconPreviewProvider : CollectionPreviewParameterProvider<ListItemWithIconPreviewConfig>(
collection = listOf(
ListItemWithIconPreviewConfig(
title = "0x34B4492A412D84A6E606288f3Bd714b89135D4dE",
subtitle = "0.000000000000000000000000000000 BTC",
info = "0.0.0000 at 00:00",
subtitleEndOffset = "BTC".length,
iconRes = R.drawable.ic_arrow_down_24,
),
ListItemWithIconPreviewConfig(
title = "0x34B4492A412D84A6E606288f3Bd714b89135D4dE",
subtitle = "1 BTC",
info = "0.0.0000 at 00:00",
subtitleEndOffset = "BTC".length,
iconRes = R.drawable.ic_arrow_down_24,
),
ListItemWithIconPreviewConfig(
title = "0x34B4492A412D84A6E606288f3Bd714b89135D4dE",
subtitle = "Wallet",
),
ListItemWithIconPreviewConfig(
title = "0x34B4492A412D84A6E606288f3Bd714b89135D4dE",
subtitle = "0.000000000000000000000000000000 BTC",
info = "0.0.0000 at 00:00",
subtitleEndOffset = "BTC".length,
iconRes = R.drawable.ic_arrow_down_24,
isLoading = true,
),
),
)
//endregion

View file

@ -0,0 +1,248 @@
package com.tangem.features.send.v2.subcomponents.destination.ui
import androidx.annotation.StringRes
import androidx.compose.animation.*
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.containers.FooterContainer
import com.tangem.core.ui.components.inputrow.InputRowRecipient
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.send.v2.impl.R
import com.tangem.features.send.v2.subcomponents.destination.analytics.EnterAddressSource
import com.tangem.features.send.v2.subcomponents.destination.model.SendDestinationClickIntents
import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationRecipientListUM
import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationTextFieldUM
import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationUM
import kotlinx.collections.immutable.ImmutableList
private const val ADDRESS_FIELD_KEY = "ADDRESS_FIELD_KEY"
private const val MEMO_FIELD_KEY = "MEMO_FIELD_KEY"
@Composable
internal fun SendDestinationContent(
state: DestinationUM,
clickIntents: SendDestinationClickIntents,
isBalanceHidden: Boolean,
) {
if (state !is DestinationUM.Content) return
val recipients = state.recent
val wallets = state.wallets
val memoField = state.memoTextField
val address = state.addressTextField
val isValidating by remember(state.isValidating) { derivedStateOf { state.isValidating } }
val isError by remember(address.isError) { derivedStateOf { address.isError } }
LazyColumn(
modifier = Modifier // Do not put fillMaxSize() in here
.background(TangemTheme.colors.background.tertiary)
.padding(
start = 16.dp,
end = 16.dp,
bottom = 16.dp,
),
) {
addressItem(
address = address,
networkName = state.networkName,
isError = isError,
isValidating = isValidating,
onAddressChange = clickIntents::onRecipientAddressValueChange,
)
memoField(
memoField = memoField,
onMemoChange = clickIntents::onRecipientMemoValueChange,
)
listHeaderItem(
titleRes = R.string.send_recipient_wallets_title,
isVisible = wallets.isNotEmpty() && wallets.first().isVisible,
isFirst = true,
)
listItem(
list = wallets,
clickIntents = clickIntents,
isLast = recipients.any { !it.isVisible },
isBalanceHidden = isBalanceHidden,
)
listHeaderItem(
titleRes = R.string.send_recent_transactions,
isVisible = recipients.isNotEmpty() && recipients.first().isVisible,
isFirst = wallets.any { !it.isVisible },
)
listItem(
list = recipients,
clickIntents = clickIntents,
isLast = true,
isBalanceHidden = isBalanceHidden,
)
}
}
private fun LazyListScope.addressItem(
address: DestinationTextFieldUM.RecipientAddress,
networkName: String,
isError: Boolean,
isValidating: Boolean,
onAddressChange: (String, EnterAddressSource) -> Unit,
) {
item(key = ADDRESS_FIELD_KEY) {
FooterContainer(
footer = resourceReference(R.string.send_recipient_address_footer, wrappedList(networkName)),
) {
InputRowRecipient(
value = address.value,
title = address.label,
placeholder = address.placeholder,
onValueChange = { onAddressChange(it, EnterAddressSource.InputField) },
onPasteClick = { onAddressChange(it, EnterAddressSource.PasteButton) },
isError = isError,
isLoading = isValidating,
error = address.error,
isValuePasted = address.isValuePasted,
modifier = Modifier
.background(
color = TangemTheme.colors.background.action,
shape = TangemTheme.shapes.roundedCornersXMedium,
),
)
}
}
}
private fun LazyListScope.memoField(
memoField: DestinationTextFieldUM.RecipientMemo?,
onMemoChange: (String, Boolean) -> Unit,
) {
if (memoField != null) {
item(key = MEMO_FIELD_KEY) {
val placeholder = if (memoField.isEnabled) memoField.placeholder else memoField.disabledText
TextFieldWithPaste(
value = memoField.value,
label = memoField.label,
placeholder = placeholder,
footer = resourceReference(R.string.send_recipient_memo_footer),
onValueChange = { onMemoChange(it, false) },
onPasteClick = { onMemoChange(it, true) },
modifier = Modifier.padding(top = 20.dp),
labelStyle = TangemTheme.typography.subtitle2,
isError = memoField.isError,
error = memoField.error,
isReadOnly = !memoField.isEnabled,
isValuePasted = memoField.isValuePasted,
)
}
}
}
private fun LazyListScope.listHeaderItem(@StringRes titleRes: Int, isVisible: Boolean, isFirst: Boolean) {
item(key = titleRes) {
AnimateRecentAppearance(isVisible) {
val (topPadding, paddingFromTop) = if (isFirst) {
20.dp to 12.dp
} else {
0.dp to 8.dp
}
val topRadius = if (isFirst) 16.dp else 0.dp
Text(
text = stringResourceSafe(titleRes),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
modifier = Modifier
.fillMaxWidth()
.padding(top = topPadding)
.clip(
RoundedCornerShape(
topEnd = topRadius,
topStart = topRadius,
),
)
.background(TangemTheme.colors.background.action)
.padding(
top = paddingFromTop,
bottom = 12.dp,
start = 12.dp,
end = 12.dp,
),
)
}
}
}
private fun LazyListScope.listItem(
list: ImmutableList<DestinationRecipientListUM>,
clickIntents: SendDestinationClickIntents,
isLast: Boolean,
isBalanceHidden: Boolean,
) {
items(
count = list.size,
key = { list[it].id },
contentType = { list[it]::class.java },
) { index ->
val item = list[index]
val title = item.title.resolveReference()
if (item.isVisible) {
ListItemWithIcon(
title = title,
subtitle = item.subtitle.orMaskWithStars(isBalanceHidden).resolveReference(),
info = item.timestamp?.resolveReference(),
subtitleEndOffset = item.subtitleEndOffset,
subtitleIconRes = item.subtitleIconRes,
onClick = {
clickIntents.onRecipientAddressValueChange(
title,
EnterAddressSource.RecentAddress,
)
},
isLoading = item.isLoading,
modifier = Modifier
.animateItem()
.then(
if (isLast && index == list.lastIndex) {
Modifier
.clip(
shape = RoundedCornerShape(
bottomStart = 16.dp,
bottomEnd = 16.dp,
),
)
} else {
Modifier
},
)
.background(TangemTheme.colors.background.action),
)
}
}
}
@Composable
private fun AnimateRecentAppearance(isVisible: Boolean, content: @Composable () -> Unit) {
AnimatedContent(
targetState = isVisible,
label = "Item Appearance Animation",
transitionSpec = {
(slideInHorizontally() + fadeIn())
.togetherWith(slideOutVertically() + fadeOut())
},
) {
if (it) {
content()
} else {
Box(modifier = Modifier.fillMaxWidth())
}
}
}

View file

@ -0,0 +1,103 @@
package com.tangem.features.send.v2.subcomponents.destination.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment.Companion.CenterEnd
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.fields.SimpleTextField
import com.tangem.core.ui.components.inputrow.inner.CrossIcon
import com.tangem.core.ui.components.inputrow.inner.PasteButton
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.components.containers.FooterContainer
@Composable
internal fun TextFieldWithPaste(
value: String,
placeholder: TextReference,
label: TextReference,
onValueChange: (String) -> Unit,
onPasteClick: (String) -> Unit,
modifier: Modifier = Modifier,
footer: TextReference? = null,
labelStyle: TextStyle = TangemTheme.typography.body2,
error: TextReference? = null,
isError: Boolean = false,
isReadOnly: Boolean = false,
isValuePasted: Boolean = false,
) {
val (title, color) = when {
isError && error != null -> error to TangemTheme.colors.text.warning
isReadOnly -> label to TangemTheme.colors.text.tertiary
else -> label to TangemTheme.colors.text.secondary
}
val placeholderColor = if (isReadOnly) TangemTheme.colors.text.tertiary else TangemTheme.colors.text.disabled
FooterContainer(modifier, footer) {
Box(
modifier = Modifier
.background(
color = TangemTheme.colors.background.action,
shape = TangemTheme.shapes.roundedCornersXMedium,
)
.padding(end = 12.dp),
) {
Row {
Column(
modifier = Modifier
.weight(1f)
.padding(12.dp),
) {
Text(
text = title.resolveReference(),
style = labelStyle,
color = color,
)
SimpleTextField(
value = value,
placeholder = placeholder,
placeholderColor = placeholderColor,
onValueChange = onValueChange,
readOnly = isReadOnly,
isValuePasted = isValuePasted,
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
)
}
AnimatedVisibility(
visible = !isReadOnly,
label = "Animate read only status change",
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier
.align(CenterVertically),
) {
CrossIcon(
onClick = onPasteClick,
)
}
}
AnimatedVisibility(
visible = !isReadOnly,
label = "Animate read only status change",
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.align(CenterEnd),
) {
PasteButton(
isPasteButtonVisible = value.isBlank(),
onClick = onPasteClick,
)
}
}
}
}

View file

@ -0,0 +1,22 @@
package com.tangem.features.send.v2.subcomponents.destination.ui.state
import androidx.annotation.DrawableRes
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.wallets.models.UserWalletId
@Immutable
internal data class DestinationRecipientListUM(
val id: String,
val title: TextReference = TextReference.Companion.EMPTY,
val subtitle: TextReference = TextReference.Companion.EMPTY,
val timestamp: TextReference? = null,
val subtitleEndOffset: Int = 0,
@DrawableRes val subtitleIconRes: Int? = null,
val isVisible: Boolean = true,
val isLoading: Boolean = false,
val userWalletId: UserWalletId? = null,
val network: Network? = null,
val address: String? = null,
)

View file

@ -0,0 +1,42 @@
package com.tangem.features.send.v2.subcomponents.destination.ui.state
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.extensions.TextReference
@Immutable
internal sealed class DestinationTextFieldUM {
/** Current value */
abstract val value: String
/** Lambda be invoked when value is been changed */
abstract val onValueChange: (String) -> Unit
/** Keyboard options */
abstract val keyboardOptions: KeyboardOptions
data class RecipientAddress(
override val value: String,
override val onValueChange: (String) -> Unit,
override val keyboardOptions: KeyboardOptions,
val placeholder: TextReference,
val label: TextReference,
val isError: Boolean = false,
val error: TextReference? = null,
val isValuePasted: Boolean,
) : DestinationTextFieldUM()
data class RecipientMemo(
override val value: String,
override val onValueChange: (String) -> Unit,
override val keyboardOptions: KeyboardOptions,
val placeholder: TextReference,
val label: TextReference,
val isError: Boolean = false,
val error: TextReference? = null,
val disabledText: TextReference,
val isEnabled: Boolean,
val isValuePasted: Boolean,
) : DestinationTextFieldUM()
}

View file

@ -0,0 +1,25 @@
package com.tangem.features.send.v2.subcomponents.destination.ui.state
import androidx.compose.runtime.Immutable
import kotlinx.collections.immutable.ImmutableList
@Immutable
internal sealed class DestinationUM {
abstract val isPrimaryButtonEnabled: Boolean
data class Content(
override val isPrimaryButtonEnabled: Boolean,
val addressTextField: DestinationTextFieldUM.RecipientAddress,
val memoTextField: DestinationTextFieldUM.RecipientMemo?,
val recent: ImmutableList<DestinationRecipientListUM>,
val wallets: ImmutableList<DestinationRecipientListUM>,
val networkName: String,
val isValidating: Boolean = false,
val isEditingDisabled: Boolean = false,
) : DestinationUM()
data class Empty(
override val isPrimaryButtonEnabled: Boolean = false,
) : DestinationUM()
}

View file

@ -0,0 +1,21 @@
package com.tangem.features.send.v2.subcomponents.destination.ui.state
import androidx.compose.runtime.Immutable
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.wallets.models.UserWalletId
/**
* Available wallet to send
*
* @property name wallet name
* @property userWalletId wallet id
* @property address blockchain address
* @property cryptoCurrency selected crypto currency
*/
@Immutable
data class DestinationWalletUM(
val name: String,
val userWalletId: UserWalletId,
val address: String,
val cryptoCurrency: CryptoCurrency,
)