From 96f5aa8238a5e57af1100a4d4913014affdbb8a8 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 10 Nov 2023 21:52:01 +0300 Subject: [PATCH] Updated on 2026-08-14 --- core/res/src/main/res/values-ru/strings.xml | 4 +- core/res/src/main/res/values/strings.xml | 2 +- core/ui/build.gradle.kts | 1 + .../appbar/AppBarWithBackButtonAndIcon.kt | 103 +++++++++ .../fields/AmountVisualTransformation.kt | 32 +++ .../core/ui/utils/DateTimeFormatters.kt | 13 +- .../com/tangem/core/ui/utils/DateUtils.kt | 33 +++ .../main/res/drawable/ic_qrcode_scan_24.xml | 13 ++ .../DefaultWalletManagersFacade.kt | 5 +- .../walletmanager/WalletManagersFacade.kt | 7 + features/send/impl/build.gradle.kts | 13 +- .../send/impl/presentation/SendFragment.kt | 44 ++-- .../presentation/domain/AvailableWallet.kt | 15 ++ .../domain/SendRecipientListContent.kt | 21 ++ .../presentation/state/SendStateFactory.kt | 133 ++++++++++-- .../impl/presentation/state/SendUiState.kt | 102 ++++----- .../impl/presentation/state/StateRouter.kt | 39 ++++ .../state/amount/SendAmountStateConverter.kt | 73 +++---- .../fields/SendAmountFieldChangeConverter.kt | 60 +++--- .../state/fields/SendAmountFieldConverter.kt | 1 - .../state/fields/SendTextField.kt | 37 +++- .../SendRecipientAddressFieldConverter.kt | 31 +++ .../recipient/SendRecipientListConverter.kt | 113 ++++++++++ .../SendRecipientMemoFieldConverter.kt | 51 +++++ .../recipient/SendRecipientStateConverter.kt | 30 +++ .../presentation/ui/SendNavigationButtons.kt | 48 +++-- .../send/impl/presentation/ui/SendScreen.kt | 96 ++++++--- .../presentation/ui/amount/AmountField.kt | 31 +-- .../ui/amount/AmountFieldContainer.kt | 10 +- .../ui/{ => amount}/SendAmountContent.kt | 29 +-- .../presentation/ui/common/FooterContainer.kt | 38 ++++ .../ui/recipient/ListItemWithIcon.kt | 135 +++++++----- .../ui/recipient/SendRecipientContent.kt | 199 ++++++++++++++++++ .../presentation/ui/recipient/TextFields.kt | 50 +++-- .../viewmodel/AddressVerification.kt | 18 ++ .../viewmodel/MemoVerification.kt | 46 ++++ .../viewmodel/SendClickIntents.kt | 12 ++ .../presentation/viewmodel/SendViewModel.kt | 167 +++++++++++++-- .../TokenDetailsTxHistoryItemFlowConverter.kt | 32 +-- gradle/dependencies.toml | 4 +- 40 files changed, 1503 insertions(+), 388 deletions(-) create mode 100644 core/ui/src/main/java/com/tangem/core/ui/components/appbar/AppBarWithBackButtonAndIcon.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/components/fields/AmountVisualTransformation.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/utils/DateUtils.kt create mode 100644 core/ui/src/main/res/drawable/ic_qrcode_scan_24.xml create mode 100644 features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/domain/AvailableWallet.kt create mode 100644 features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/domain/SendRecipientListContent.kt create mode 100644 features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/StateRouter.kt create mode 100644 features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/recipient/SendRecipientAddressFieldConverter.kt create mode 100644 features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/recipient/SendRecipientListConverter.kt create mode 100644 features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/recipient/SendRecipientMemoFieldConverter.kt create mode 100644 features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/recipient/SendRecipientStateConverter.kt rename features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/{ => amount}/SendAmountContent.kt (75%) create mode 100644 features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/common/FooterContainer.kt create mode 100644 features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/recipient/SendRecipientContent.kt create mode 100644 features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/viewmodel/AddressVerification.kt create mode 100644 features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/viewmodel/MemoVerification.kt diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml index 2af2ae2c68..52aa5dfca4 100644 --- a/core/res/src/main/res/values-ru/strings.xml +++ b/core/res/src/main/res/values-ru/strings.xml @@ -395,7 +395,7 @@ Сумма к получению %1$s Адрес Код назначения - %1$s в %2$s + %1$s в %2$s Введите адрес Адрес совпадает с адресом кошелька Недопустимый Tag. Он не будет добавлен в транзакцию. @@ -427,7 +427,7 @@ Включенная комиссия превышает сумму перевода, что приводит к отрицательному значению. Минимальная сумма отправки - %1$s. Пожалуйста, убедитесь, что остаток после отправки также не будет меньше %1$s. Сумма резерва не может быть менее %1$s. - Пожалуйста, пополните свой баланс, чтобы продолжить. + Пожалуйста, пополните свой баланс, чтобы продолжить. Увеличение комиссии Комиссия при переводе всего баланса выше. Для того, чтобы снизить комиссию Вы можете оставить 0.01. Комиссия превышает баланс diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 020371fd62..09f3ec83d1 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -395,7 +395,7 @@ The recipient will receive %1$s Address Destination Tag - %1$s at %2$s + %1$s at %2$s Enter address Address is the same as wallet address Invalid Tag. It won\'t be added to the transaction. diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 5868a39017..ab9b42cd1f 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -14,6 +14,7 @@ dependencies { /** Project - Core */ implementation(projects.core.res) + implementation(projects.core.utils) /** AndroidX libraries */ implementation(deps.androidx.fragment.ktx) diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/appbar/AppBarWithBackButtonAndIcon.kt b/core/ui/src/main/java/com/tangem/core/ui/components/appbar/AppBarWithBackButtonAndIcon.kt new file mode 100644 index 0000000000..73509da21f --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/appbar/AppBarWithBackButtonAndIcon.kt @@ -0,0 +1,103 @@ +package com.tangem.core.ui.components.appbar + +import androidx.annotation.DrawableRes +import androidx.compose.animation.* +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import com.tangem.core.ui.R +import com.tangem.core.ui.res.TangemTheme + +@Composable +fun AppBarWithBackButtonAndIcon( + onBackClick: () -> Unit, + modifier: Modifier = Modifier, + text: String? = null, + @DrawableRes backIconRes: Int? = null, + @DrawableRes iconRes: Int? = null, + onIconClick: (() -> Unit)? = null, + backgroundColor: Color = TangemTheme.colors.background.secondary, +) { + Row( + modifier = modifier + .background(color = backgroundColor) + .fillMaxWidth() + .padding(all = TangemTheme.dimens.spacing16), + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing16), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painter = painterResource(backIconRes ?: R.drawable.ic_back_24), + contentDescription = null, + modifier = Modifier + .size(size = TangemTheme.dimens.size24) + .clickable { onBackClick() }, + tint = TangemTheme.colors.icon.primary1, + ) + AnimatedContent( + targetState = text, + modifier = Modifier.weight(1f), + transitionSpec = { fadeIn().togetherWith(fadeOut()) }, + label = "Toolbar title change", + ) { + if (!it.isNullOrBlank()) { + Text( + text = it, + color = TangemTheme.colors.text.primary1, + maxLines = 1, + style = TangemTheme.typography.subtitle1, + ) + } + } + AnimatedContent( + targetState = iconRes, + transitionSpec = { (fadeIn() + scaleIn()).togetherWith(fadeOut() + scaleOut()) }, + label = "Toolbar icon change", + ) { + if (onIconClick != null && it != null) { + Icon( + painter = painterResource(it), + contentDescription = null, + modifier = Modifier + .size(size = TangemTheme.dimens.size24) + .clickable { onIconClick() }, + tint = TangemTheme.colors.icon.primary1, + ) + } + } + } +} + +@Preview(widthDp = 360, heightDp = 56, showBackground = true) +@Composable +private fun PreviewAppBarWithBackButtonAndIconInLightTheme() { + TangemTheme(isDark = false) { + AppBarWithBackButtonAndIcon( + text = "Title", + iconRes = R.drawable.ic_qrcode_scan_24, + onBackClick = {}, + onIconClick = {}, + ) + } +} + +@Preview(widthDp = 360, heightDp = 56, showBackground = true) +@Composable +private fun PreviewAppBarWithBackButtonAndIconInDarkTheme() { + TangemTheme(isDark = true) { + AppBarWithBackButtonAndIcon( + text = "Title", + iconRes = R.drawable.ic_qrcode_scan_24, + onBackClick = {}, + onIconClick = {}, + ) + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/fields/AmountVisualTransformation.kt b/core/ui/src/main/java/com/tangem/core/ui/components/fields/AmountVisualTransformation.kt new file mode 100644 index 0000000000..89e4fa09ce --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/fields/AmountVisualTransformation.kt @@ -0,0 +1,32 @@ +package com.tangem.core.ui.components.fields + +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 + +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 + } + }, + ) + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/utils/DateTimeFormatters.kt b/core/ui/src/main/java/com/tangem/core/ui/utils/DateTimeFormatters.kt index 80e9488f0a..dc27c1641a 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/utils/DateTimeFormatters.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/utils/DateTimeFormatters.kt @@ -9,12 +9,14 @@ import java.util.Locale @Suppress("MagicNumber") object DateTimeFormatters { + private const val DDMMYYYY = "dd.MM.yyyy" + /** * Two SS means, SHORT style for date and time. * If pattern contains "a", it means time is in 12 hour format. * [Documentation](https://www.joda.org/joda-time/apidocs/org/joda/time/format/DateTimeFormat.html) */ - val timeFormatter by lazy { + val timeFormatter: DateTimeFormatter by lazy { val is12HourFormat = DateTimeFormat.patternForStyle("SS", Locale.getDefault()).contains("a") if (is12HourFormat) { DateTimeFormatterBuilder() @@ -35,7 +37,7 @@ object DateTimeFormatters { } } - val dateFormatter by lazy { + val dateFormatter: DateTimeFormatter by lazy { DateTimeFormatterBuilder() .appendDayOfMonth(1) .appendLiteral(' ') @@ -46,6 +48,13 @@ object DateTimeFormatters { .withLocale(Locale.getDefault()) } + val dateDDMMYYYY: DateTimeFormatter by lazy { + DateTimeFormatterBuilder() + .appendPattern(DDMMYYYY) + .toFormatter() + .withLocale(Locale.getDefault()) + } + fun formatTime(formatter: DateTimeFormatter = timeFormatter, time: DateTime): String { return formatter.print(time) } diff --git a/core/ui/src/main/java/com/tangem/core/ui/utils/DateUtils.kt b/core/ui/src/main/java/com/tangem/core/ui/utils/DateUtils.kt new file mode 100644 index 0000000000..b0237e897a --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/utils/DateUtils.kt @@ -0,0 +1,33 @@ +package com.tangem.core.ui.utils + +import android.text.format.DateUtils +import com.tangem.utils.extensions.isToday +import com.tangem.utils.extensions.isYesterday +import org.joda.time.DateTime +import org.joda.time.DateTimeZone +import org.joda.time.format.DateTimeFormatter + +/** + * If [this] timestamp is today or yesterday, returns relative date, + * otherwise returns formatting date. + */ +fun Long.toDateFormat(formatter: DateTimeFormatter = DateTimeFormatters.dateFormatter): 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(formatter = formatter, date = localDate) + } +} + +/** + * Returns formatted time according to [formatter]. + */ +fun Long.toTimeFormat(formatter: DateTimeFormatter = DateTimeFormatters.timeFormatter): String { + return DateTimeFormatters.formatTime(formatter = formatter, time = DateTime(this, DateTimeZone.getDefault())) +} \ No newline at end of file diff --git a/core/ui/src/main/res/drawable/ic_qrcode_scan_24.xml b/core/ui/src/main/res/drawable/ic_qrcode_scan_24.xml new file mode 100644 index 0000000000..7cde4123e0 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_qrcode_scan_24.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/domain/legacy/src/main/java/com/tangem/domain/walletmanager/DefaultWalletManagersFacade.kt b/domain/legacy/src/main/java/com/tangem/domain/walletmanager/DefaultWalletManagersFacade.kt index f9bc744566..bc1bf00b14 100644 --- a/domain/legacy/src/main/java/com/tangem/domain/walletmanager/DefaultWalletManagersFacade.kt +++ b/domain/legacy/src/main/java/com/tangem/domain/walletmanager/DefaultWalletManagersFacade.kt @@ -323,6 +323,10 @@ class DefaultWalletManagersFacade( } override suspend fun getAddress(userWalletId: UserWalletId, network: Network): List
{ + return getAddresses(userWalletId, network).sortedBy { it.type } + } + + override suspend fun getAddresses(userWalletId: UserWalletId, network: Network): Set
{ val blockchain = Blockchain.fromId(network.id.value) return getOrCreateWalletManager( @@ -332,7 +336,6 @@ class DefaultWalletManagersFacade( ) ?.wallet ?.addresses - ?.sortedBy { it.type } .orEmpty() } diff --git a/domain/legacy/src/main/java/com/tangem/domain/walletmanager/WalletManagersFacade.kt b/domain/legacy/src/main/java/com/tangem/domain/walletmanager/WalletManagersFacade.kt index e4817280a7..31345d6f99 100644 --- a/domain/legacy/src/main/java/com/tangem/domain/walletmanager/WalletManagersFacade.kt +++ b/domain/legacy/src/main/java/com/tangem/domain/walletmanager/WalletManagersFacade.kt @@ -114,6 +114,13 @@ interface WalletManagersFacade { */ suspend fun getAddress(userWalletId: UserWalletId, network: Network): List
+ /** Returns list of all addresses for all currencies in selected wallet + * + * @param userWalletId selected wallet id + * @param network required to create wallet manager + */ + suspend fun getAddresses(userWalletId: UserWalletId, network: Network): Set
+ /** * Returns info about rent if wallet manager implemented [RentProvider], otherwise null * diff --git a/features/send/impl/build.gradle.kts b/features/send/impl/build.gradle.kts index 9c3a10e456..65f362206d 100644 --- a/features/send/impl/build.gradle.kts +++ b/features/send/impl/build.gradle.kts @@ -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,15 @@ 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) /** Feature modules */ implementation(projects.features.send.api) diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/SendFragment.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/SendFragment.kt index 61136054f5..2790caab93 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/SendFragment.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/SendFragment.kt @@ -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() + + 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() - 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 { diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/domain/AvailableWallet.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/domain/AvailableWallet.kt new file mode 100644 index 0000000000..69e56bfc62 --- /dev/null +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/domain/AvailableWallet.kt @@ -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, +) \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/domain/SendRecipientListContent.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/domain/SendRecipientListContent.kt new file mode 100644 index 0000000000..6cb3516497 --- /dev/null +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/domain/SendRecipientListContent.kt @@ -0,0 +1,21 @@ +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, + ) : SendRecipientListContent() +} \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendStateFactory.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendStateFactory.kt index d60ad8e28b..87ac690720 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendStateFactory.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendStateFactory.kt @@ -1,22 +1,35 @@ 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.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.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, + private val userWalletProvider: Provider, + private val walletAddressesProvider: Provider>, private val appCurrencyProvider: Provider, - private val userWalletProvider: Provider, + private val cryptoCurrencyStatusProvider: Provider, ) { private val iconStateConverter by lazy(::CryptoCurrencyToIconStateConverter) @@ -27,33 +40,129 @@ internal class SendStateFactory( private val amountStateConverter by lazy { SendAmountStateConverter( - currentStateProvider = currentStateProvider, appCurrencyProvider = appCurrencyProvider, - clickIntents = clickIntents, iconStateConverter = iconStateConverter, userWalletProvider = userWalletProvider, sendAmountFieldConverter = amountFieldConverter, + cryptoCurrencyStatusProvider = cryptoCurrencyStatusProvider, ) } - fun getInitialState(): SendUiState = SendUiState.Content.Initial(clickIntents = clickIntents) - - fun getAmountState(cryptoCurrencyStatus: Either): SendUiState { - return amountStateConverter.convert(cryptoCurrencyStatus) + private val recipientStateConverter by lazy { + SendRecipientStateConverter( + clickIntents = clickIntents, + 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 = SendStates.FeeState(), + ) + //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, txHistory: PagingData) { + recipientListStateConverter.convert( + wallets = wallets, + txHistory = txHistory, + ) + } + + 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, + ), + ) + } + //endregion } \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendUiState.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendUiState.kt index f4c9014b85..6baeb27cfb 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendUiState.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendUiState.kt @@ -1,71 +1,77 @@ 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.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> = MutableStateFlow(PagingData.empty()), + val currentState: MutableStateFlow, +) - /** 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, + val amountTextField: MutableStateFlow, + 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, + val memoTextField: MutableStateFlow?, + val recipients: MutableStateFlow> = 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, - val amountTextField: SendTextField.Amount, - ) : Content() + // todo [REDACTED_JIRA] + /** Fee and speed state */ + data class FeeState( + override val type: SendUiStateType = SendUiStateType.Fee, + ) : 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, } \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/StateRouter.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/StateRouter.kt new file mode 100644 index 0000000000..e7526fdf04 --- /dev/null +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/StateRouter.kt @@ -0,0 +1,39 @@ +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, +) { + var currentState: MutableStateFlow = 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 } + } + else -> { + // todo implement + } + } + } + + fun onPrevClick() { + when (currentState.value) { + SendUiStateType.Amount -> onBackClick() + SendUiStateType.Recipient -> currentState.update { SendUiStateType.Amount } + SendUiStateType.Fee -> currentState.update { SendUiStateType.Recipient } + else -> onBackClick() + } + } +} \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/amount/SendAmountStateConverter.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/amount/SendAmountStateConverter.kt index 3545aeccc6..346cd114aa 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/amount/SendAmountStateConverter.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/amount/SendAmountStateConverter.kt @@ -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, private val appCurrencyProvider: Provider, - private val userWalletProvider: Provider, - private val clickIntents: SendClickIntents, + private val userWalletProvider: Provider, + private val cryptoCurrencyStatusProvider: Provider, private val iconStateConverter: CryptoCurrencyToIconStateConverter, private val sendAmountFieldConverter: SendAmountFieldConverter, -) : Converter, SendUiState> { +) : Converter { - override fun convert(value: Either): 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, + ), + ), ) } } \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fields/SendAmountFieldChangeConverter.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fields/SendAmountFieldChangeConverter.kt index 5c91a7489e..cacb180097 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fields/SendAmountFieldChangeConverter.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fields/SendAmountFieldChangeConverter.kt @@ -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 { 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 = "[.,]" } } \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fields/SendAmountFieldConverter.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fields/SendAmountFieldConverter.kt index d6577f14c2..4b027a624d 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fields/SendAmountFieldConverter.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fields/SendAmountFieldConverter.kt @@ -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), diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fields/SendTextField.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fields/SendTextField.kt index f62f981b67..456359a16c 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fields/SendTextField.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fields/SendTextField.kt @@ -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() } \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/recipient/SendRecipientAddressFieldConverter.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/recipient/SendRecipientAddressFieldConverter.kt new file mode 100644 index 0000000000..75cd69aa6a --- /dev/null +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/recipient/SendRecipientAddressFieldConverter.kt @@ -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> { + + override fun convert(value: Unit): MutableStateFlow { + 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), + ), + ) + } +} \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/recipient/SendRecipientListConverter.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/recipient/SendRecipientListConverter.kt new file mode 100644 index 0000000000..da8a97622d --- /dev/null +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/recipient/SendRecipientListConverter.kt @@ -0,0 +1,113 @@ +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, + private val cryptoCurrencyStatusProvider: Provider, +) { + + fun convert(wallets: List, txHistory: PagingData) { + val filteredWallets = wallets.filterNotNull() + .groupBy { item -> item.name } + .values.flatten() + .mapIndexed { index, item -> + item.copy( + name = "${item.name} ${index.inc()}", + ) + } + + currentStateProvider().recipientList.update { + 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 { tx -> + SendRecipientListContent.Item( + id = tx.txHash, + title = tx.extractAddress(), + subtitle = TextReference.Str(tx.getAmount()), + info = tx.extractTimestamp(), + subtitleIconRes = tx.extractIconRes(), + ) + }.insertWallets(filteredWallets) + } + } + + private fun PagingData.insertWallets( + wallets: List, + ): PagingData { + return insertSeparators(terminalSeparatorType = TerminalSeparatorType.SOURCE_COMPLETE) { before, after -> + return@insertSeparators when { + before == null && after is SendRecipientListContent.Item -> { + SendRecipientListContent.Wallets( + wallets.map { + SendRecipientListContent.Item( + id = it.address, + title = TextReference.Str(it.address), + subtitle = TextReference.Str(it.name), + ) + }.toPersistentList(), + ) + } + else -> null + } + } + } + + 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)) + } +} \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/recipient/SendRecipientMemoFieldConverter.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/recipient/SendRecipientMemoFieldConverter.kt new file mode 100644 index 0000000000..d1890038cb --- /dev/null +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/recipient/SendRecipientMemoFieldConverter.kt @@ -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, +) : Converter> { + + fun convertOrNull(): MutableStateFlow? { + 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 { + 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), + ), + ) + } +} \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/recipient/SendRecipientStateConverter.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/recipient/SendRecipientStateConverter.kt new file mode 100644 index 0000000000..ebdafea549 --- /dev/null +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/recipient/SendRecipientStateConverter.kt @@ -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, +) : Converter { + + 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, + ) + } +} \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/SendNavigationButtons.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/SendNavigationButtons.kt index 599b46babc..30ca727485 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/SendNavigationButtons.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/SendNavigationButtons.kt @@ -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, ) } } diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/SendScreen.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/SendScreen.kt index 34df63ae5a..93a0f62d57 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/SendScreen.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/SendScreen.kt @@ -1,53 +1,95 @@ 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.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, + 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, + ) + else -> { /* [REDACTED_TODO_COMMENT]*/ } } } } \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/amount/AmountField.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/amount/AmountField.kt index 5cd1c54f34..2fedacc5c1 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/amount/AmountField.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/amount/AmountField.kt @@ -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 - } - }, - ) - } } \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/amount/AmountFieldContainer.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/amount/AmountFieldContainer.kt index 0bb6d13de0..363392865b 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/amount/AmountFieldContainer.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/amount/AmountFieldContainer.kt @@ -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, ) } } \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/SendAmountContent.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/amount/SendAmountContent.kt similarity index 75% rename from features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/SendAmountContent.kt rename to features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/amount/SendAmountContent.kt index 0cc832db0c..d400239936 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/SendAmountContent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/amount/SendAmountContent.kt @@ -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 @@ -18,29 +16,20 @@ 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 +38,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 diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/common/FooterContainer.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/common/FooterContainer.kt new file mode 100644 index 0000000000..ce0fbcee3b --- /dev/null +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/common/FooterContainer.kt @@ -0,0 +1,38 @@ +package com.tangem.features.send.impl.presentation.ui.common + +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() + footer?.let { + Text( + text = it, + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier + .padding(top = footerTopPadding), + ) + } + } +} \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/recipient/ListItemWithIcon.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/recipient/ListItemWithIcon.kt index 2c3bfff4b4..f7c2cd613f 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/recipient/ListItemWithIcon.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/recipient/ListItemWithIcon.kt @@ -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( diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/recipient/SendRecipientContent.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/recipient/SendRecipientContent.kt new file mode 100644 index 0000000000..ada2667527 --- /dev/null +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/recipient/SendRecipientContent.kt @@ -0,0 +1,199 @@ +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, +) { + 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, + ) + } +} + +@OptIn(ExperimentalFoundationApi::class) +private fun LazyListScope.recipientListItem( + recipientList: LazyPagingItems, + 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) { + Modifier.clip( + RoundedCornerShape( + topEnd = TangemTheme.dimens.radius12, + topStart = TangemTheme.dimens.radius12, + ), + ) + } 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) }, + ) + } + Text( + text = stringResource(R.string.send_recent_transactions), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier + .fillMaxWidth() + .padding( + top = TangemTheme.dimens.spacing8, + bottom = TangemTheme.dimens.spacing8, + start = TangemTheme.dimens.spacing12, + end = TangemTheme.dimens.spacing12, + ), + ) + } +} \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/recipient/TextFields.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/recipient/TextFields.kt index 1b9118851a..3a2ed57e1a 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/recipient/TextFields.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/recipient/TextFields.kt @@ -23,14 +23,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 +43,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 +61,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 +122,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 +144,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 +175,7 @@ internal fun TextFieldWithInfo( modifier: Modifier = Modifier, info: TextReference? = null, footer: String? = null, + visualTransformation: VisualTransformation = VisualTransformation.None, ) { FooterContainer( footer = footer, @@ -189,6 +205,7 @@ internal fun TextFieldWithInfo( SimpleTextField( value = value, onValueChange = onValueChange, + visualTransformation = visualTransformation, modifier = Modifier .padding(top = TangemTheme.dimens.spacing6) .weight(1f), @@ -208,27 +225,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,6 +283,7 @@ private fun SimpleTextField( modifier: Modifier = Modifier, placeholder: TextReference? = null, singleLine: Boolean = false, + visualTransformation: VisualTransformation = VisualTransformation.None, ) { val focusRequester = remember { FocusRequester() } BasicTextField( @@ -295,6 +292,7 @@ private fun SimpleTextField( textStyle = TangemTheme.typography.body2, cursorBrush = SolidColor(TangemTheme.colors.text.primary1), singleLine = singleLine, + visualTransformation = visualTransformation, decorationBox = { textValue -> Box { if (value.isBlank() && placeholder != null) { diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/viewmodel/AddressVerification.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/viewmodel/AddressVerification.kt new file mode 100644 index 0000000000..3dd89596a1 --- /dev/null +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/viewmodel/AddressVerification.kt @@ -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: String): Boolean { + return walletAddresses.all { it.value != address } +} \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/viewmodel/MemoVerification.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/viewmodel/MemoVerification.kt new file mode 100644 index 0000000000..a27ce13f23 --- /dev/null +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/viewmodel/MemoVerification.kt @@ -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 \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/viewmodel/SendClickIntents.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/viewmodel/SendClickIntents.kt index 118d166c69..03740cb51e 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/viewmodel/SendClickIntents.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/viewmodel/SendClickIntents.kt @@ -2,13 +2,25 @@ package com.tangem.features.send.impl.presentation.viewmodel 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 } \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/viewmodel/SendViewModel.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/viewmodel/SendViewModel.kt index 274feef98e..2bc9bb638a 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/viewmodel/SendViewModel.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/viewmodel/SendViewModel.kt @@ -4,32 +4,53 @@ 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.common.Provider 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.txhistory.models.TxHistoryItem +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.StateRouter 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 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 walletManagersFacade: WalletManagersFacade, savedStateHandle: SavedStateHandle, ) : ViewModel(), DefaultLifecycleObserver, SendClickIntents { @@ -42,26 +63,41 @@ internal class SendViewModel @Inject constructor( private val selectedAppCurrencyFlow: StateFlow = 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
() private var balanceJobHolder = JobHolder() + private var recipientsJobHolder = JobHolder() + private var walletAddressesJobHolder = JobHolder() override fun onCreate(owner: LifecycleOwner) { + getWalletAddresses() subscribeOnCurrencyStatusUpdates(owner) + getWalletsAndRecent() + } + + 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 +122,12 @@ internal class SendViewModel @Inject constructor( .conflate() .distinctUntilChanged() .onEach { either -> - uiState = stateFactory.getAmountState( - cryptoCurrencyStatus = either, - ) + either.onRight { + cryptoCurrencyStatus = it + uiState = stateFactory.getReadyState() + } } - .flowOn(dispatchers.io) + .flowOn(dispatchers.main) .launchIn(viewModelScope) .saveIn(balanceJobHolder) } @@ -107,27 +144,81 @@ 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(), + ) { wallets, txHistory -> + stateFactory.onLoadedRecipientList( + wallets = wallets, + txHistory = txHistory, + ) + } + .flowOn(dispatchers.io) + .launchIn(viewModelScope) + .saveIn(recipientsJobHolder) + } + + private fun getUserWallets(): Flow> { + 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> { + return flow { + txHistoryItemsUseCase( + userWalletId = userWalletId, + currency = cryptoCurrency, + ).fold( + ifRight = { emitAll(it.distinctUntilChanged()) }, + ifLeft = {}, + ) } } - override fun onPrevClick() { - // todo implement + private fun getWalletAddresses() { + viewModelScope.launch(dispatchers.io) { + walletAddresses = walletManagersFacade.getAddresses( + userWalletId = userWalletId, + network = cryptoCurrency.network, + ) + }.saveIn(walletAddressesJobHolder) } - private fun onRecipientStateClick() { - stateFactory.getOnReceiveState() - } + // region screen state navigation + override fun onBackClick() = innerRouter.onBackClick() + override fun onNextClick() = innerRouter.onNextClick() + override fun onPrevClick() = innerRouter.onPrevClick() - private fun onFeeStateClick() { - // todo implement + override fun onQrCodeScanClick() { + // TODO Add QR code scanning } // endregion @@ -141,14 +232,44 @@ 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 + + companion object { + private const val XRP_X_ADDRESS = 'X' + private const val DEFAULT_VALUE = "0.00" + } } \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/txhistory/TokenDetailsTxHistoryItemFlowConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/txhistory/TokenDetailsTxHistoryItemFlowConverter.kt index 40261aefd2..819d32e1de 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/txhistory/TokenDetailsTxHistoryItemFlowConverter.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/txhistory/TokenDetailsTxHistoryItemFlowConverter.kt @@ -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 internal class TokenDetailsTxHistoryItemFlowConverter( private val currentStateProvider: Provider, @@ -101,7 +97,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 @@ -117,26 +113,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())) - } } \ No newline at end of file diff --git a/gradle/dependencies.toml b/gradle/dependencies.toml index 6de5a3f3ab..86e6d89da1 100644 --- a/gradle/dependencies.toml +++ b/gradle/dependencies.toml @@ -34,7 +34,7 @@ compose-navigation = "2.7.4" compose-accompanist = "0.30.1" compose-paging = "3.2.1" compose-reorderable = "0.9.6" -compoese-lifecycle-runtime = "2.6.2" +compose-lifecycle-runtime = "2.6.2" # endregion Compose # region Other libraries @@ -142,7 +142,7 @@ androidx-palette = { module = "androidx.palette:palette", version.ref = "android lifecycle-common-java8 = { module = "androidx.lifecycle:lifecycle-common-java8", version.ref = "androidxLifecycle" } lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidxLifecycle" } lifecycle-viewModel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidxLifecycle" } -lifecycle-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "compoese-lifecycle-runtime" } +lifecycle-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "compose-lifecycle-runtime" } androidx-datastore = { module = "androidx.datastore:datastore-preferences", version.ref = "androidx-datastore" } # region AndroidX