Updated on 2026-08-14

This commit is contained in:
Tangem 2023-11-22 12:48:29 +03:00
commit a1b910cc7e
149 changed files with 4442 additions and 1330 deletions

View file

@ -0,0 +1,42 @@
package com.tangem.managetokens.presentation.common.state
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.features.managetokens.impl.R
internal sealed class AlertState {
abstract val message: TextReference
class DefaultAlert(
override val message: TextReference,
) : AlertState()
class TokenUnavailable(
val onUpvoteClick: () -> Unit,
) : AlertState() {
override val message: TextReference = resourceReference(R.string.manage_tokens_unavailable_description)
val confirmButtonText: TextReference = resourceReference(R.string.common_close)
val dismissButtonText: TextReference = resourceReference(R.string.manage_tokens_unavailable_vote)
}
object NonNative : AlertState() {
override val message: TextReference = resourceReference(R.string.manage_tokens_network_selector_non_native_info)
}
object TokensUnsupported : AlertState() {
override val message: TextReference = resourceReference(R.string.alert_manage_tokens_unsupported_message)
}
object TokensUnsupportedCurve : AlertState() {
override val message: TextReference = resourceReference(R.string.alert_manage_tokens_unsupported_curve_message)
}
class TokensUnsupportedBlockchainByCard(val token: String) : AlertState() {
override val message: TextReference = resourceReference(
id = R.string.alert_manage_tokens_unsupported_blockchain_by_card_message,
formatArgs = wrappedList(token),
)
}
}

View file

@ -0,0 +1,8 @@
package com.tangem.managetokens.presentation.common.state
import androidx.compose.runtime.Immutable
@Immutable
internal sealed interface Event {
data class ShowAlert(val state: AlertState) : Event
}

View file

@ -0,0 +1,18 @@
package com.tangem.managetokens.presentation.common.ui
import androidx.compose.runtime.Composable
import com.tangem.core.ui.event.StateEvent
import com.tangem.managetokens.presentation.common.state.AlertState
import com.tangem.managetokens.presentation.common.state.Event
@Composable
internal fun EventEffect(event: StateEvent<Event>, onAlertStateSet: (AlertState) -> Unit) {
com.tangem.core.ui.event.EventEffect(
event = event,
onTrigger = { value ->
when (value) {
is Event.ShowAlert -> onAlertStateSet(value.state)
}
},
)
}

View file

@ -0,0 +1,50 @@
package com.tangem.managetokens.presentation.common.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.tangem.core.ui.components.BasicDialog
import com.tangem.core.ui.components.DialogButton
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.features.managetokens.impl.R
import com.tangem.managetokens.presentation.common.state.AlertState
@Composable
internal fun Alert(state: AlertState, onDismiss: () -> Unit) {
when (state) {
is AlertState.DefaultAlert,
is AlertState.NonNative,
AlertState.TokensUnsupportedCurve,
AlertState.TokensUnsupported,
is AlertState.TokensUnsupportedBlockchainByCard,
-> DefaultAlert(state, onDismiss)
is AlertState.TokenUnavailable -> TokenUnavailableAlert(state, onDismiss)
}
}
@Composable
private fun DefaultAlert(state: AlertState, onDismiss: () -> Unit) {
BasicDialog(
message = state.message.resolveReference(),
confirmButton = DialogButton(
title = stringResource(id = R.string.common_ok),
onClick = onDismiss,
),
onDismissDialog = onDismiss,
)
}
@Composable
private fun TokenUnavailableAlert(state: AlertState.TokenUnavailable, onDismiss: () -> Unit) {
BasicDialog(
message = state.message.resolveReference(),
confirmButton = DialogButton(
title = state.confirmButtonText.resolveReference(),
onClick = onDismiss,
),
dismissButton = DialogButton(
title = state.dismissButtonText.resolveReference(),
onClick = { state.onUpvoteClick() },
),
onDismissDialog = onDismiss,
)
}

View file

@ -0,0 +1,11 @@
package com.tangem.managetokens.presentation.managetokens.state
/**
* SearchBar state.
*/
internal data class SearchBarState(
val query: String,
val onQueryChange: (String) -> Unit,
val active: Boolean,
val onActiveChange: (Boolean) -> Unit,
)

View file

@ -0,0 +1,143 @@
package com.tangem.managetokens.presentation.managetokens.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.managetokens.impl.R
import com.tangem.managetokens.presentation.managetokens.state.SearchBarState
@OptIn(ExperimentalComposeUiApi::class)
@Composable
internal fun TokensSearchBar(state: SearchBarState, modifier: Modifier = Modifier) {
val keyboardController = LocalSoftwareKeyboardController.current
val focusManager = LocalFocusManager.current
TextField(
value = state.query,
onValueChange = state.onQueryChange,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(
onSearch = {
keyboardController?.hide()
focusManager.clearFocus()
},
),
singleLine = true,
maxLines = 1,
textStyle = TangemTheme.typography.body2.copy(
color = TangemTheme.colors.text.primary1,
),
leadingIcon = {
Icon(
painter = painterResource(id = R.drawable.ic_search_24),
tint = TangemTheme.colors.icon.informative,
contentDescription = null,
modifier = Modifier.clickable { state.onActiveChange(true) },
)
},
trailingIcon = {
if (state.query.isNotEmpty() || state.active) {
IconButton(
onClick = {
if (state.query.isNotEmpty()) {
state.onQueryChange("")
}
focusManager.clearFocus()
keyboardController?.hide()
state.onActiveChange(false)
},
) {
Icon(
painter = painterResource(id = R.drawable.ic_close),
tint = TangemTheme.colors.icon.informative,
contentDescription = null,
)
}
}
},
placeholder = {
Text(
text = stringResource(R.string.manage_tokens_search_placeholder),
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.body2,
)
},
shape = RoundedCornerShape(TangemTheme.dimens.radius36),
colors = searchbarTextFieldColors(),
modifier = modifier
.fillMaxWidth()
.onFocusChanged {
if (it.isFocused) {
state.onActiveChange(true)
} else {
state.onActiveChange(false)
}
},
)
}
@Composable
private fun searchbarTextFieldColors(): TextFieldColors {
return TextFieldDefaults.textFieldColors(
backgroundColor = TangemTheme.colors.field.primary,
textColor = TangemTheme.colors.text.primary1,
cursorColor = TangemTheme.colors.icon.primary1,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
)
}
@Preview
@Composable
private fun Preview_TokensSearchBar_Light(
@PreviewParameter(SearchBarkConfigProvider::class)
state: SearchBarState,
) {
TangemTheme(isDark = false) {
TokensSearchBar(state)
}
}
@Preview
@Composable
private fun Preview_TokensSearchBar_Dark(@PreviewParameter(SearchBarkConfigProvider::class) state: SearchBarState) {
TangemTheme(isDark = true) {
TokensSearchBar(state)
}
}
private class SearchBarkConfigProvider : CollectionPreviewParameterProvider<SearchBarState>(
collection = listOf(
SearchBarState(
query = "BTC",
onQueryChange = {},
active = true,
onActiveChange = {},
),
SearchBarState(
query = "",
onQueryChange = {},
active = false,
onActiveChange = {},
),
),
)

View file

@ -12,6 +12,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import coil.compose.SubcomposeAsyncImage
import coil.request.ImageRequest
@ -25,8 +26,7 @@ import kotlinx.coroutines.launch
@Composable
internal fun TokenIcon(state: TokenIconState, modifier: Modifier = Modifier) {
val iconModifier = modifier
.size(TangemTheme.dimens.size36)
val iconModifier = modifier.size(TangemTheme.dimens.size36)
if (state.iconReference != null) {
DefaultCurrencyIcon(
modifier = iconModifier,
@ -73,11 +73,14 @@ private inline fun DefaultCurrencyIcon(
crossinline errorIcon: @Composable () -> Unit,
modifier: Modifier = Modifier,
) {
val itemBackgroundColor = TangemTheme.colors.background.primary.toArgb()
var iconBackgroundColor by remember { mutableStateOf(Color.Transparent) }
var isBackgroundColorDefined by remember { mutableStateOf(false) }
val itemBackgroundColor = TangemTheme.colors.background.primary.toArgb()
val isDarkTheme = isSystemInDarkTheme()
val coroutineScope = rememberCoroutineScope()
val pixelsSize = with(LocalDensity.current) { TangemTheme.dimens.size36.roundToPx() }
SubcomposeAsyncImage(
modifier = modifier
.background(
@ -86,17 +89,21 @@ private inline fun DefaultCurrencyIcon(
),
model = ImageRequest.Builder(context = LocalContext.current)
.data(iconReference.getReference())
.size(size = pixelsSize)
.memoryCacheKey(key = iconReference.getReference().toString() + pixelsSize)
.crossfade(enable = true)
.allowHardware(false)
.listener(
onSuccess = { _, result ->
if (isDarkTheme) {
if (!isBackgroundColorDefined && isDarkTheme) {
coroutineScope.launch {
val color = ImageBackgroundContrastChecker(
drawable = result.drawable,
backgroundColor = itemBackgroundColor,
).getContrastColorIfNeeded(isDarkTheme)
size = pixelsSize,
).getContrastColor(isDarkTheme = true)
iconBackgroundColor = color
isBackgroundColorDefined = true
}
}
},

View file

@ -14,12 +14,14 @@ dependencies {
/** AndroidX */
implementation(deps.androidx.fragment.ktx)
implementation(deps.androidx.appCompat)
implementation(deps.androidx.paging.runtime)
/** Other dependencies */
implementation(deps.kotlin.immutable.collections)
implementation(deps.material)
implementation(deps.arrow.core)
implementation(deps.tangem.card.core)
implementation(deps.lifecycle.compose)
implementation(deps.jodatime)
/** Compose */
implementation(deps.compose.accompanist.systemUiController)
@ -29,6 +31,12 @@ dependencies {
implementation(deps.compose.ui.tooling)
implementation(deps.compose.navigation)
implementation(deps.compose.navigation.hilt)
implementation(deps.compose.paging)
implementation(deps.compose.constraintLayout)
/** Tangem SDKs */
implementation(deps.tangem.card.core)
implementation(deps.tangem.blockchain)
/** Common */
implementation(projects.common)
@ -40,12 +48,16 @@ dependencies {
/** Domain modules */
implementation(projects.domain.models)
implementation(projects.domain.legacy)
implementation(projects.domain.tokens)
implementation(projects.domain.tokens.models)
implementation(projects.domain.wallets)
implementation(projects.domain.wallets.models)
implementation(projects.domain.appCurrency)
implementation(projects.domain.appCurrency.models)
implementation(projects.domain.txhistory)
implementation(projects.domain.txhistory.models)
implementation(projects.domain.transaction)
/** Feature modules */
implementation(projects.features.send.api)

View file

@ -1,43 +1,53 @@
package com.tangem.features.send.impl.presentation
import androidx.activity.compose.BackHandler
import android.os.Bundle
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.fragment.app.viewModels
import com.tangem.core.ui.components.SystemBarsEffect
import com.tangem.core.ui.screen.ComposeBottomSheetFragment
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.screen.ComposeFragment
import com.tangem.core.ui.theme.AppThemeModeHolder
import com.tangem.features.send.impl.presentation.state.SendUiState
import com.tangem.features.send.impl.presentation.state.StateRouter
import com.tangem.features.send.impl.presentation.ui.SendScreen
import com.tangem.features.send.impl.presentation.viewmodel.SendViewModel
import dagger.hilt.android.AndroidEntryPoint
import java.lang.ref.WeakReference
import javax.inject.Inject
/**
* Send fragment
*/
@AndroidEntryPoint
internal class SendFragment : ComposeBottomSheetFragment() {
internal class SendFragment : ComposeFragment() {
@Inject
override lateinit var appThemeModeHolder: AppThemeModeHolder
override val expandedHeightFraction: Float = 1f
private val viewModel by viewModels<SendViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycle.addObserver(viewModel)
viewModel.setRouter(
StateRouter(
fragmentManager = WeakReference(parentFragmentManager),
),
)
}
@Composable
override fun ScreenContent(modifier: Modifier) {
val viewModel = hiltViewModel<SendViewModel>()
LocalLifecycleOwner.current.lifecycle.addObserver(viewModel)
SystemBarsEffect { setSystemBarsColor(color = Color.Transparent) }
BackHandler { dismiss() }
when (val state = viewModel.uiState) {
is SendUiState.Content -> SendScreen(state)
SendUiState.Dismiss -> dismiss()
val systemBarsColor = TangemTheme.colors.background.tertiary
SystemBarsEffect {
setSystemBarsColor(systemBarsColor)
}
SendScreen(viewModel.uiState)
}
override fun onDestroy() {
lifecycle.removeObserver(viewModel)
super.onDestroy()
}
companion object {

View file

@ -0,0 +1,15 @@
package com.tangem.features.send.impl.presentation.domain
import androidx.compose.runtime.Immutable
/**
* Available wallet to send
*
* @property name wallet name
* @property address blockchain address
*/
@Immutable
data class AvailableWallet(
val name: String,
val address: String,
)

View file

@ -0,0 +1,22 @@
package com.tangem.features.send.impl.presentation.domain
import androidx.annotation.DrawableRes
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.extensions.TextReference
import kotlinx.collections.immutable.PersistentList
@Immutable
internal sealed class SendRecipientListContent {
data class Item(
val id: String,
val title: TextReference,
val subtitle: TextReference,
val info: TextReference? = null,
@DrawableRes val subtitleIconRes: Int? = null,
) : SendRecipientListContent()
data class Wallets(
val list: PersistentList<Item>,
val isWalletsOnly: Boolean,
) : SendRecipientListContent()
}

View file

@ -1,59 +1,210 @@
package com.tangem.features.send.impl.presentation.state
import arrow.core.Either
import androidx.paging.PagingData
import com.tangem.blockchain.common.address.Address
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.common.Provider
import com.tangem.core.ui.components.currency.tokenicon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.error.CurrencyStatusError
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.txhistory.models.TxHistoryItem
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.features.send.impl.R
import com.tangem.features.send.impl.presentation.domain.AvailableWallet
import com.tangem.features.send.impl.presentation.state.amount.SendAmountStateConverter
import com.tangem.features.send.impl.presentation.state.fee.FeeSelectorState
import com.tangem.features.send.impl.presentation.state.fee.FeeType
import com.tangem.features.send.impl.presentation.state.fee.SendFeeCustomFieldConverter
import com.tangem.features.send.impl.presentation.state.fee.SendFeeStateConverter
import com.tangem.features.send.impl.presentation.state.fields.SendAmountFieldChangeConverter
import com.tangem.features.send.impl.presentation.state.fields.SendAmountFieldConverter
import com.tangem.features.send.impl.presentation.state.recipient.SendRecipientListConverter
import com.tangem.features.send.impl.presentation.state.recipient.SendRecipientStateConverter
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import com.tangem.features.send.impl.presentation.viewmodel.isNotAddressInWallet
import com.tangem.features.send.impl.presentation.viewmodel.validateMemo
import com.tangem.features.send.impl.presentation.viewmodel.verifyAddress
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
internal class SendStateFactory(
private val clickIntents: SendClickIntents,
private val currentStateProvider: Provider<SendUiState>,
private val userWalletProvider: Provider<UserWallet>,
private val walletAddressesProvider: Provider<Set<Address>>,
private val appCurrencyProvider: Provider<AppCurrency>,
private val userWalletProvider: Provider<UserWallet?>,
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
) {
private val iconStateConverter by lazy(::CryptoCurrencyToIconStateConverter)
private val amountFieldConverter by lazy { SendAmountFieldConverter(clickIntents) }
private val amountFieldChangeConverter by lazy { SendAmountFieldChangeConverter(currentStateProvider) }
private val amountStateConverter by lazy {
SendAmountStateConverter(
currentStateProvider = currentStateProvider,
appCurrencyProvider = appCurrencyProvider,
private val customFeeFieldConverter by lazy {
SendFeeCustomFieldConverter(
clickIntents = clickIntents,
iconStateConverter = iconStateConverter,
userWalletProvider = userWalletProvider,
sendAmountFieldConverter = amountFieldConverter,
appCurrencyProvider = appCurrencyProvider,
)
}
fun getInitialState(): SendUiState = SendUiState.Content.Initial(clickIntents = clickIntents)
fun getAmountState(cryptoCurrencyStatus: Either<CurrencyStatusError, CryptoCurrencyStatus>): SendUiState {
return amountStateConverter.convert(cryptoCurrencyStatus)
private val amountStateConverter by lazy {
SendAmountStateConverter(
appCurrencyProvider = appCurrencyProvider,
iconStateConverter = iconStateConverter,
userWalletProvider = userWalletProvider,
sendAmountFieldConverter = amountFieldConverter,
cryptoCurrencyStatusProvider = cryptoCurrencyStatusProvider,
)
}
private val recipientStateConverter by lazy {
SendRecipientStateConverter(
clickIntents = clickIntents,
cryptoCurrencyStatusProvider = cryptoCurrencyStatusProvider,
)
}
private val feeStateConverter by lazy {
SendFeeStateConverter(
cryptoCurrencyStatusProvider = cryptoCurrencyStatusProvider,
)
}
fun getOnReceiveState(): SendUiState = SendUiState.Content.Initial(clickIntents = clickIntents)
private val recipientListStateConverter by lazy {
SendRecipientListConverter(
currentStateProvider = currentStateProvider,
cryptoCurrencyStatusProvider = cryptoCurrencyStatusProvider,
)
}
// region UI states
fun getInitialState(): SendUiState = SendUiState(
clickIntents = clickIntents,
currentState = MutableStateFlow(SendUiStateType.Amount),
)
fun getReadyState(): SendUiState = currentStateProvider().copy(
amountState = amountStateConverter.convert(Unit),
recipientState = recipientStateConverter.convert(Unit),
feeState = feeStateConverter.convert(Unit),
)
//endregion
//region amount state clicks
fun getOnAmountValueChange(value: String) = amountFieldChangeConverter.convert(value)
fun getOnCurrencyChangedState(isFiat: Boolean): SendUiState {
val state = currentStateProvider()
val amountState = state as? SendUiState.Content.AmountState ?: return state
val amountState = state.amountState ?: return state
return if (amountState.isFiatValue == isFiat) {
state
} else {
return state.copy(isFiatValue = isFiat)
return state.copy(amountState = amountState.copy(isFiatValue = isFiat))
}
}
//endregion
//region recipient
fun onLoadedRecipientList(
wallets: List<AvailableWallet?>,
txHistory: PagingData<TxHistoryItem>,
txHistoryCount: Int,
) {
recipientListStateConverter.convert(
wallets = wallets,
txHistory = txHistory,
txHistoryCount = txHistoryCount,
)
}
fun getOnRecipientAddressValueChangeState(value: String): SendUiState {
val state = currentStateProvider()
val recipientState = state.recipientState ?: return state
val isValidMemo = validateMemo(
memo = value,
cryptoCurrency = cryptoCurrencyStatusProvider().currency,
)
val isAddressInWallet = isNotAddressInWallet(
walletAddresses = walletAddressesProvider(),
address = recipientState.addressTextField.value.value,
)
val isValidAddress = verifyAddress(
address = recipientState.addressTextField.value.value,
cryptoCurrency = cryptoCurrencyStatusProvider().currency,
)
recipientState.addressTextField.update {
it.copy(
value = value,
error = when {
!isValidAddress -> TextReference.Res(R.string.send_recipient_address_error)
!isAddressInWallet -> TextReference.Res(R.string.send_recipient_address_error)
else -> null
},
isError = !isValidAddress || !isAddressInWallet,
)
}
return state.copy(
recipientState = recipientState.copy(
isPrimaryButtonEnabled = isValidMemo && isValidAddress && isAddressInWallet,
),
)
}
fun getOnRecipientMemoValueChangeState(value: String): SendUiState {
val state = currentStateProvider()
val recipientState = state.recipientState ?: return state
val isValidMemo = validateMemo(
memo = value,
cryptoCurrency = cryptoCurrencyStatusProvider().currency,
)
val isAddressInWallet = isNotAddressInWallet(
walletAddresses = walletAddressesProvider(),
address = recipientState.addressTextField.value.value,
)
val isValidAddress = verifyAddress(
address = recipientState.addressTextField.value.value,
cryptoCurrency = cryptoCurrencyStatusProvider().currency,
)
// todo add memo validation error text
recipientState.memoTextField?.update {
it.copy(
value = value,
error = TextReference.Res(R.string.send_memo_destination_tag_error),
isError = !isValidMemo,
)
}
return state.copy(
recipientState = recipientState.copy(
isPrimaryButtonEnabled = isValidMemo && isValidAddress && isAddressInWallet,
),
)
}
fun onFeeOnLoadingState() {
currentStateProvider().feeState?.feeSelectorState?.update {
FeeSelectorState.Loading
}
}
fun onFeeOnLoadedState(fees: TransactionFee) {
currentStateProvider().feeState?.feeSelectorState?.update {
FeeSelectorState.Content(
fees = fees,
customValues = customFeeFieldConverter.convert(fees.normal),
)
}
}
//endregion
//region fee
fun onFeeSelectedState(feeType: FeeType) {
currentStateProvider().feeState?.feeSelectorState?.update {
(it as? FeeSelectorState.Content)?.copy(selectedFee = feeType) ?: it
}
}
//endregion
}

View file

@ -1,71 +1,81 @@
package com.tangem.features.send.impl.presentation.state
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.paging.PagingData
import com.tangem.core.ui.components.currency.tokenicon.TokenIconState
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.send.impl.presentation.domain.SendRecipientListContent
import com.tangem.features.send.impl.presentation.state.amount.SendAmountSegmentedButtonsConfig
import com.tangem.features.send.impl.presentation.state.fee.FeeSelectorState
import com.tangem.features.send.impl.presentation.state.fields.SendTextField
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import kotlinx.collections.immutable.PersistentList
import kotlinx.coroutines.flow.MutableStateFlow
/**
* Ui states of the send screen
*/
@Immutable
internal sealed class SendUiState {
internal data class SendUiState(
val clickIntents: SendClickIntents,
val amountState: SendStates.AmountState? = null,
val recipientState: SendStates.RecipientState? = null,
val feeState: SendStates.FeeState? = null,
val recipientList: MutableStateFlow<PagingData<SendRecipientListContent>> = MutableStateFlow(PagingData.empty()),
val currentState: MutableStateFlow<SendUiStateType>,
)
/** States with content */
sealed class Content : SendUiState() {
@Stable
internal sealed class SendStates {
/** Is primary button enabled */
abstract val isPrimaryButtonEnabled: Boolean
abstract val type: SendUiStateType
/** Click intents */
abstract val clickIntents: SendClickIntents
/** Amount state */
data class AmountState(
override val type: SendUiStateType = SendUiStateType.Amount,
val cryptoCurrencyStatus: CryptoCurrencyStatus,
val appCurrency: AppCurrency,
val walletName: String,
val walletBalance: String,
val tokenIconState: TokenIconState,
val isFiatValue: Boolean,
val segmentedButtonConfig: PersistentList<SendAmountSegmentedButtonsConfig>,
val amountTextField: MutableStateFlow<SendTextField.Amount>,
val isPrimaryButtonEnabled: Boolean,
) : SendStates()
/** Initial state */
data class Initial(
override val isPrimaryButtonEnabled: Boolean = false,
override val clickIntents: SendClickIntents,
) : Content()
/** Recipient state */
data class RecipientState(
override val type: SendUiStateType = SendUiStateType.Recipient,
val addressTextField: MutableStateFlow<SendTextField.RecipientAddress>,
val memoTextField: MutableStateFlow<SendTextField.RecipientMemo>?,
val recipients: MutableStateFlow<PagingData<SendRecipientListContent>> = MutableStateFlow(PagingData.empty()),
val network: String,
val isPrimaryButtonEnabled: Boolean,
) : SendStates()
/** Amount state */
data class AmountState(
override val isPrimaryButtonEnabled: Boolean = false,
override val clickIntents: SendClickIntents,
val walletName: String,
val walletBalance: String,
val tokenIconState: TokenIconState,
val cryptoCurrencyStatus: CryptoCurrencyStatus,
val appCurrency: AppCurrency,
val isFiatValue: Boolean,
val segmentedButtonConfig: PersistentList<SendAmountSegmentedButtonsConfig>,
val amountTextField: SendTextField.Amount,
) : Content()
/** Fee and speed state */
data class FeeState(
override val type: SendUiStateType = SendUiStateType.Fee,
val cryptoCurrencyStatus: CryptoCurrencyStatus,
val feeSelectorState: MutableStateFlow<FeeSelectorState> = MutableStateFlow(FeeSelectorState.Empty),
val isSubtract: MutableStateFlow<Boolean> = MutableStateFlow(false),
val receivedAmount: MutableStateFlow<String> = MutableStateFlow(""),
) : SendStates()
// todo [REDACTED_JIRA]
/** Recipient state */
data class RecipientState(
override val isPrimaryButtonEnabled: Boolean = false,
override val clickIntents: SendClickIntents,
) : Content()
// todo [REDACTED_JIRA]
/** Send state */
data class SendState(
val isSuccess: Boolean,
)
}
// todo [REDACTED_JIRA]
/** Fee and speed state */
data class FeeState(
override val isPrimaryButtonEnabled: Boolean = false,
override val clickIntents: SendClickIntents,
) : Content()
// todo [REDACTED_JIRA]
/** Send state */
data class SendState(
override val isPrimaryButtonEnabled: Boolean = true,
override val clickIntents: SendClickIntents,
) : Content()
}
/** Dismiss screen */
object Dismiss : SendUiState()
enum class SendUiStateType {
Amount,
Recipient,
Fee,
Send,
Done,
}

View file

@ -0,0 +1,36 @@
package com.tangem.features.send.impl.presentation.state
import androidx.fragment.app.FragmentManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import java.lang.ref.WeakReference
internal class StateRouter(
private val fragmentManager: WeakReference<FragmentManager>,
) {
var currentState: MutableStateFlow<SendUiStateType> = MutableStateFlow(SendUiStateType.Amount)
fun onBackClick() {
fragmentManager.get()?.popBackStack()
}
fun onNextClick() {
when (currentState.value) {
SendUiStateType.Amount -> currentState.update { SendUiStateType.Recipient }
SendUiStateType.Recipient -> currentState.update { SendUiStateType.Fee }
SendUiStateType.Fee -> currentState.update { SendUiStateType.Send }
SendUiStateType.Send -> currentState.update { SendUiStateType.Done }
SendUiStateType.Done -> onBackClick()
}
}
fun onPrevClick() {
when (currentState.value) {
SendUiStateType.Amount -> onBackClick()
SendUiStateType.Recipient -> currentState.update { SendUiStateType.Amount }
SendUiStateType.Fee -> currentState.update { SendUiStateType.Recipient }
SendUiStateType.Send -> currentState.update { SendUiStateType.Fee }
SendUiStateType.Done -> onBackClick()
}
}
}

View file

@ -1,64 +1,55 @@
package com.tangem.features.send.impl.presentation.state.amount
import arrow.core.Either
import com.tangem.common.Provider
import com.tangem.core.ui.components.currency.tokenicon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.utils.BigDecimalFormatter.formatCryptoAmount
import com.tangem.core.ui.utils.BigDecimalFormatter.formatFiatAmount
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.error.CurrencyStatusError
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.features.send.impl.presentation.state.SendUiState
import com.tangem.features.send.impl.presentation.state.SendStates
import com.tangem.features.send.impl.presentation.state.fields.SendAmountFieldConverter
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.MutableStateFlow
internal class SendAmountStateConverter(
private val currentStateProvider: Provider<SendUiState>,
private val appCurrencyProvider: Provider<AppCurrency>,
private val userWalletProvider: Provider<UserWallet?>,
private val clickIntents: SendClickIntents,
private val userWalletProvider: Provider<UserWallet>,
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val iconStateConverter: CryptoCurrencyToIconStateConverter,
private val sendAmountFieldConverter: SendAmountFieldConverter,
) : Converter<Either<CurrencyStatusError, CryptoCurrencyStatus>, SendUiState> {
) : Converter<Unit, SendStates.AmountState> {
override fun convert(value: Either<CurrencyStatusError, CryptoCurrencyStatus>): SendUiState {
val userWallet = userWalletProvider() ?: return currentStateProvider()
override fun convert(value: Unit): SendStates.AmountState {
val userWallet = userWalletProvider()
val appCurrency = appCurrencyProvider()
return value.fold(
ifLeft = {
// TODO add error handling
currentStateProvider()
},
ifRight = {
val fiat = formatFiatAmount(it.value.fiatAmount, appCurrency.code, appCurrency.symbol)
val crypto = formatCryptoAmount(it.value.amount, it.currency.symbol, it.currency.decimals)
SendUiState.Content.AmountState(
cryptoCurrencyStatus = it,
walletName = userWallet.name,
walletBalance = "$crypto ($fiat)",
tokenIconState = iconStateConverter.convert(it),
appCurrency = appCurrency,
amountTextField = sendAmountFieldConverter.convert(Unit),
isFiatValue = false,
clickIntents = clickIntents,
segmentedButtonConfig = persistentListOf(
SendAmountSegmentedButtonsConfig(
title = stringReference(it.currency.symbol),
iconState = iconStateConverter.convert(it),
isFiat = false,
),
SendAmountSegmentedButtonsConfig(
title = stringReference(appCurrency.code),
iconState = iconStateConverter.convert(it),
isFiat = true,
),
),
)
},
val status = cryptoCurrencyStatusProvider()
val fiat = formatFiatAmount(status.value.fiatAmount, appCurrency.code, appCurrency.symbol)
val crypto = formatCryptoAmount(status.value.amount, status.currency.symbol, status.currency.decimals)
return SendStates.AmountState(
appCurrency = appCurrency,
cryptoCurrencyStatus = status,
walletName = userWallet.name,
walletBalance = "$crypto ($fiat)",
tokenIconState = iconStateConverter.convert(status),
amountTextField = MutableStateFlow(sendAmountFieldConverter.convert(Unit)),
isFiatValue = false,
isPrimaryButtonEnabled = false,
segmentedButtonConfig = persistentListOf(
SendAmountSegmentedButtonsConfig(
title = stringReference(status.currency.symbol),
iconState = iconStateConverter.convert(status),
isFiat = false,
),
SendAmountSegmentedButtonsConfig(
title = stringReference(appCurrency.code),
iconState = iconStateConverter.convert(status),
isFiat = true,
),
),
)
}
}

View file

@ -0,0 +1,27 @@
package com.tangem.features.send.impl.presentation.state.fee
import androidx.compose.runtime.Immutable
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.features.send.impl.presentation.state.fields.SendTextField
import kotlinx.coroutines.flow.MutableStateFlow
@Immutable
internal sealed class FeeSelectorState {
object Loading : FeeSelectorState()
object Empty : FeeSelectorState()
data class Content(
val fees: TransactionFee,
val selectedFee: FeeType = FeeType.MARKET,
val customValues: MutableStateFlow<List<SendTextField.CustomFee>> = MutableStateFlow(emptyList()),
) : FeeSelectorState()
}
enum class FeeType {
SLOW,
MARKET,
FAST,
CUSTOM,
}

View file

@ -0,0 +1,61 @@
package com.tangem.features.send.impl.presentation.state.fee
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.common.Provider
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.features.send.impl.presentation.state.fields.SendTextField
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import com.tangem.utils.converter.Converter
import kotlinx.coroutines.flow.MutableStateFlow
internal class SendFeeCustomFieldConverter(
private val clickIntents: SendClickIntents,
private val appCurrencyProvider: Provider<AppCurrency>,
) : Converter<Fee, MutableStateFlow<List<SendTextField.CustomFee>>> {
override fun convert(value: Fee): MutableStateFlow<List<SendTextField.CustomFee>> {
val ethereumFee = value as? Fee.Ethereum ?: return MutableStateFlow(emptyList())
val appCurrency = appCurrencyProvider()
val maxFeeFiat = BigDecimalFormatter.formatFiatAmount(
fiatAmount = ethereumFee.amount.value,
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
)
return MutableStateFlow(
listOf(
SendTextField.CustomFee(
value = ethereumFee.amount.value.toString(),
onValueChange = { clickIntents.onCustomFeeValueChange(0, it) },
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Next,
keyboardType = KeyboardType.Number,
),
label = TextReference.Str(maxFeeFiat),
),
SendTextField.CustomFee(
value = ethereumFee.gasPrice.toString(),
onValueChange = { clickIntents.onCustomFeeValueChange(1, it) },
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Next,
keyboardType = KeyboardType.Number,
),
),
SendTextField.CustomFee(
value = ethereumFee.gasLimit.toString(),
onValueChange = { clickIntents.onCustomFeeValueChange(2, it) },
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done,
keyboardType = KeyboardType.Number,
),
),
),
)
}
}

View file

@ -0,0 +1,17 @@
package com.tangem.features.send.impl.presentation.state.fee
import com.tangem.common.Provider
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.send.impl.presentation.state.SendStates
import com.tangem.utils.converter.Converter
internal class SendFeeStateConverter(
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
) : Converter<Unit, SendStates.FeeState> {
override fun convert(value: Unit): SendStates.FeeState {
return SendStates.FeeState(
cryptoCurrencyStatus = cryptoCurrencyStatusProvider(),
)
}
}

View file

@ -1,8 +1,11 @@
package com.tangem.features.send.impl.presentation.state.fields
import com.tangem.common.Provider
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.send.impl.presentation.state.SendStates
import com.tangem.features.send.impl.presentation.state.SendUiState
import com.tangem.utils.converter.Converter
import kotlinx.coroutines.flow.update
import java.text.DecimalFormatSymbols
import java.text.NumberFormat
@ -11,21 +14,14 @@ internal class SendAmountFieldChangeConverter(
) : Converter<String, SendUiState> {
override fun convert(value: String): SendUiState {
val state = currentStateProvider()
val amountState = state.amountState ?: return state
if (
state !is SendUiState.Content.AmountState ||
value.checkDecimalSeparatorDuplicate()
) {
return state
}
if (value.checkDecimalSeparatorDuplicate()) return state
if (value.isEmpty()) return state.emptyState()
val fiatRate = state.cryptoCurrencyStatus.value.fiatRate
val fiatRate = amountState.cryptoCurrencyStatus.value.fiatRate
val trimmedValue = value.trim()
val cryptoValue = if (state.isFiatValue) {
val cryptoValue = if (amountState.isFiatValue) {
if (value.isNotBlank()) {
trimmedValue.toBigDecimal().divide(fiatRate)?.stripTrailingZeros()?.toPlainString().orEmpty()
} else {
@ -35,7 +31,7 @@ internal class SendAmountFieldChangeConverter(
trimmedValue
}
val fiatValue = if (!state.isFiatValue) {
val fiatValue = if (!amountState.isFiatValue) {
if (value.isNotBlank()) {
trimmedValue.toBigDecimal().multiply(fiatRate)?.stripTrailingZeros()?.toPlainString().orEmpty()
} else {
@ -45,37 +41,48 @@ internal class SendAmountFieldChangeConverter(
trimmedValue
}
val isExceedBalance = value.checkExceedBalance(state)
return state.copy(
amountTextField = state.amountTextField.copy(
val isExceedBalance = value.checkExceedBalance(amountState.cryptoCurrencyStatus, amountState)
amountState.amountTextField.update {
it.copy(
value = cryptoValue,
fiatValue = fiatValue,
isError = isExceedBalance,
)
}
return state.copy(
amountState = amountState.copy(
isPrimaryButtonEnabled = !isExceedBalance,
),
isPrimaryButtonEnabled = !isExceedBalance,
)
}
private fun SendUiState.Content.AmountState.emptyState(): SendUiState {
return copy(
amountTextField = amountTextField.copy(
value = if (!isFiatValue) "" else DEFAULT_VALUE,
fiatValue = if (isFiatValue) "" else DEFAULT_VALUE,
private fun SendUiState.emptyState(): SendUiState {
amountState?.amountTextField?.update {
it.copy(
value = if (!amountState.isFiatValue) "" else DEFAULT_VALUE,
fiatValue = if (amountState.isFiatValue) "" else DEFAULT_VALUE,
isError = false,
)
}
return copy(
amountState = amountState?.copy(
isPrimaryButtonEnabled = false,
),
isPrimaryButtonEnabled = false,
)
}
private fun String.checkDecimalSeparatorDuplicate(): Boolean {
val regex = "[\\.\\,]".toRegex()
val regex = TRIM_REGEX.toRegex()
val decimalSeparatorCount = regex.findAll(this).count()
return decimalSeparatorCount > 1
}
private fun String.checkExceedBalance(state: SendUiState.Content.AmountState): Boolean {
val currencyStatus = state.cryptoCurrencyStatus.value
private fun String.checkExceedBalance(
cryptoCurrencyStatus: CryptoCurrencyStatus,
state: SendStates.AmountState,
): Boolean {
val currencyStatus = cryptoCurrencyStatus.value
return if (state.isFiatValue) {
toBigDecimal() > currencyStatus.fiatAmount
} else {
@ -88,10 +95,11 @@ internal class SendAmountFieldChangeConverter(
if (length > 1 && firstOrNull() == '0' && get(1).isDigit()) trimmedValue = drop(1)
val separatorChar = DecimalFormatSymbols.getInstance().decimalSeparator.toString()
return trimmedValue.replace("[\\.\\,]".toRegex(), separatorChar)
return trimmedValue.replace(TRIM_REGEX.toRegex(), separatorChar)
}
companion object {
private val DEFAULT_VALUE = NumberFormat.getInstance().format(0.00)
private const val TRIM_REGEX = "[.,]"
}
}

View file

@ -22,7 +22,6 @@ internal class SendAmountFieldConverter(
imeAction = ImeAction.Next,
keyboardType = KeyboardType.Number,
),
label = TextReference.Str(""),
placeholder = TextReference.Str(DEFAULT_VALUE),
isError = false,
error = TextReference.Res(R.string.send_insufficient_funds),

View file

@ -16,20 +16,43 @@ internal sealed class SendTextField {
/** Keyboard options */
abstract val keyboardOptions: KeyboardOptions
/** Label */
abstract val label: TextReference
/** Placeholder (hint) */
abstract val placeholder: TextReference
// /** Placeholder (hint) */
// abstract val placeholder: TextReference
data class Amount(
override val value: String,
override val onValueChange: (String) -> Unit,
override val keyboardOptions: KeyboardOptions,
override val label: TextReference,
override val placeholder: TextReference,
val placeholder: TextReference,
val fiatValue: String,
val isError: Boolean,
val error: TextReference,
) : SendTextField()
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,
) : SendTextField()
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,
) : SendTextField()
data class CustomFee(
override val value: String,
override val onValueChange: (String) -> Unit,
override val keyboardOptions: KeyboardOptions,
val label: TextReference? = null,
) : SendTextField()
}

View file

@ -0,0 +1,31 @@
package com.tangem.features.send.impl.presentation.state.recipient
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.TextReference
import com.tangem.features.send.impl.R
import com.tangem.features.send.impl.presentation.state.fields.SendTextField
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import com.tangem.utils.converter.Converter
import kotlinx.coroutines.flow.MutableStateFlow
internal class SendRecipientAddressFieldConverter(
private val clickIntents: SendClickIntents,
) : Converter<Unit, MutableStateFlow<SendTextField.RecipientAddress>> {
override fun convert(value: Unit): MutableStateFlow<SendTextField.RecipientAddress> {
return MutableStateFlow(
SendTextField.RecipientAddress(
value = "",
onValueChange = clickIntents::onRecipientAddressValueChange,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Next,
keyboardType = KeyboardType.Text,
),
placeholder = TextReference.Res(R.string.send_enter_address_field),
label = TextReference.Res(R.string.send_recipient),
),
)
}
}

View file

@ -0,0 +1,122 @@
package com.tangem.features.send.impl.presentation.state.recipient
import androidx.paging.*
import com.tangem.common.Provider
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.utils.DateTimeFormatters
import com.tangem.core.ui.utils.toDateFormat
import com.tangem.core.ui.utils.toTimeFormat
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.txhistory.models.TxHistoryItem
import com.tangem.features.send.impl.R
import com.tangem.features.send.impl.presentation.domain.AvailableWallet
import com.tangem.features.send.impl.presentation.domain.SendRecipientListContent
import com.tangem.features.send.impl.presentation.state.SendUiState
import com.tangem.utils.toFormattedCurrencyString
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.flow.update
internal class SendRecipientListConverter(
private val currentStateProvider: Provider<SendUiState>,
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
) {
fun convert(wallets: List<AvailableWallet?>, txHistory: PagingData<TxHistoryItem>, txHistoryCount: Int) {
val filteredWallets = wallets.filterNotNull()
.groupBy { item -> item.name }
.values.flatten()
.mapIndexed { index, item ->
item.copy(
name = "${item.name} ${index.inc()}",
)
}
val walletsItem = getWalletItems(filteredWallets, txHistoryCount)
currentStateProvider().recipientList.update {
if (txHistoryCount == 0) {
PagingData.from(listOf(walletsItem))
} else {
txHistory.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
}
isTransfer && isSingleAddress && isNotContract
}.map<TxHistoryItem, SendRecipientListContent> { tx ->
SendRecipientListContent.Item(
id = tx.txHash,
title = tx.extractAddress(),
subtitle = TextReference.Str(tx.getAmount()),
info = tx.extractTimestamp(),
subtitleIconRes = tx.extractIconRes(),
)
}.insertWallets(walletsItem)
}
}
}
private fun PagingData<SendRecipientListContent>.insertWallets(
wallets: SendRecipientListContent.Wallets,
): PagingData<SendRecipientListContent> {
return insertSeparators(terminalSeparatorType = TerminalSeparatorType.SOURCE_COMPLETE) { before, after ->
return@insertSeparators when {
before == null && after is SendRecipientListContent.Item -> wallets
else -> null
}
}
}
private fun getWalletItems(wallets: List<AvailableWallet>, txHistoryCount: Int): SendRecipientListContent.Wallets {
return SendRecipientListContent.Wallets(
wallets.map {
SendRecipientListContent.Item(
id = it.address,
title = TextReference.Str(it.address),
subtitle = TextReference.Str(it.name),
)
}.toPersistentList(),
isWalletsOnly = txHistoryCount == 0,
)
}
private fun TxHistoryItem.extractAddress(): TextReference = if (isOutgoing) {
when (val destination = destinationType) {
is TxHistoryItem.DestinationType.Multiple -> TextReference.Res(
R.string.transaction_history_multiple_addresses,
)
is TxHistoryItem.DestinationType.Single -> TextReference.Str(destination.addressType.address)
}
} else {
when (val source = sourceType) {
is TxHistoryItem.SourceType.Multiple -> TextReference.Res(R.string.transaction_history_multiple_addresses)
is TxHistoryItem.SourceType.Single -> TextReference.Str(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(): String {
val cryptoCurrency = cryptoCurrencyStatusProvider().currency
return amount.toFormattedCurrencyString(
currency = cryptoCurrency.symbol,
decimals = cryptoCurrency.decimals,
)
}
private fun TxHistoryItem.extractTimestamp(): TextReference {
val date = timestampInMillis.toDateFormat(
formatter = DateTimeFormatters.dateDDMMYYYY,
)
val time = timestampInMillis.toTimeFormat()
return TextReference.Res(R.string.send_date_format, wrappedList(date, time))
}
}

View file

@ -0,0 +1,51 @@
package com.tangem.features.send.impl.presentation.state.recipient
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import com.tangem.blockchain.common.Blockchain
import com.tangem.common.Provider
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.send.impl.R
import com.tangem.features.send.impl.presentation.state.fields.SendTextField
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import com.tangem.utils.converter.Converter
import kotlinx.coroutines.flow.MutableStateFlow
internal class SendRecipientMemoFieldConverter(
private val clickIntents: SendClickIntents,
private val cryptoCurrencyStatus: Provider<CryptoCurrencyStatus>,
) : Converter<Int, MutableStateFlow<SendTextField.RecipientMemo>> {
fun convertOrNull(): MutableStateFlow<SendTextField.RecipientMemo>? {
val cryptoCurrency = cryptoCurrencyStatus().currency
return when (cryptoCurrency.network.id.value) {
Blockchain.XRP.id -> convert(R.string.send_destination_tag_field)
Blockchain.Binance.id,
Blockchain.TON.id,
Blockchain.Cosmos.id,
Blockchain.TerraV1.id,
Blockchain.TerraV2.id,
Blockchain.Stellar.id,
-> convert(R.string.send_extras_hint_memo)
else -> null
}
}
override fun convert(value: Int): MutableStateFlow<SendTextField.RecipientMemo> {
return MutableStateFlow(
SendTextField.RecipientMemo(
value = "",
onValueChange = clickIntents::onRecipientMemoValueChange,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done,
keyboardType = KeyboardType.Text,
),
placeholder = TextReference.Res(R.string.send_optional_field),
label = TextReference.Res(value),
),
)
}
}

View file

@ -0,0 +1,30 @@
package com.tangem.features.send.impl.presentation.state.recipient
import com.tangem.common.Provider
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.send.impl.presentation.state.SendStates
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import com.tangem.utils.converter.Converter
internal class SendRecipientStateConverter(
private val clickIntents: SendClickIntents,
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
) : Converter<Unit, SendStates.RecipientState> {
private val addressFieldConverter by lazy { SendRecipientAddressFieldConverter(clickIntents) }
private val memoFieldConverter by lazy {
SendRecipientMemoFieldConverter(
clickIntents,
cryptoCurrencyStatusProvider,
)
}
override fun convert(value: Unit): SendStates.RecipientState {
return SendStates.RecipientState(
addressTextField = addressFieldConverter.convert(Unit),
memoTextField = memoFieldConverter.convertOrNull(),
network = cryptoCurrencyStatusProvider().currency.network.name,
isPrimaryButtonEnabled = false,
)
}
}

View file

@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
@ -19,9 +20,10 @@ import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.PrimaryButtonIconEnd
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.send.impl.presentation.state.SendUiState
import com.tangem.features.send.impl.presentation.state.SendUiStateType
@Composable
internal fun SendNavigationButtons(uiState: SendUiState.Content) {
internal fun SendNavigationButtons(uiState: SendUiState) {
Row(
modifier = Modifier
.fillMaxWidth()
@ -38,9 +40,11 @@ internal fun SendNavigationButtons(uiState: SendUiState.Content) {
}
@Composable
private fun SendSecondaryNavigationButton(uiState: SendUiState.Content) {
private fun SendSecondaryNavigationButton(uiState: SendUiState) {
val currentState = uiState.currentState.collectAsState()
AnimatedVisibility(
visible = uiState is SendUiState.Content.RecipientState || uiState is SendUiState.Content.FeeState,
visible = currentState.value == SendUiStateType.Recipient ||
currentState.value == SendUiStateType.Fee,
) {
Icon(
modifier = Modifier
@ -48,46 +52,52 @@ private fun SendSecondaryNavigationButton(uiState: SendUiState.Content) {
.clip(RoundedCornerShape(TangemTheme.dimens.radius16))
.background(TangemTheme.colors.button.secondary)
.clickable {
// todo add prev click
uiState.clickIntents.onPrevClick()
}
.padding(TangemTheme.dimens.spacing12),
painter = painterResource(R.drawable.ic_back_24),
tint = TangemTheme.colors.icon.primary1,
contentDescription = null,
)
}
}
@Composable
private fun SendPrimaryNavigationButton(uiState: SendUiState.Content, modifier: Modifier = Modifier) {
val buttonTextId = when (uiState) {
is SendUiState.Content.AmountState,
is SendUiState.Content.RecipientState,
is SendUiState.Content.FeeState,
private fun SendPrimaryNavigationButton(uiState: SendUiState, modifier: Modifier = Modifier) {
val currentState = uiState.currentState.collectAsState()
val buttonTextId = when (currentState.value) {
SendUiStateType.Amount,
SendUiStateType.Recipient,
SendUiStateType.Fee,
-> R.string.common_next
is SendUiState.Content.SendState -> R.string.common_send
SendUiStateType.Send -> R.string.common_send
else -> R.string.common_close
}
val isButtonEnabled = when (currentState.value) {
SendUiStateType.Amount -> uiState.amountState?.isPrimaryButtonEnabled ?: false
SendUiStateType.Recipient -> uiState.recipientState?.isPrimaryButtonEnabled ?: false
else -> true
}
AnimatedContent(
targetState = buttonTextId,
label = "Update send screen state",
modifier = modifier,
) { textId ->
if (uiState is SendUiState.Content.SendState) {
if (currentState.value == SendUiStateType.Send) {
PrimaryButtonIconEnd(
text = stringResource(textId),
iconResId = R.drawable.ic_tangem_24,
enabled = uiState.isPrimaryButtonEnabled,
onClick = {
// todo add next click
},
enabled = isButtonEnabled,
onClick = uiState.clickIntents::onNextClick,
)
} else {
PrimaryButton(
text = stringResource(textId),
enabled = uiState.isPrimaryButtonEnabled,
onClick = {
// todo add next click
},
enabled = isButtonEnabled,
onClick = uiState.clickIntents::onNextClick,
)
}
}

View file

@ -1,53 +1,100 @@
package com.tangem.features.send.impl.presentation.ui
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.scrollable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetDraggableHeader
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.paging.compose.collectAsLazyPagingItems
import com.tangem.core.ui.components.appbar.AppBarWithBackButtonAndIcon
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.send.impl.R
import com.tangem.features.send.impl.presentation.state.SendUiState
import com.tangem.features.send.impl.presentation.state.SendUiStateType
import com.tangem.features.send.impl.presentation.ui.amount.SendAmountContent
import com.tangem.features.send.impl.presentation.ui.fee.SendSpeedAndFeeContent
import com.tangem.features.send.impl.presentation.ui.recipient.SendRecipientContent
@Composable
internal fun SendScreen(uiState: SendUiState.Content) {
internal fun SendScreen(uiState: SendUiState) {
val currentState = uiState.currentState.collectAsStateWithLifecycle()
BackHandler { uiState.clickIntents.onPrevClick() }
Column(
modifier = Modifier
.fillMaxSize()
.systemBarsPadding()
.imePadding()
.background(
color = TangemTheme.colors.background.tertiary,
shape = RoundedCornerShape(
topStart = TangemTheme.dimens.radius24,
topEnd = TangemTheme.dimens.radius24,
),
),
.background(color = TangemTheme.colors.background.tertiary),
horizontalAlignment = Alignment.CenterHorizontally,
) {
TangemBottomSheetDraggableHeader(
color = TangemTheme.colors.background.tertiary,
)
Box(
modifier = Modifier
.weight(1f)
.scrollable(state = rememberScrollState(), orientation = Orientation.Vertical),
) {
SendScreenContent(uiState)
val titleRes = when (currentState.value) {
SendUiStateType.Amount,
SendUiStateType.Send,
-> R.string.common_send
SendUiStateType.Recipient -> R.string.send_recipient
SendUiStateType.Fee -> R.string.common_fee_selector_title
SendUiStateType.Done -> null
}
val iconRes = when (currentState.value) {
SendUiStateType.Amount,
SendUiStateType.Recipient,
-> R.drawable.ic_qrcode_scan_24
else -> null
}
AppBarWithBackButtonAndIcon(
text = titleRes?.let { stringResource(it) },
onBackClick = uiState.clickIntents::onBackClick,
onIconClick = uiState.clickIntents::onQrCodeScanClick,
backIconRes = R.drawable.ic_close_24,
iconRes = iconRes,
backgroundColor = TangemTheme.colors.background.tertiary,
)
SendScreenContent(
uiState = uiState,
currentState = currentState,
modifier = Modifier
.weight(1f),
)
SendNavigationButtons(uiState)
}
}
@Composable
private fun SendScreenContent(uiState: SendUiState.Content) {
when (uiState) {
is SendUiState.Content.AmountState -> SendAmountContent(uiState)
else -> { /* [REDACTED_TODO_COMMENT]*/
private fun SendScreenContent(
uiState: SendUiState,
currentState: State<SendUiStateType>,
modifier: Modifier = Modifier,
) {
val recipientList = uiState.recipientList.collectAsLazyPagingItems()
AnimatedContent(
targetState = currentState.value,
label = "Send Scree Navigation",
modifier = modifier,
) { state ->
when (state) {
SendUiStateType.Amount -> SendAmountContent(
uiState.amountState,
uiState.clickIntents,
)
SendUiStateType.Recipient -> SendRecipientContent(
uiState.recipientState,
uiState.clickIntents,
recipientList,
)
SendUiStateType.Fee -> SendSpeedAndFeeContent(
uiState.feeState,
uiState.clickIntents,
)
else -> { /* [REDACTED_TODO_COMMENT]*/ }
}
}
}

View file

@ -18,12 +18,8 @@ import androidx.compose.ui.Alignment.Companion.CenterHorizontally
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.input.OffsetMapping
import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import com.tangem.core.ui.components.fields.AmountVisualTransformation
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
@ -140,29 +136,4 @@ private fun AmountFieldError(isError: Boolean, error: TextReference, modifier: M
textAlign = TextAlign.Center,
)
}
}
private class AmountVisualTransformation(
private val symbol: String,
) : VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText {
return TransformedText(
buildAnnotatedString {
append(text)
if (text.isNotBlank()) {
append(" ")
append(symbol)
}
},
object : OffsetMapping {
override fun originalToTransformed(offset: Int): Int {
return text.length
}
override fun transformedToOriginal(offset: Int): Int {
return text.length
}
},
)
}
}

View file

@ -11,12 +11,14 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextAlign
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.ui.components.currency.tokenicon.TokenIcon
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.send.impl.presentation.state.SendUiState
import com.tangem.features.send.impl.presentation.state.SendStates
@Composable
internal fun AmountFieldContainer(amountState: SendUiState.Content.AmountState, modifier: Modifier = Modifier) {
internal fun AmountFieldContainer(amountState: SendStates.AmountState, modifier: Modifier = Modifier) {
val amountTextField = amountState.amountTextField.collectAsStateWithLifecycle()
Column(
modifier = modifier
.fillMaxWidth()
@ -52,10 +54,10 @@ internal fun AmountFieldContainer(amountState: SendUiState.Content.AmountState,
.align(Alignment.CenterHorizontally),
)
AmountField(
sendField = amountState.amountTextField,
sendField = amountTextField.value,
isFiat = amountState.isFiatValue,
cryptoSymbol = amountState.cryptoCurrencyStatus.currency.symbol,
fiatSymbol = amountState.appCurrency.symbol,
isFiat = amountState.isFiatValue,
)
}
}

View file

@ -1,4 +1,4 @@
package com.tangem.features.send.impl.presentation.ui
package com.tangem.features.send.impl.presentation.ui.amount
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
@ -6,10 +6,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import com.tangem.core.ui.components.SecondaryButton
import com.tangem.core.ui.components.buttons.common.TangemButtonSize
import com.tangem.core.ui.components.buttons.segmentedbutton.SegmentedButtons
@ -17,30 +15,22 @@ import com.tangem.core.ui.components.currency.fiaticon.FiatIcon
import com.tangem.core.ui.components.currency.tokenicon.TokenIcon
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.send.impl.R
import com.tangem.features.send.impl.presentation.state.SendStates
import com.tangem.features.send.impl.presentation.state.amount.SendAmountSegmentedButtonsConfig
import com.tangem.features.send.impl.presentation.state.SendUiState
import com.tangem.features.send.impl.presentation.ui.amount.AmountFieldContainer
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
@Composable
internal fun SendAmountContent(amountState: SendUiState.Content.AmountState) {
internal fun SendAmountContent(amountState: SendStates.AmountState?, clickIntents: SendClickIntents) {
if (amountState == null) return
Column(
modifier = Modifier
.background(TangemTheme.colors.background.tertiary),
) {
Text(
text = stringResource(R.string.common_send),
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
modifier = Modifier
.padding(vertical = TangemTheme.dimens.spacing16)
.align(CenterHorizontally),
)
AmountFieldContainer(amountState = amountState)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(
top = TangemTheme.dimens.spacing12,
start = TangemTheme.dimens.spacing16,
@ -49,16 +39,16 @@ internal fun SendAmountContent(amountState: SendUiState.Content.AmountState) {
) {
SegmentedButtons(
modifier = Modifier
.height(TangemTheme.dimens.size40)
.weight(1f),
.weight(1f)
.height(TangemTheme.dimens.size40),
config = amountState.segmentedButtonConfig,
onClick = { amountState.clickIntents.onCurrencyChangeClick(it.isFiat) },
onClick = { clickIntents.onCurrencyChangeClick(it.isFiat) },
) {
SendAmountCurrencyButton(it)
}
SecondaryButton(
text = stringResource(R.string.send_max_amount),
onClick = amountState.clickIntents::onMaxValueClick,
onClick = clickIntents::onMaxValueClick,
size = TangemButtonSize.Text,
shape = RoundedCornerShape(TangemTheme.dimens.radius26),
modifier = Modifier
@ -83,8 +73,8 @@ private fun SendAmountCurrencyButton(button: SendAmountSegmentedButtonsConfig) {
if (button.isFiat) {
FiatIcon(
url = button.iconUrl,
modifier = Modifier
.size(TangemTheme.dimens.size18),
size = TangemTheme.dimens.size18,
modifier = Modifier.size(TangemTheme.dimens.size18),
)
} else {
button.iconState?.let {

View file

@ -0,0 +1,39 @@
package com.tangem.features.send.impl.presentation.ui.common
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import com.tangem.core.ui.res.TangemTheme
/**
* Container for footer info below the text field
*
* @param modifier of component
* @param footer text
* @param footerTopPadding padding between footer and field
* @param content field content
*/
@Composable
internal fun FooterContainer(
modifier: Modifier = Modifier,
footer: String? = null,
footerTopPadding: Dp = TangemTheme.dimens.spacing8,
content: @Composable () -> Unit,
) {
Column(modifier = modifier) {
content()
AnimatedVisibility(visible = footer != null) {
Text(
text = footer.orEmpty(),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
modifier = Modifier
.padding(top = footerTopPadding),
)
}
}
}

View file

@ -0,0 +1,63 @@
package com.tangem.features.send.impl.presentation.ui.fee
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.tangem.core.ui.components.fields.AmountVisualTransformation
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.send.impl.R
import com.tangem.features.send.impl.presentation.state.fee.FeeType
import com.tangem.features.send.impl.presentation.state.fields.SendTextField
import com.tangem.features.send.impl.presentation.ui.recipient.TextFieldWithInfo
private const val ETHEREUM_UNIT = "GWEI"
@Composable
internal fun SendCustomFeeEthereum(
customValues: State<List<SendTextField.CustomFee>>,
selectedFee: FeeType,
symbol: String,
modifier: Modifier = Modifier,
) {
val fee = customValues.value[0]
val gasPrice = customValues.value[1]
val gasLimit = customValues.value[2]
if (selectedFee == FeeType.CUSTOM && customValues.value.isNotEmpty()) {
Column(
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
modifier = modifier,
) {
TextFieldWithInfo(
value = fee.value,
label = stringResource(R.string.send_max_fee),
footer = stringResource(R.string.send_max_fee_footer),
info = fee.label,
visualTransformation = AmountVisualTransformation(symbol),
keyboardOptions = fee.keyboardOptions,
onValueChange = fee.onValueChange,
isSingleLine = true,
)
TextFieldWithInfo(
value = gasPrice.value,
label = stringResource(R.string.send_gas_price),
footer = stringResource(R.string.send_gas_price_footer),
onValueChange = gasPrice.onValueChange,
visualTransformation = AmountVisualTransformation(ETHEREUM_UNIT),
keyboardOptions = fee.keyboardOptions,
isSingleLine = true,
)
TextFieldWithInfo(
value = gasLimit.value,
label = stringResource(R.string.send_gas_limit),
footer = stringResource(R.string.send_gas_limit_footer),
onValueChange = gasLimit.onValueChange,
keyboardOptions = fee.keyboardOptions,
isSingleLine = true,
)
}
}
}

View file

@ -0,0 +1,86 @@
package com.tangem.features.send.impl.presentation.ui.fee
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.send.impl.presentation.state.SendStates
import com.tangem.features.send.impl.presentation.state.fee.FeeSelectorState
import com.tangem.features.send.impl.presentation.state.fee.FeeType
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
private const val FEE_SELECTOR_KEY = "FEE_SELECTOR_KEY"
private const val FEE_CUSTOM_KEY = "FEE_CUSTOM_KEY"
@OptIn(ExperimentalFoundationApi::class)
@Composable
internal fun SendSpeedAndFeeContent(state: SendStates.FeeState?, clickIntents: SendClickIntents) {
if (state == null) return
val feeSendState = state.feeSelectorState.collectAsStateWithLifecycle()
LazyColumn(
modifier = Modifier
.fillMaxSize()
.background(TangemTheme.colors.background.tertiary)
.padding(
horizontal = TangemTheme.dimens.spacing16,
),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
item(
key = FEE_SELECTOR_KEY,
) {
SendSpeedSelector(
state = feeSendState,
clickIntents = clickIntents,
)
}
if (feeSendState.value is FeeSelectorState.Content) {
item(
key = FEE_CUSTOM_KEY,
) {
AnimatedVisibility(
visible = feeSendState.value is FeeSelectorState.Content,
modifier = Modifier
.fillMaxWidth()
.background(TangemTheme.colors.background.tertiary),
) {
val fee = feeSendState.value as FeeSelectorState.Content
val customValues = fee.customValues.collectAsStateWithLifecycle()
SendCustomFeeEthereum(
customValues = customValues,
selectedFee = fee.selectedFee,
symbol = state.cryptoCurrencyStatus.currency.symbol,
modifier = Modifier
.animateItemPlacement(),
)
}
}
}
item {
val topPadding = (feeSendState.value as? FeeSelectorState.Content)?.let { state ->
if (state.selectedFee != FeeType.CUSTOM) {
TangemTheme.dimens.spacing8
} else {
TangemTheme.dimens.spacing0
}
} ?: TangemTheme.dimens.spacing0
SendSpeedSubtract(
receivingAmount = state.receivedAmount,
isSubtract = state.isSubtract,
onSelectClick = clickIntents::onSubtractSelect,
modifier = Modifier
.animateItemPlacement()
.padding(
top = topPadding,
bottom = TangemTheme.dimens.spacing12,
),
)
}
}
}

View file

@ -0,0 +1,271 @@
package com.tangem.features.send.impl.presentation.ui.fee
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
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.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.SpacerWMax
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.send.impl.R
import com.tangem.features.send.impl.presentation.state.fee.FeeSelectorState
import com.tangem.features.send.impl.presentation.state.fee.FeeType
import com.tangem.features.send.impl.presentation.ui.common.FooterContainer
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
@Suppress("LongMethod")
@Composable
internal fun SendSpeedSelector(
state: State<FeeSelectorState>,
clickIntents: SendClickIntents,
modifier: Modifier = Modifier,
) {
FooterContainer(
footer = stringResource(R.string.common_fee_selector_footer),
modifier = modifier,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors.background.action),
) {
when (val selector = state.value) {
FeeSelectorState.Loading -> {
SendSpeedSelectorItemLoading()
SendSpeedSelectorItemLoading()
SendSpeedSelectorItemLoading()
}
is FeeSelectorState.Content -> {
when (selector.fees) {
is TransactionFee.Choosable -> {
SendSpeedSelectorItem(
titleRes = R.string.common_fee_selector_option_slow,
iconRes = R.drawable.ic_tortoise_24,
amount = TextReference.Str(selector.fees.minimum.amount.value.toString()),
symbol = TextReference.Str(selector.fees.minimum.amount.currencySymbol),
isSelected = selector.selectedFee == FeeType.SLOW,
onSelect = { clickIntents.onFeeSelectorClick(FeeType.SLOW) },
)
SendSpeedSelectorItem(
titleRes = R.string.common_fee_selector_option_market,
iconRes = R.drawable.ic_bird_24,
amount = TextReference.Str(selector.fees.normal.amount.value.toString()),
symbol = TextReference.Str(selector.fees.normal.amount.currencySymbol),
isSelected = selector.selectedFee == FeeType.MARKET,
onSelect = { clickIntents.onFeeSelectorClick(FeeType.MARKET) },
)
SendSpeedSelectorItem(
titleRes = R.string.common_fee_selector_option_fast,
iconRes = R.drawable.ic_hare_24,
amount = TextReference.Str(selector.fees.priority.amount.value.toString()),
symbol = TextReference.Str(selector.fees.priority.amount.currencySymbol),
isSelected = selector.selectedFee == FeeType.FAST,
onSelect = { clickIntents.onFeeSelectorClick(FeeType.FAST) },
showDivider = selector.fees.normal is Fee.Ethereum,
)
if (selector.fees.normal is Fee.Ethereum) {
SendSpeedSelectorItem(
titleRes = R.string.common_fee_selector_option_custom,
iconRes = R.drawable.ic_edit_24,
isSelected = selector.selectedFee == FeeType.CUSTOM,
onSelect = { clickIntents.onFeeSelectorClick(FeeType.CUSTOM) },
showDivider = selector.fees.normal !is Fee.Ethereum,
)
}
}
is TransactionFee.Single -> {
SendSpeedSelectorItem(
titleRes = R.string.common_fee_selector_option_market,
iconRes = R.drawable.ic_bird_24,
isSelected = true,
amount = TextReference.Str(selector.fees.normal.amount.value.toString()),
symbol = TextReference.Str(selector.fees.normal.amount.currencySymbol),
onSelect = { clickIntents.onFeeSelectorClick(FeeType.MARKET) },
showDivider = false,
)
}
}
}
FeeSelectorState.Empty -> Unit
}
}
}
}
@Composable
private fun SendSpeedSelectorItemLoading() {
Row(modifier = Modifier.fillMaxWidth()) {
RectangleShimmer(
radius = TangemTheme.dimens.radius3,
modifier = Modifier
.padding(
top = TangemTheme.dimens.spacing18,
bottom = TangemTheme.dimens.spacing18,
start = TangemTheme.dimens.spacing12,
)
.size(
width = TangemTheme.dimens.size50,
height = TangemTheme.dimens.size12,
),
)
SpacerWMax()
RectangleShimmer(
radius = TangemTheme.dimens.radius3,
modifier = Modifier
.padding(
top = TangemTheme.dimens.spacing18,
bottom = TangemTheme.dimens.spacing18,
end = TangemTheme.dimens.spacing12,
)
.size(
width = TangemTheme.dimens.size90,
height = TangemTheme.dimens.size12,
),
)
}
}
@Composable
private fun SendSpeedSelectorItem(
@StringRes titleRes: Int,
@DrawableRes iconRes: Int,
onSelect: () -> Unit,
modifier: Modifier = Modifier,
amount: TextReference? = null,
symbol: TextReference? = null,
isSelected: Boolean = false,
showDivider: Boolean = true,
) {
val iconTint by animateColorAsState(
targetValue = if (isSelected) {
TangemTheme.colors.icon.accent
} else {
TangemTheme.colors.icon.informative
},
label = "Selector icon tint change",
)
val textStyle = if (isSelected) {
TangemTheme.typography.subtitle2
} else {
TangemTheme.typography.body2
}
Box(
modifier = modifier
.fillMaxWidth()
.clickable { onSelect() },
) {
Row(modifier = Modifier.fillMaxWidth()) {
Icon(
painter = painterResource(iconRes),
tint = iconTint,
contentDescription = null,
modifier = Modifier
.padding(
start = TangemTheme.dimens.spacing12,
top = TangemTheme.dimens.spacing12,
bottom = TangemTheme.dimens.spacing12,
),
)
Text(
text = stringResource(titleRes),
style = textStyle,
color = TangemTheme.colors.text.primary1,
modifier = Modifier
.padding(
start = TangemTheme.dimens.spacing8,
top = TangemTheme.dimens.spacing14,
bottom = TangemTheme.dimens.spacing14,
),
)
if (amount != null && symbol != null) {
SelectorValueContent(
amount = amount,
symbol = symbol,
textStyle = textStyle,
)
}
}
if (showDivider) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(TangemTheme.dimens.size1)
.padding(horizontal = TangemTheme.dimens.spacing12)
.background(TangemTheme.colors.stroke.primary)
.align(Alignment.BottomCenter),
)
}
}
}
@Composable
private fun RowScope.SelectorValueContent(amount: TextReference, symbol: TextReference, textStyle: TextStyle) {
Text(
text = amount.resolveReference(),
style = textStyle,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.End,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
modifier = Modifier
.weight(1f)
.padding(
start = TangemTheme.dimens.spacing4,
top = TangemTheme.dimens.spacing14,
bottom = TangemTheme.dimens.spacing14,
),
)
Text(
text = symbol.resolveReference(),
style = textStyle,
color = TangemTheme.colors.text.primary1,
modifier = Modifier
.padding(
start = TangemTheme.dimens.spacing1,
end = TangemTheme.dimens.spacing12,
top = TangemTheme.dimens.spacing14,
bottom = TangemTheme.dimens.spacing14,
),
)
}
//region preview
@Preview
@Composable
private fun FeeSelectorPreview_Light() {
TangemTheme {
SendSpeedSelectorItemLoading()
}
}
@Preview
@Composable
private fun FeeSelectorPreview_Dark() {
TangemTheme(isDark = true) {
SendSpeedSelectorItemLoading()
}
}
//endregion

View file

@ -0,0 +1,66 @@
package com.tangem.features.send.impl.presentation.ui.fee
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.ui.components.TangemSwitch
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.send.impl.R
import com.tangem.features.send.impl.presentation.ui.common.FooterContainer
import kotlinx.coroutines.flow.StateFlow
@Composable
internal fun SendSpeedSubtract(
receivingAmount: StateFlow<String>,
isSubtract: StateFlow<Boolean>,
onSelectClick: (Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
val isSelected = isSubtract.collectAsStateWithLifecycle()
val footer = receivingAmount.collectAsStateWithLifecycle()
val footerText = if (isSelected.value) {
stringResource(R.string.send_amount_substract_footer, footer.value)
} else {
null
}
FooterContainer(
footer = footerText,
modifier = modifier,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxWidth()
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors.background.action)
.padding(
vertical = TangemTheme.dimens.spacing16,
horizontal = TangemTheme.dimens.spacing20,
),
) {
Text(
text = stringResource(R.string.send_amount_substract),
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.primary1,
modifier = Modifier
.padding(end = TangemTheme.dimens.spacing12),
)
TangemSwitch(
checked = isSelected.value,
onCheckedChange = onSelectClick,
)
}
}
}

View file

@ -13,9 +13,13 @@ 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.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import androidx.constraintlayout.compose.ConstraintLayout
import androidx.constraintlayout.compose.Dimension
import androidx.constraintlayout.compose.Visibility
import com.tangem.core.ui.components.MiddleEllipsisText
import com.tangem.core.ui.components.icons.identicon.IdentIcon
import com.tangem.core.ui.res.TangemTheme
@ -28,75 +32,98 @@ import com.tangem.features.send.impl.R
* @param subtitle subtitle
* @param onClick click listener
* @param modifier modifier
* @param info info
* @param subtitleIconRes icon
*/
@Suppress("DestructuringDeclarationWithTooManyEntries", "LongMethod")
@Composable
fun ListItemWithIcon(
title: String,
subtitle: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
info: String? = null,
@DrawableRes subtitleIconRes: Int? = null,
) {
Row(
ConstraintLayout(
modifier = modifier
.fillMaxWidth()
.background(TangemTheme.colors.background.action)
.clickable { onClick() }
.padding(
vertical = TangemTheme.dimens.spacing8,
horizontal = TangemTheme.dimens.spacing12,
),
.padding(horizontal = TangemTheme.dimens.spacing12),
) {
val (iconRef, titleRef, subtitleRef, subtitleIconRef, infoRef) = createRefs()
val spacing2 = TangemTheme.dimens.spacing2
val spacing8 = TangemTheme.dimens.spacing8
val spacing10 = TangemTheme.dimens.spacing10
val spacing12 = TangemTheme.dimens.spacing12
IdentIcon(
address = title,
modifier = Modifier
.size(TangemTheme.dimens.size40)
.clip(RoundedCornerShape(TangemTheme.dimens.radius20)),
.clip(RoundedCornerShape(TangemTheme.dimens.radius20))
.constrainAs(iconRef) {
start.linkTo(parent.start)
top.linkTo(parent.top, margin = spacing8)
bottom.linkTo(parent.bottom, margin = spacing8)
},
)
Column(
MiddleEllipsisText(
text = title,
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Justify,
modifier = Modifier
.fillMaxWidth()
.padding(
start = TangemTheme.dimens.spacing12,
top = TangemTheme.dimens.spacing2,
bottom = TangemTheme.dimens.spacing2,
),
) {
MiddleEllipsisText(
text = title,
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Justify,
modifier = Modifier.fillMaxSize(),
.constrainAs(titleRef) {
start.linkTo(iconRef.end, margin = spacing12)
end.linkTo(parent.end)
top.linkTo(parent.top, margin = spacing10)
width = Dimension.fillToConstraints
},
)
Icon(
painter = painterResource(id = subtitleIconRes ?: R.drawable.ic_arrow_down_24),
contentDescription = null,
tint = TangemTheme.colors.icon.informative,
modifier = Modifier
.size(TangemTheme.dimens.size16)
.background(TangemTheme.colors.background.tertiary, CircleShape)
.constrainAs(subtitleIconRef) {
start.linkTo(iconRef.end, margin = spacing12)
top.linkTo(titleRef.bottom)
bottom.linkTo(parent.bottom, margin = spacing10)
visibility = if (subtitleIconRes == null) Visibility.Gone else Visibility.Visible
},
)
Text(
text = subtitle,
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.constrainAs(subtitleRef) {
start.linkTo(subtitleIconRef.end, margin = spacing2, goneMargin = spacing12)
end.linkTo(infoRef.start)
top.linkTo(titleRef.bottom)
bottom.linkTo(parent.bottom, margin = spacing10)
width = Dimension.fillToConstraints
},
)
info?.let {
Text(
text = it,
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
maxLines = 1,
modifier = Modifier
.constrainAs(infoRef) {
start.linkTo(subtitleRef.end, goneMargin = spacing12)
end.linkTo(parent.end)
top.linkTo(titleRef.bottom)
bottom.linkTo(parent.bottom, margin = spacing10)
},
)
Row {
subtitleIconRes?.let { iconRes ->
Icon(
painter = painterResource(id = iconRes),
contentDescription = null,
tint = TangemTheme.colors.icon.informative,
modifier = Modifier
.size(TangemTheme.dimens.size16)
.background(TangemTheme.colors.background.tertiary, CircleShape)
.padding(TangemTheme.dimens.spacing3),
)
}
Text(
text = subtitle,
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
maxLines = 1,
modifier = Modifier
.then(
if (subtitleIconRes != null) {
Modifier.padding(start = TangemTheme.dimens.spacing4)
} else {
Modifier
},
),
)
}
}
}
}
@ -111,6 +138,7 @@ private fun ListItemWithIconPreview_Light(
ListItemWithIcon(
title = config.title,
subtitle = config.subtitle,
info = config.info,
subtitleIconRes = config.iconRes,
onClick = {},
)
@ -126,6 +154,7 @@ private fun ListItemWithIconPreview_Dark(
ListItemWithIcon(
title = config.title,
subtitle = config.subtitle,
info = config.info,
subtitleIconRes = config.iconRes,
onClick = {},
)
@ -135,6 +164,7 @@ private fun ListItemWithIconPreview_Dark(
private data class ListItemWithIconPreviewConfig(
val title: String,
val subtitle: String,
val info: String? = null,
val iconRes: Int? = null,
)
@ -142,7 +172,14 @@ private class ListItemWithIconPreviewProvider : CollectionPreviewParameterProvid
collection = listOf(
ListItemWithIconPreviewConfig(
title = "0x34B4492A412D84A6E606288f3Bd714b89135D4dE",
subtitle = "Wallet",
subtitle = "0.000000000000000000000000000000 BTC",
info = "0.0.0000 at 00:00",
iconRes = R.drawable.ic_arrow_down_24,
),
ListItemWithIconPreviewConfig(
title = "0x34B4492A412D84A6E606288f3Bd714b89135D4dE",
subtitle = "1 BTC",
info = "0.0.0000 at 00:00",
iconRes = R.drawable.ic_arrow_down_24,
),
ListItemWithIconPreviewConfig(

View file

@ -0,0 +1,214 @@
package com.tangem.features.send.impl.presentation.ui.recipient
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.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.collectAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.itemContentType
import androidx.paging.compose.itemKey
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.send.impl.R
import com.tangem.features.send.impl.presentation.domain.SendRecipientListContent
import com.tangem.features.send.impl.presentation.state.SendStates
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
private const val ADDRESS_FIELD_KEY = "ADDRESS_FIELD_KEY"
private const val MEMO_FIELD_KEY = "MEMO_FIELD_KEY"
private const val MY_WALLETS_HEADER_KEY = "MY_WALLETS_HEADER_KEY"
@Composable
internal fun SendRecipientContent(
uiState: SendStates.RecipientState?,
clickIntents: SendClickIntents,
recipientList: LazyPagingItems<SendRecipientListContent>,
) {
if (uiState == null) return
val address = uiState.addressTextField.collectAsState().value
val memo = uiState.memoTextField?.collectAsState()?.value
LazyColumn(
modifier = Modifier
.fillMaxSize()
.background(TangemTheme.colors.background.tertiary)
.padding(horizontal = TangemTheme.dimens.spacing16),
) {
item(key = ADDRESS_FIELD_KEY) {
TextFieldWithPasteAndIcon(
value = address.value,
label = address.label,
placeholder = address.placeholder,
footer = stringResource(R.string.send_recipient_address_footer, uiState.network),
onValueChange = address.onValueChange,
onPasteClick = clickIntents::onRecipientAddressValueChange,
singleLine = true,
modifier = Modifier.padding(top = TangemTheme.dimens.spacing4),
isError = address.isError,
error = address.error,
)
}
memo?.let { memoField ->
item(key = MEMO_FIELD_KEY) {
TextFieldWithPaste(
value = memoField.value,
label = memoField.label,
placeholder = memoField.placeholder,
footer = stringResource(R.string.send_recipient_memo_footer),
onValueChange = memoField.onValueChange,
onPasteClick = clickIntents::onRecipientMemoValueChange,
modifier = Modifier.padding(top = TangemTheme.dimens.spacing20),
isError = memoField.isError,
error = memoField.error,
)
}
}
recipientListItem(
recipientList = recipientList,
clickIntents = clickIntents,
)
}
}
@Suppress("LongMethod")
@OptIn(ExperimentalFoundationApi::class)
private fun LazyListScope.recipientListItem(
recipientList: LazyPagingItems<SendRecipientListContent>,
clickIntents: SendClickIntents,
) {
items(
count = recipientList.itemCount,
key = recipientList.itemKey {
when (it) {
is SendRecipientListContent.Wallets -> MY_WALLETS_HEADER_KEY
is SendRecipientListContent.Item -> it.id
}
},
contentType = recipientList.itemContentType { it::class.java },
) { index ->
recipientList[index]?.let { item ->
when (item) {
is SendRecipientListContent.Wallets -> {
RecipientWalletListItem(
item = item,
clickIntents = clickIntents,
modifier = Modifier
.animateItemPlacement()
.padding(top = TangemTheme.dimens.spacing20)
.then(
if (index == 0) {
val bottomRadius = if (item.isWalletsOnly) {
TangemTheme.dimens.radius12
} else {
TangemTheme.dimens.radius0
}
Modifier.clip(
RoundedCornerShape(
topEnd = TangemTheme.dimens.radius12,
topStart = TangemTheme.dimens.radius12,
bottomStart = bottomRadius,
bottomEnd = bottomRadius,
),
)
} else {
Modifier
},
),
)
}
is SendRecipientListContent.Item -> {
val title = item.title.resolveReference()
ListItemWithIcon(
title = item.title.resolveReference(),
subtitle = item.subtitle.resolveReference(),
info = item.info?.let { ", ${it.resolveReference()}" },
subtitleIconRes = item.subtitleIconRes,
modifier = Modifier
.then(
if (index == recipientList.itemCount - 1) {
Modifier
.padding(bottom = TangemTheme.dimens.spacing20)
.clip(
RoundedCornerShape(
bottomEnd = TangemTheme.dimens.radius12,
bottomStart = TangemTheme.dimens.radius12,
),
)
} else {
Modifier
},
)
.background(TangemTheme.colors.background.action),
onClick = { clickIntents.onRecipientAddressValueChange(title) },
)
}
}
}
}
}
@Composable
private fun RecipientWalletListItem(
item: SendRecipientListContent.Wallets,
clickIntents: SendClickIntents,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.background(TangemTheme.colors.background.action)
.padding(top = TangemTheme.dimens.spacing12),
) {
if (item.list.isNotEmpty()) {
Text(
text = stringResource(R.string.send_recipient_wallets_title),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
modifier = Modifier
.fillMaxWidth()
.padding(
start = TangemTheme.dimens.spacing12,
end = TangemTheme.dimens.spacing12,
bottom = TangemTheme.dimens.spacing8,
),
)
}
item.list.forEachIndexed { _, wallet ->
val title = wallet.title.resolveReference()
ListItemWithIcon(
title = wallet.title.resolveReference(),
subtitle = wallet.subtitle.resolveReference(),
onClick = { clickIntents.onRecipientAddressValueChange(title) },
)
}
if (!item.isWalletsOnly) {
val topPadding = if (item.list.isNotEmpty()) {
TangemTheme.dimens.spacing8
} else {
TangemTheme.dimens.spacing0
}
Text(
text = stringResource(R.string.send_recent_transactions),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
modifier = Modifier
.fillMaxWidth()
.padding(
top = topPadding,
bottom = TangemTheme.dimens.spacing8,
start = TangemTheme.dimens.spacing12,
end = TangemTheme.dimens.spacing12,
),
)
}
}
}

View file

@ -6,6 +6,7 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
@ -23,14 +24,15 @@ import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import com.tangem.core.ui.components.SpacerH8
import com.tangem.core.ui.components.icons.identicon.IdentIcon
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.send.impl.R
import com.tangem.features.send.impl.presentation.ui.common.FooterContainer
@Composable
internal fun TextFieldWithPasteAndIcon(
@ -42,7 +44,14 @@ internal fun TextFieldWithPasteAndIcon(
modifier: Modifier = Modifier,
footer: String? = null,
singleLine: Boolean = false,
error: TextReference? = null,
isError: Boolean = false,
) {
val (title, color) = if (isError && error != null) {
error to TangemTheme.colors.text.warning
} else {
label to TangemTheme.colors.text.secondary
}
FooterContainer(modifier, footer) {
Column(
modifier = Modifier
@ -53,9 +62,9 @@ internal fun TextFieldWithPasteAndIcon(
),
) {
Text(
text = label.resolveReference(),
text = title.resolveReference(),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.secondary,
color = color,
modifier = Modifier
.padding(
start = TangemTheme.dimens.spacing12,
@ -114,7 +123,14 @@ internal fun TextFieldWithPaste(
onPasteClick: (String) -> Unit,
modifier: Modifier = Modifier,
footer: String? = null,
error: TextReference? = null,
isError: Boolean = false,
) {
val (title, color) = if (isError && error != null) {
error to TangemTheme.colors.text.warning
} else {
label to TangemTheme.colors.text.secondary
}
FooterContainer(modifier, footer) {
Row(
modifier = Modifier
@ -129,9 +145,9 @@ internal fun TextFieldWithPaste(
.padding(TangemTheme.dimens.spacing12),
) {
Text(
text = label.resolveReference(),
text = title.resolveReference(),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.secondary,
color = color,
)
SimpleTextField(
value = value,
@ -160,6 +176,9 @@ internal fun TextFieldWithInfo(
modifier: Modifier = Modifier,
info: TextReference? = null,
footer: String? = null,
isSingleLine: Boolean = false,
visualTransformation: VisualTransformation = VisualTransformation.None,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
) {
FooterContainer(
footer = footer,
@ -189,6 +208,9 @@ internal fun TextFieldWithInfo(
SimpleTextField(
value = value,
onValueChange = onValueChange,
visualTransformation = visualTransformation,
singleLine = isSingleLine,
keyboardOptions = keyboardOptions,
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing6)
.weight(1f),
@ -208,27 +230,6 @@ internal fun TextFieldWithInfo(
}
}
@Composable
private fun FooterContainer(
modifier: Modifier = Modifier,
footer: String? = null,
footerTopPadding: Dp = TangemTheme.dimens.spacing8,
content: @Composable () -> Unit,
) {
Column(modifier = modifier) {
content()
footer?.let {
Text(
text = it,
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
modifier = Modifier
.padding(top = footerTopPadding),
)
}
}
}
@Composable
private fun PasteButton(isPasteButtonVisible: Boolean, onClick: (String) -> Unit, modifier: Modifier = Modifier) {
val clipboardManager = LocalClipboardManager.current
@ -287,14 +288,18 @@ private fun SimpleTextField(
modifier: Modifier = Modifier,
placeholder: TextReference? = null,
singleLine: Boolean = false,
visualTransformation: VisualTransformation = VisualTransformation.None,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
) {
val focusRequester = remember { FocusRequester() }
BasicTextField(
value = value,
onValueChange = onValueChange,
textStyle = TangemTheme.typography.body2,
textStyle = TangemTheme.typography.body2.copy(color = TangemTheme.colors.text.primary1),
cursorBrush = SolidColor(TangemTheme.colors.text.primary1),
singleLine = singleLine,
visualTransformation = visualTransformation,
keyboardOptions = keyboardOptions,
decorationBox = { textValue ->
Box {
if (value.isBlank() && placeholder != null) {

View file

@ -0,0 +1,18 @@
package com.tangem.features.send.impl.presentation.viewmodel
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.address.Address
import com.tangem.domain.tokens.model.CryptoCurrency
internal fun verifyAddress(address: String, cryptoCurrency: CryptoCurrency?): Boolean {
if (address.isEmpty()) return true
val blockchain = cryptoCurrency?.let {
Blockchain.fromId(cryptoCurrency.id.rawNetworkId)
} ?: return false
return blockchain.validateAddress(address)
}
internal fun isNotAddressInWallet(walletAddresses: Set<Address>, address: String): Boolean {
return walletAddresses.all { it.value != address }
}

View file

@ -0,0 +1,46 @@
package com.tangem.features.send.impl.presentation.viewmodel
import androidx.core.text.isDigitsOnly
import com.tangem.blockchain.common.Blockchain
import com.tangem.domain.tokens.model.CryptoCurrency
import java.math.BigInteger
internal fun validateMemo(memo: String, cryptoCurrency: CryptoCurrency?): Boolean {
if (cryptoCurrency == null) return false
return when (cryptoCurrency.network.id.value) {
Blockchain.XRP.id -> {
val tag = memo.toLongOrNull()
tag != null && tag <= XRP_TAG_MAX_NUMBER
}
Blockchain.Stellar.id -> {
isAssignableValue(memo)
}
else -> true
}
}
private fun isAssignableValue(value: String): Boolean {
val memoType = when {
value.isNotEmpty() && value.isDigitsOnly() -> XlmMemoType.ID
else -> XlmMemoType.TEXT
}
return when (memoType) {
XlmMemoType.TEXT -> {
// from org.stellar.sdk.MemoText
value.toByteArray().size <= XLM_MEMO_MAX_LENGTH
}
XlmMemoType.ID -> {
try {
// from com.tangem.blockchain.blockchains.stellar.StellarMemo.toStellarSdkMemo
value.toBigInteger() in BigInteger.ZERO..Long.MAX_VALUE.toBigInteger() * 2.toBigInteger()
} catch (ex: NumberFormatException) {
false
}
}
}
}
private enum class XlmMemoType { TEXT, ID }
private const val XRP_TAG_MAX_NUMBER = 4294967295
private const val XLM_MEMO_MAX_LENGTH = 28

View file

@ -1,14 +1,36 @@
package com.tangem.features.send.impl.presentation.viewmodel
import com.tangem.features.send.impl.presentation.state.fee.FeeType
interface SendClickIntents {
fun onBackClick()
fun onNextClick()
fun onPrevClick()
fun onQrCodeScanClick()
// region Amount
fun onAmountValueChange(value: String)
fun onCurrencyChangeClick(isFiat: Boolean)
fun onMaxValueClick()
// endregion
// region Recipient
fun onRecipientAddressValueChange(value: String)
fun onRecipientMemoValueChange(value: String)
// endregion
// region Fee
fun onFeeSelectorClick(feeType: FeeType)
fun onCustomFeeValueChange(index: Int, value: String)
fun onSubtractSelect(value: Boolean)
// endregion
}

View file

@ -4,32 +4,63 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.*
import androidx.paging.PagingData
import arrow.core.getOrElse
import com.tangem.blockchain.blockchains.xrp.XrpAddressService
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.address.Address
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.common.Provider
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.GetCryptoCurrenciesUseCase
import com.tangem.domain.tokens.GetCurrencyStatusUpdatesUseCase
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.transaction.usecase.GetFeeUseCase
import com.tangem.domain.txhistory.models.TxHistoryItem
import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsCountUseCase
import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsUseCase
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.send.api.navigation.SendRouter
import com.tangem.features.send.impl.presentation.domain.AvailableWallet
import com.tangem.features.send.impl.presentation.state.SendStateFactory
import com.tangem.features.send.impl.presentation.state.SendUiState
import com.tangem.features.send.impl.presentation.state.SendUiStateType
import com.tangem.features.send.impl.presentation.state.StateRouter
import com.tangem.features.send.impl.presentation.state.fee.FeeSelectorState
import com.tangem.features.send.impl.presentation.state.fee.FeeType
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import java.math.BigDecimal
import javax.inject.Inject
import kotlin.properties.Delegates
@Suppress("LongParameterList")
@HiltViewModel
internal class SendViewModel @Inject constructor(
private val dispatchers: CoroutineDispatcherProvider,
private val getUserWalletUseCase: GetUserWalletUseCase,
private val getCurrencyStatusUpdatesUseCase: GetCurrencyStatusUpdatesUseCase,
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val getWalletsUseCase: GetWalletsUseCase,
private val getCryptoCurrenciesUseCase: GetCryptoCurrenciesUseCase,
private val txHistoryItemsUseCase: GetTxHistoryItemsUseCase,
private val txHistoryItemsCountUseCase: GetTxHistoryItemsCountUseCase,
private val getFeeUseCase: GetFeeUseCase,
private val walletManagersFacade: WalletManagersFacade,
savedStateHandle: SavedStateHandle,
) : ViewModel(), DefaultLifecycleObserver, SendClickIntents {
@ -42,26 +73,42 @@ internal class SendViewModel @Inject constructor(
private val selectedAppCurrencyFlow: StateFlow<AppCurrency> = createSelectedAppCurrencyFlow()
private var innerRouter: StateRouter by Delegates.notNull()
private val stateFactory = SendStateFactory(
clickIntents = this,
currentStateProvider = Provider { uiState },
userWalletProvider = Provider { userWallet },
walletAddressesProvider = Provider { walletAddresses },
appCurrencyProvider = Provider(selectedAppCurrencyFlow::value),
cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus },
)
var uiState: SendUiState by mutableStateOf(stateFactory.getInitialState())
private set
private var userWallet: UserWallet? = null
private var userWallet: UserWallet by Delegates.notNull()
private var cryptoCurrencyStatus: CryptoCurrencyStatus by Delegates.notNull()
private var walletAddresses = emptySet<Address>()
private var balanceJobHolder = JobHolder()
private var recipientsJobHolder = JobHolder()
private var walletAddressesJobHolder = JobHolder()
private var feeJobHolder = JobHolder()
override fun onCreate(owner: LifecycleOwner) {
getWalletAddresses()
subscribeOnCurrencyStatusUpdates(owner)
getFee()
}
fun setRouter(router: StateRouter) {
innerRouter = router
uiState = uiState.copy(currentState = router.currentState)
}
private fun subscribeOnCurrencyStatusUpdates(owner: LifecycleOwner) {
viewModelScope.launch(dispatchers.io) {
viewModelScope.launch(dispatchers.main) {
getUserWalletUseCase(userWalletId).fold(
ifRight = { wallet ->
userWallet = wallet
@ -86,11 +133,13 @@ internal class SendViewModel @Inject constructor(
.conflate()
.distinctUntilChanged()
.onEach { either ->
uiState = stateFactory.getAmountState(
cryptoCurrencyStatus = either,
)
either.onRight {
cryptoCurrencyStatus = it
getWalletsAndRecent()
uiState = stateFactory.getReadyState()
}
}
.flowOn(dispatchers.io)
.flowOn(dispatchers.main)
.launchIn(viewModelScope)
.saveIn(balanceJobHolder)
}
@ -107,27 +156,127 @@ internal class SendViewModel @Inject constructor(
)
}
// region screen state navigation
override fun onNextClick() {
when (uiState) {
is SendUiState.Content.AmountState -> onRecipientStateClick()
is SendUiState.Content.RecipientState -> onFeeStateClick()
else -> {
// todo implement
private fun getWalletsAndRecent() {
combine(
flow = getUserWallets().conflate(),
flow2 = getTxHistory().conflate(),
flow3 = getTxHistoryCount().conflate(),
) { wallets, txHistory, txHistoryCount ->
stateFactory.onLoadedRecipientList(
wallets = wallets,
txHistory = txHistory,
txHistoryCount = txHistoryCount,
)
}
.flowOn(dispatchers.io)
.launchIn(viewModelScope)
.saveIn(recipientsJobHolder)
}
private fun getUserWallets(): Flow<List<AvailableWallet?>> {
return getWalletsUseCase()
.distinctUntilChanged()
.map { userWallets ->
coroutineScope {
userWallets
.filterNot { it.walletId == userWalletId || it.isLocked }
.map { wallet ->
async(dispatchers.io) {
getCryptoCurrenciesUseCase(wallet.walletId)
.fold(
ifRight = { currencyItem ->
val walletCurrency = currencyItem.firstOrNull {
it.network.id == cryptoCurrency.network.id
} ?: return@fold null
val addresses = walletManagersFacade.getAddress(
userWalletId = wallet.walletId,
network = walletCurrency.network,
)
return@fold AvailableWallet(
name = wallet.name,
address = addresses.first().value,
)
},
ifLeft = { null },
)
}
}
}.awaitAll()
}
}
private fun getTxHistory(): Flow<PagingData<TxHistoryItem>> {
return flow {
txHistoryItemsUseCase(
userWalletId = userWalletId,
currency = cryptoCurrency,
).fold(
ifRight = { emitAll(it.distinctUntilChanged()) },
ifLeft = {},
)
}
}
override fun onPrevClick() {
// todo implement
private fun getTxHistoryCount(): Flow<Int> {
return flow {
txHistoryItemsCountUseCase(
userWalletId = userWalletId,
currency = cryptoCurrency,
).fold(
ifRight = { emit(it) },
ifLeft = { emit(0) },
)
}
}
private fun onRecipientStateClick() {
stateFactory.getOnReceiveState()
private fun getFee() {
viewModelScope.launch(dispatchers.main) {
uiState.currentState
.filter { it == SendUiStateType.Fee }
.onEach {
val amountState = uiState.amountState ?: return@onEach
val recipientState = uiState.recipientState ?: return@onEach
stateFactory.onFeeOnLoadingState()
getFeeUseCase.invoke(
amount = amountState.amountTextField.value.value.toBigDecimal(),
destination = recipientState.addressTextField.value.value,
userWalletId = userWalletId,
cryptoCurrency = cryptoCurrency,
)
.conflate()
.distinctUntilChanged()
.onEach { maybeFee ->
maybeFee.fold(
ifRight = {
stateFactory.onFeeOnLoadedState(it)
},
ifLeft = {
// TODO add error handling
},
)
}
.launchIn(viewModelScope)
}.launchIn(viewModelScope)
}.saveIn(feeJobHolder)
}
private fun onFeeStateClick() {
// todo implement
private fun getWalletAddresses() {
viewModelScope.launch(dispatchers.io) {
walletAddresses = walletManagersFacade.getAddresses(
userWalletId = userWalletId,
network = cryptoCurrency.network,
)
}.saveIn(walletAddressesJobHolder)
}
// region screen state navigation
override fun onBackClick() = innerRouter.onBackClick()
override fun onNextClick() = innerRouter.onNextClick()
override fun onPrevClick() = innerRouter.onPrevClick()
override fun onQrCodeScanClick() {
// TODO Add QR code scanning
}
// endregion
@ -141,14 +290,100 @@ internal class SendViewModel @Inject constructor(
}
override fun onMaxValueClick() {
val amountState = uiState as? SendUiState.Content.AmountState ?: return
val amountState = uiState.amountState ?: return
val amount = if (amountState.isFiatValue) {
amountState.cryptoCurrencyStatus.value.fiatAmount
} else {
amountState.cryptoCurrencyStatus.value.amount
}
onAmountValueChange(amount?.toPlainString() ?: "0.00")
onAmountValueChange(amount?.toPlainString() ?: DEFAULT_VALUE)
}
// endregion
// region recipient state clicks
override fun onRecipientAddressValueChange(value: String) {
if (!checkIfXrpAddressValue(value)) {
uiState = stateFactory.getOnRecipientAddressValueChangeState(value)
}
}
override fun onRecipientMemoValueChange(value: String) {
if (!checkIfXrpAddressValue(value)) {
uiState = stateFactory.getOnRecipientMemoValueChangeState(value)
}
}
private fun checkIfXrpAddressValue(value: String): Boolean {
if (cryptoCurrency.network.id.value == Blockchain.XRP.id && value.first() == XRP_X_ADDRESS) {
viewModelScope.launch(dispatchers.io) {
val result = XrpAddressService.decodeXAddress(value)
onRecipientAddressValueChange(result?.address.orEmpty())
onRecipientMemoValueChange(result?.destinationTag.toString())
}
return true
}
return false
}
// endregion
//region fee
override fun onFeeSelectorClick(feeType: FeeType) {
stateFactory.onFeeSelectedState(feeType)
updateReceiveAmount()
}
override fun onCustomFeeValueChange(index: Int, value: String) {
uiState.feeState?.apply {
(feeSelectorState.value as? FeeSelectorState.Content)?.let { feeSelector ->
feeSelector.customValues.update {
it.toMutableList().apply {
set(index, it[index].copy(value = value))
}
}
updateReceiveAmount()
}
}
}
override fun onSubtractSelect(value: Boolean) {
uiState.feeState?.isSubtract?.update { value }
if (value) {
updateReceiveAmount()
}
}
private fun updateReceiveAmount() {
uiState.feeState?.receivedAmount?.update {
BigDecimalFormatter.formatCryptoAmount(
cryptoAmount = calculateReceiveAmount(),
cryptoCurrency = cryptoCurrency.symbol,
decimals = cryptoCurrency.decimals,
)
}
}
private fun calculateReceiveAmount(): BigDecimal {
val feeState = uiState.feeState?.feeSelectorState?.value as? FeeSelectorState.Content ?: return BigDecimal.ZERO
val amount = uiState.amountState?.amountTextField?.value ?: return BigDecimal.ZERO
val fee = when (val selectedFee = feeState.fees) {
is TransactionFee.Choosable -> {
when (feeState.selectedFee) {
FeeType.SLOW -> selectedFee.minimum.amount.value
FeeType.MARKET -> selectedFee.normal.amount.value
FeeType.FAST -> selectedFee.priority.amount.value
FeeType.CUSTOM -> feeState.customValues.value.firstOrNull()?.value?.let { BigDecimal(it) }
}
}
is TransactionFee.Single -> selectedFee.normal.amount.value
} ?: BigDecimal.ZERO
return BigDecimal(amount.value).minus(fee)
}
//endregion
companion object {
private const val XRP_X_ADDRESS = 'X'
private const val DEFAULT_VALUE = "0.00"
}
}

View file

@ -20,6 +20,7 @@ import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
@ -217,6 +218,7 @@ private fun TokenItem(token: TokenToSelect, network: Network, screenBackgroundCo
@Composable
private fun TokenIcon(token: TokenToSelect, screenBackgroundColor: Color, @DrawableRes iconPlaceholder: Int?) {
var iconBackgroundColor by remember { mutableStateOf(Color.Transparent) }
var isBackgroundColorDefined by remember { mutableStateOf(false) }
val isDarkTheme = isSystemInDarkTheme()
val coroutineScope = rememberCoroutineScope()
@ -231,29 +233,35 @@ private fun TokenIcon(token: TokenToSelect, screenBackgroundColor: Color, @Drawa
shape = TangemTheme.shapes.roundedCorners8,
),
) {
val iconModifier = Modifier
.size(TangemTheme.dimens.size40)
val pixelsSize = with(LocalDensity.current) { TangemTheme.dimens.size40.roundToPx() }
val iconModifier = Modifier.size(TangemTheme.dimens.size40)
val colorFilter = if (!token.available) {
val matrix = ColorMatrix().apply { setToSaturation(0f) }
ColorFilter.colorMatrix(matrix)
} else {
null
}
SubcomposeAsyncImage(
modifier = iconModifier,
model = ImageRequest.Builder(LocalContext.current)
.data(data)
.size(size = pixelsSize)
.memoryCacheKey(key = data.toString() + pixelsSize)
.crossfade(true)
.allowHardware(false)
.listener(
onSuccess = { _, result ->
if (isDarkTheme) {
if (!isBackgroundColorDefined && isDarkTheme) {
coroutineScope.launch {
val color = ImageBackgroundContrastChecker(
drawable = result.drawable,
backgroundColor = screenBackgroundColor.toArgb(),
).getContrastColorIfNeeded(isDarkTheme)
size = pixelsSize,
).getContrastColor(isDarkTheme = true)
iconBackgroundColor = color
isBackgroundColorDefined = true
}
}
},

View file

@ -20,6 +20,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
@ -292,8 +293,9 @@ private fun TokenIcon(
@DrawableRes iconPlaceholder: Int? = null,
@DrawableRes networkIconRes: Int? = null,
) {
val itemBackgroundColor = TangemTheme.colors.background.primary.toArgb()
var iconBackgroundColor by remember { mutableStateOf(Color.Transparent) }
var isBackgroundColorDefined by remember { mutableStateOf(false) }
val itemBackgroundColor = TangemTheme.colors.background.primary.toArgb()
val isDarkTheme = isSystemInDarkTheme()
val coroutineScope = rememberCoroutineScope()
@ -310,13 +312,16 @@ private fun TokenIcon(
shape = TangemTheme.shapes.roundedCorners8,
)
val data = tokenIconUrl.ifEmpty {
iconPlaceholder
}
val data = tokenIconUrl.ifEmpty { iconPlaceholder }
val pixelsSize = with(LocalDensity.current) { TangemTheme.dimens.size36.roundToPx() }
SubcomposeAsyncImage(
modifier = tokenImageModifier,
model = ImageRequest.Builder(LocalContext.current)
.data(data)
.size(size = pixelsSize)
.memoryCacheKey(key = data.toString() + pixelsSize)
.crossfade(true)
.allowHardware(false)
.listener(
@ -326,8 +331,10 @@ private fun TokenIcon(
val color = ImageBackgroundContrastChecker(
drawable = result.drawable,
backgroundColor = itemBackgroundColor,
).getContrastColorIfNeeded(isDarkTheme)
size = pixelsSize,
).getContrastColor(true)
iconBackgroundColor = color
isBackgroundColorDefined = true
}
}
},

View file

@ -1,23 +1,19 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.txhistory
import android.text.format.DateUtils
import androidx.paging.*
import com.tangem.common.Provider
import com.tangem.core.ui.components.transactions.state.TransactionState
import com.tangem.core.ui.components.transactions.state.TxHistoryState
import com.tangem.core.ui.components.transactions.state.TxHistoryState.TxHistoryItemState
import com.tangem.core.ui.utils.DateTimeFormatters
import com.tangem.core.ui.utils.toDateFormat
import com.tangem.core.ui.utils.toTimeFormat
import com.tangem.domain.txhistory.models.TxHistoryItem
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState
import com.tangem.feature.tokendetails.presentation.tokendetails.viewmodels.TokenDetailsClickIntents
import com.tangem.utils.converter.Converter
import com.tangem.utils.extensions.isToday
import com.tangem.utils.extensions.isYesterday
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.*
import org.joda.time.DateTime
import org.joda.time.DateTimeZone
import java.util.UUID
internal class TokenDetailsTxHistoryItemFlowConverter(
@ -108,7 +104,7 @@ internal class TokenDetailsTxHistoryItemFlowConverter(
) {
val txContent = txHistoryItemState.state as TransactionState.Content
txHistoryItemState.copy(
state = txContent.copy(timestamp = txContent.timestamp.toTimeFormat()),
state = txContent.copy(timestamp = txContent.timestamp.toLong().toTimeFormat()),
)
} else {
txHistoryItemState
@ -124,26 +120,4 @@ internal class TokenDetailsTxHistoryItemFlowConverter(
null
}
}
/**
* If [this] timestamp is today or yesterday, returns relative date,
* otherwise returns formatting date.
*/
private fun Long.toDateFormat(): String {
val localDate = DateTime(this, DateTimeZone.getDefault())
return if (localDate.isToday() || localDate.isYesterday()) {
DateUtils.getRelativeTimeSpanString(
this,
DateTime.now().millis,
DateUtils.DAY_IN_MILLIS,
DateUtils.FORMAT_ABBREV_RELATIVE,
).toString()
} else {
DateTimeFormatters.formatDate(date = localDate)
}
}
private fun String.toTimeFormat(): String {
return DateTimeFormatters.formatTime(time = DateTime(this.toLong(), DateTimeZone.getDefault()))
}
}

View file

@ -14,6 +14,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import coil.compose.SubcomposeAsyncImage
import coil.request.ImageRequest
@ -136,11 +137,13 @@ private inline fun DefaultCurrencyIcon(
crossinline errorIcon: @Composable () -> Unit,
modifier: Modifier = Modifier,
) {
val itemBackgroundColor = TangemTheme.colors.background.primary.toArgb()
var iconBackgroundColor by remember { mutableStateOf(Color.Transparent) }
var isBackgroundColorDefined by remember { mutableStateOf(false) }
val itemBackgroundColor = TangemTheme.colors.background.primary.toArgb()
val isDarkTheme = isSystemInDarkTheme()
val coroutineScope = rememberCoroutineScope()
val pixelsSize = with(LocalDensity.current) { TangemTheme.dimens.size48.roundToPx() }
SubcomposeAsyncImage(
modifier = modifier
.background(
@ -149,17 +152,21 @@ private inline fun DefaultCurrencyIcon(
),
model = ImageRequest.Builder(context = LocalContext.current)
.data(iconData)
.size(pixelsSize)
.memoryCacheKey(iconData.toString() + pixelsSize)
.crossfade(enable = true)
.allowHardware(false)
.listener(
onSuccess = { _, result ->
if (isDarkTheme) {
if (!isBackgroundColorDefined && isDarkTheme) {
coroutineScope.launch {
val color = ImageBackgroundContrastChecker(
drawable = result.drawable,
backgroundColor = itemBackgroundColor,
).getContrastColorIfNeeded(isDarkTheme)
size = pixelsSize,
).getContrastColor(isDarkTheme = true)
iconBackgroundColor = color
isBackgroundColorDefined = true
}
}
},