Updated on 2026-08-14

This commit is contained in:
Tangem 2023-11-15 19:26:58 +03:00
commit adf9e09ac1
68 changed files with 2815 additions and 458 deletions

View file

@ -50,6 +50,7 @@ dependencies {
implementation(projects.domain.appTheme.models)
implementation(projects.domain.balanceHiding)
implementation(projects.domain.balanceHiding.models)
implementation(projects.domain.transaction)
implementation(projects.common)
implementation(projects.core.analytics)

View file

@ -0,0 +1,24 @@
package com.tangem.tap.di.domain
import com.tangem.domain.transaction.usecase.GetFeeUseCase
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ViewModelComponent
import dagger.hilt.android.scopes.ViewModelScoped
@Module
@InstallIn(ViewModelComponent::class)
internal object TransactionDomainModule {
@Provides
@ViewModelScoped
fun provideGetUseCase(
walletManagersFacade: WalletManagersFacade,
dispatchers: CoroutineDispatcherProvider,
): GetFeeUseCase {
return GetFeeUseCase(walletManagersFacade, dispatchers)
}
}

View file

@ -18,7 +18,7 @@ import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.tap.features.details.ui.appsettings.AppSettingsItemsFactory
import com.tangem.tap.features.details.ui.appsettings.AppSettingsScreenState.Item
import com.tangem.tap.features.details.ui.common.TangemSwitch
import com.tangem.core.ui.components.TangemSwitch
@Composable
internal fun SettingsSwitchItem(item: Item.Switch, modifier: Modifier = Modifier) {

View file

@ -11,8 +11,9 @@
<string name="alert_failed_to_send_email_title">Не удалось отправить письмо</string>
<string name="alert_failed_to_send_transaction_message">Причина: %s</string>
<string name="alert_failed_to_send_transaction_title">Не могу отправить транзакцию</string>
<string name="alert_manage_tokens_unsupported_blockchain_by_card_message">Выбранный кошелёк не поддерживает сеть %1$s</string>
<string name="alert_manage_tokens_unsupported_curve_message">Для активации криптографии сети %1$s необходимо сбросить кошелек до заводских настроек. Пожалуйста, выведите свои средства, чтобы не потерять их, после сброса доступ к текущему кошельку будет невозможен.</string>
<string name="alert_manage_tokens_unsupported_message">Токены в сети Solana не поддерживаются этой картой из-за ограничений прошивки.</string>
<string name="alert_manage_tokens_unsupported_message">Токены в сети %1$s не поддерживаются этой картой из-за ограничений прошивки.</string>
<string name="alert_troubleshooting_scan_card_title">У вас возникли трудности со сканированием карты?</string>
<string name="alert_unsupported_card">Эта карта не предназначена для работы с этим приложением</string>
<string name="app_settings_enable_biometrics_description">Перейдите в настройки, чтобы включить биометрическую аутентификацию в приложении Tangem</string>
@ -57,10 +58,7 @@
<string name="card_settings_title">Настройки карты</string>
<string name="chat_bot_name">Tangem Bot</string>
<string name="chat_button_title">Чат</string>
<string name="chat_user_action_rate_user">Оценить агента</string>
<string name="chat_user_action_send_log">Отправить логи</string>
<string name="chat_user_actions_title">Пожалуйста, выберите действие</string>
<string name="chat_user_rate_agent_title">Пожалуйста, оцените работу агента</string>
<string name="common_accept">Принять</string>
<string name="common_access_denied">Доступ запрещен</string>
<string name="common_add">Добавить</string>
@ -89,12 +87,12 @@
<string name="common_exchange">Обменять</string>
<string name="common_explore_transaction_history">Посмотреть историю транзакций</string>
<string name="common_explorer">Обозреватель</string>
<string name="common_fee_selector_title">Скорость и комиссия</string>
<string name="common_fee_selector_footer">Сетевые комиссии за транзакции используются для поддержки безопасности сети, поощрения валидаторов, выделения ресурсов и определения приоритета транзакции.</string>
<string name="common_fee_selector_option_slow">Медленно</string>
<string name="common_fee_selector_option_market">По рынку</string>
<string name="common_fee_selector_option_fast">Быстро</string>
<string name="common_fee_selector_option_custom">Свое</string>
<string name="common_fee_selector_option_fast">Быстро</string>
<string name="common_fee_selector_option_market">По рынку</string>
<string name="common_fee_selector_option_slow">Медленно</string>
<string name="common_fee_selector_title">Скорость и комиссия</string>
<string name="common_generate_addresses">Сгенерировать адреса</string>
<string name="common_import">Импортировать</string>
<string name="common_like">Нравится</string>
@ -144,7 +142,10 @@
<string name="custom_token_creation_error_required_field">Обязательное поле</string>
<string name="custom_token_creation_error_wrong_decimals">Количество знаков после запятой должно быть корректным числом не больше %d</string>
<string name="custom_token_custom_derivation">Своя деривация</string>
<string name="custom_token_custom_derivation_placeholder">Например m/00\'/0000\'/0\'/0/0</string>
<string name="custom_token_custom_derivation_title">Введите свою деривацию</string>
<string name="custom_token_decimals_input_title">Знаков после запятой</string>
<string name="custom_token_derivation_path">Путь деривации</string>
<string name="custom_token_derivation_path_default">По умолчанию</string>
<string name="custom_token_derivation_path_input_title">Деривация по BIP44</string>
<string name="custom_token_invalid_derivation_path">Введенный путь деривации некорректен</string>
@ -152,11 +153,15 @@
<string name="custom_token_name_input_title">Название токена</string>
<string name="custom_token_network_input_not_selected">Не выбрано</string>
<string name="custom_token_network_input_title">Сеть</string>
<string name="custom_token_network_selector_title">Сеть</string>
<string name="custom_token_subtitle">Вы можете добавить токен в ручную, если он не поддерживается Tangem</string>
<string name="custom_token_token_symbol_input_placeholder">Например, USDC</string>
<string name="custom_token_token_symbol_input_title">Символ</string>
<string name="custom_token_token_symbol_input_title_old">Символ токена</string>
<string name="custom_token_validation_error_already_added">Этот токен/сеть уже находится в вашем списке</string>
<string name="custom_token_validation_error_not_found">Токены могут быть созданы кем угодно. Остерегайтесь мошеннических токенов, они могут ничего не стоить</string>
<string name="custom_token_validation_error_not_found_description">Остерегайтесь мошеннических токенов, они могут ничего не стоить</string>
<string name="custom_token_validation_error_not_found_title">Токены могут быть созданы кем угодно</string>
<string name="details_chat">Чат</string>
<string name="details_manage_security_access_code">Код доступа</string>
<string name="details_manage_security_access_code_description">Перед сканированием карты вам нужно будет ввести правильный код доступа.</string>
@ -182,6 +187,13 @@
<string name="error_wrong_wallet_tapped">Вы использовали карту от другого кошелька. Приложите карту, связанную с этим кошельком.</string>
<string name="exchange_receive_view_header">Вы получаете</string>
<string name="exchange_send_view_header">Вы отправляете</string>
<string name="exchange_tokens_available_tokens_header">Мои токены</string>
<string name="exchange_tokens_empty_tokens">У вас нет добавленных токенов. Добавьте токены для обмена</string>
<string name="exchange_tokens_unavailable_tokens_header">Недоступен для обмена с %s</string>
<string name="express_choose_providers_subtitle">Провайдеры проводят транзакции, обеспечивая плавный и эффективный обмен токенами</string>
<string name="express_choose_providers_title">Выберите провайдера</string>
<string name="express_provider_best_rate">Лучший курс</string>
<string name="express_provider_permission_needed">Требуется разрешение</string>
<string name="feedback_data_collection_message">Информация ниже не является обязательной. Вы можете стереть её, если хотите.</string>
<string name="feedback_preface_rate_negative">Расскажите, каких функций вам не хватает, и мы постараемся вам помочь.</string>
<string name="feedback_preface_scan_failed">Скажите, пожалуйста, какая у вас карта?</string>
@ -238,7 +250,9 @@
<string name="manage_tokens_title">Рыночная капитализация</string>
<string name="manage_tokens_unavailable_description">Выбранный токен не доступен в кошельке на данный момент. Но не переживайте, вы можете выразить свой интерес проголосовав за его добавление.</string>
<string name="manage_tokens_unavailable_vote">Голосовать</string>
<string name="manage_tokens_wallet_does_not_supported_blockchain">Кошелек не поддерживает выбранную монету</string>
<string name="manage_tokens_wallet_selector_title">Выберите кошелек</string>
<string name="manage_tokens_wallet_support_only_one_network_title">Кошелёк не поддерживает более одной сети</string>
<string name="onboarding_access_code_feature_1_description">Вам необходимо установить единый код доступа для защиты всех ваших карт</string>
<string name="onboarding_access_code_feature_1_title">Защита</string>
<string name="onboarding_access_code_feature_2_description">Позже вы сможете установить индивидуальный код доступа для каждой карты</string>
@ -392,50 +406,50 @@
<string name="scan_card_settings_title">Приготовьте свою карту</string>
<string name="send_amount_label">Сумма</string>
<string name="send_amount_substract">Вычесть из суммы отправки</string>
<string name="send_amount_substract_footer">Сумма к получению %1$s</string>
<string name="send_amount_substract_footer">Сумма к получению %s</string>
<string name="send_date_format">%1$s в %2$s</string>
<string name="send_destination_hint_address">Адрес</string>
<string name="send_destination_tag_field">Код назначения</string>
<string name="send_datetime_formatter">%1$s в %2$s</string>
<string name="send_enter_address_field">Введите адрес</string>
<string name="send_error_address_same_as_wallet">Адрес совпадает с адресом кошелька</string>
<string name="send_extras_error_invalid_destination_tag">Недопустимый Tag. Он не будет добавлен в транзакцию.</string>
<string name="send_extras_error_invalid_memo">Недопустимый Memo. Он не будет добавлен в транзакцию.</string>
<string name="send_extras_hint_destination_tag">Tag</string>
<string name="send_extras_hint_memo">Memo</string>
<string name="send_recent_transactions">Последние</string>
<string name="send_recipient_address_footer">Убедитесь, что вы отправляете средства на адрес кошелька %1$s. Ошибки могут привести к потере ваших токенов.</string>
<string name="send_recipient_memo_footer">Мемо/Код назначения - это код, разделяющий транзакции к общему получателю в сети криптовалют. Внимание: отсутствие мемо может привести к потере средств.</string>
<string name="send_fee_include_description">Включая комиссию</string>
<string name="send_fee_label">Комиссия</string>
<string name="send_fee_picker_low">Низкая</string>
<string name="send_fee_picker_normal">Нормальная</string>
<string name="send_fee_picker_priority">Приоритетная</string>
<string name="send_gas_limit">Лимит газа</string>
<string name="send_gas_price">Цена газа</string>
<string name="send_gas_price_footer">Цена газа влияет на скорость транзакции. При сильно низкой, транзакция может быть не обработана.</string>
<string name="send_gas_limit">Лимит газа</string>
<string name="send_max_amount_label">Максимальная сумма</string>
<string name="send_max_amount">Всё</string>
<string name="send_max_amount_label">Максимальная сумма</string>
<string name="send_max_fee">Комиссия не превысит </string>
<string name="send_max_fee_footer">Максимальная cумма комиссии</string>
<string name="send_max_fee">Комиссия не превысит</string>
<string name="send_network_fee_title">Сетевая комиссия</string>
<string name="send_network_fee_warning_title">Покрытие сетевой комиссии</string>
<string name="send_network_fee_warning_content">Сумма отправки будет уменьшена для покрытия выбранного уровня комиссии</string>
<string name="send_notification_transaction_delay_text">Обратите внимание, что при определенных параметрах комиссии возможны задержки по вашей транзакции</string>
<string name="send_network_fee_warning_title">Покрытие сетевой комиссии</string>
<string name="send_notification_exceed_balance_text">Недостаточно средств для перевода, так как сумма комиссии и сумма перевода в совокупности больше имеющегося баланса</string>
<string name="send_notification_exceed_balance_title">Недостаточно средств</string>
<string name="send_notification_exceed_balance_text">Недостаточно средств для перевода, так как сумма комиссии и сумма перевода в совокупности больше имеющегося баланса.</string>
<string name="send_notification_invalid_amount_title">Недопустимая сумма</string>
<string name="send_notification_invalid_amount_text">Включенная комиссия превышает сумму перевода, что приводит к отрицательному значению.</string>
<string name="send_notification_invalid_minimum_amount_text">Минимальная сумма отправки - %1$s. Пожалуйста, убедитесь, что остаток после отправки также не будет меньше %1$s.</string>
<string name="send_notification_invalid_reserve_amount_title">Сумма резерва не может быть менее %1$s.</string>
<string name="send_notification_top_up_text">Пожалуйста, пополните свой баланс, чтобы продолжить.</string>
<string name="send_notification_high_fee_title">Увеличение комиссии</string>
<string name="send_notification_high_fee_text">Комиссия при переводе всего баланса выше. Для того, чтобы снизить комиссию Вы можете оставить 0.01.</string>
<string name="send_notification_exceed_fee_title">Комиссия превышает баланс</string>
<string name="send_notification_exceed_fee_text">Размер комиссии превышает баланс сети. Для продолжения необходимо пополнить баланс сети.</string>
<string name="send_notification_exceed_fee_title">Комиссия превышает баланс</string>
<string name="send_notification_high_fee_text">Комиссия при переводе всего баланса выше. Для того, чтобы снизить комиссию Вы можете оставить 0.01.</string>
<string name="send_notification_high_fee_title">Увеличение комиссии</string>
<string name="send_notification_invalid_amount_text">Включенная комиссия превышает сумму перевода, что приводит к отрицательному значению</string>
<string name="send_notification_invalid_amount_title">Недопустимая сумма</string>
<string name="send_notification_invalid_minimum_amount_text">Минимальная сумма отправки - %1$s. Пожалуйста, убедитесь, что остаток после отправки также не будет меньше %1$s.</string>
<string name="send_notification_invalid_reserve_amount_text">Пожалуйста, пополните свой баланс, чтобы продолжить</string>
<string name="send_notification_invalid_reserve_amount_title">Сумма резерва не может быть менее %1$s</string>
<string name="send_notification_transaction_delay_text">Обратите внимание, что при определенных параметрах комиссии возможны задержки по вашей транзакции</string>
<string name="send_notification_transaction_delay_title">Возможны задержки по транзакции</string>
<string name="send_optional_field">Необязательное</string>
<string name="send_qrcode_scan_amount_alert_title">QR код содержит информацию о сумме отправки равной %s</string>
<string name="send_recent_transactions">Последние</string>
<string name="send_recipient">Получатель</string>
<string name="send_recipient_address_footer">Убедитесь, что вы отправляете средства на адрес кошелька %s. Ошибки могут привести к потере ваших токенов</string>
<string name="send_recipient_memo_footer">Мемо/ Код назначения - это код, разделяющий транзакции к общему получателю в сети криптовалют. Внимание: отсутствие мемо может привести к потере средств.</string>
<string name="send_recipient_wallets_title">Мои кошельки</string>
<string name="send_title_currency_format">Отправка %s</string>
<string name="send_total_label">Всего</string>
@ -650,7 +664,7 @@
<string name="warning_solana_rent_fee_message">Сеть Solana взимает арендную плату в размере %1$s каждые 2 дня. Аккаунты, которые не могут позволить себе арендную плату, удаляются из сети. Пополните свой счет более чем на %2$s, чтобы не платить арендную плату.</string>
<string name="warning_some_networks_unreachable_message">Некоторые сети в настоящее время недоступны. Пожалуйста, повторите попытку позже.</string>
<string name="warning_some_networks_unreachable_title">Некоторые сети недоступны</string>
<string name="warning_testnet_card_message">Это Testnet карта. Она не может обрабатывать транзакции и используется только в целях тестирования и разработки.</string>
<string name="warning_testnet_card_message">Это Testnet карта. Он не может обрабатывать транзакции и используется только в целях тестирования и разработки.</string>
<string name="warning_testnet_card_title">Только для целей тестирования</string>
<string name="welcome_interrupted_backup_alert_discard">Отказаться</string>
<string name="welcome_interrupted_backup_alert_message">Вы не закончили резервное копирование. Хотите продолжить?</string>

View file

@ -11,8 +11,9 @@
<string name="alert_failed_to_send_email_title">Failed to send the email</string>
<string name="alert_failed_to_send_transaction_message">Reason: %s</string>
<string name="alert_failed_to_send_transaction_title">Can\'t send a transaction</string>
<string name="alert_manage_tokens_unsupported_blockchain_by_card_message">The selected does not support the %1$s network</string>
<string name="alert_manage_tokens_unsupported_curve_message">To activate the %1$s blockchain\'s cryptographic encryption, you\'ll need to reset the wallet to factory settings. Please withdraw your funds before doing so to ensure that you don\'t lose them, and then complete the reset process. Access to the current wallet will not be possible after the reset.</string>
<string name="alert_manage_tokens_unsupported_message">Tokens in Solana network are not supported by this card due to firmware limitation.</string>
<string name="alert_manage_tokens_unsupported_message">Tokens in %1$s network are not supported by this card due to firmware limitation.</string>
<string name="alert_troubleshooting_scan_card_title">Are you having difficulty scanning your card?</string>
<string name="alert_unsupported_card">This card is not designed to work with this app</string>
<string name="app_settings_enable_biometrics_description">Go to settings to enable biometric authentication in the Tangem App</string>
@ -55,10 +56,7 @@
<string name="card_settings_title">Card Settings</string>
<string name="chat_bot_name">Tangem Bot</string>
<string name="chat_button_title">Support</string>
<string name="chat_user_action_rate_user">Rate agent</string>
<string name="chat_user_action_send_log">Send logs</string>
<string name="chat_user_actions_title">Please select an action</string>
<string name="chat_user_rate_agent_title">Please, rate the work of the agent</string>
<string name="common_accept">Accept</string>
<string name="common_access_denied">Access denied</string>
<string name="common_add">Add</string>
@ -85,15 +83,15 @@
<string name="common_enabled">Enabled</string>
<string name="common_error">Error</string>
<string name="common_exchange">Exchange</string>
<string name="common_explore_transaction_history">Explore transaction history</string>
<string name="common_explore">Explore</string>
<string name="common_explore_transaction_history">Explore transaction history</string>
<string name="common_explorer">Explorer</string>
<string name="common_fee_selector_title">Speed and fee</string>
<string name="common_fee_selector_footer">Network transaction fees are used to support network security, incentivize validators, allocate resources, and determine transaction priority</string>
<string name="common_fee_selector_option_slow">Slow</string>
<string name="common_fee_selector_option_market">Market</string>
<string name="common_fee_selector_option_fast">Fast</string>
<string name="common_fee_selector_footer">Network transaction fees are used to support network security, incentivize validators, allocate resources, and determine transaction priority.</string>
<string name="common_fee_selector_option_custom">Custom</string>
<string name="common_fee_selector_option_fast">Fast</string>
<string name="common_fee_selector_option_market">Market</string>
<string name="common_fee_selector_option_slow">Slow</string>
<string name="common_fee_selector_title">Speed and fee</string>
<string name="common_generate_addresses">Generate addresses</string>
<string name="common_import">Import</string>
<string name="common_learn_and_earn">Learn &amp; Earn</string>
@ -162,6 +160,8 @@
<string name="custom_token_token_symbol_input_title_old">Token symbol</string>
<string name="custom_token_validation_error_already_added">This token/network has already been added to your list</string>
<string name="custom_token_validation_error_not_found">Note that tokens can be created by anyone. Be aware of adding scam tokens, they can cost nothing.</string>
<string name="custom_token_validation_error_not_found_description">Be aware of adding scam tokens, they can cost nothing</string>
<string name="custom_token_validation_error_not_found_title">Note that tokens can be created by anyone</string>
<string name="details_chat">Chat</string>
<string name="details_manage_security_access_code">Access code</string>
<string name="details_manage_security_access_code_description">You will have to submit the correct access code before scanning the card</string>
@ -187,6 +187,13 @@
<string name="error_wrong_wallet_tapped">You have used a card from another wallet. Tap the card associated with this wallet</string>
<string name="exchange_receive_view_header">You Receive</string>
<string name="exchange_send_view_header">You Send</string>
<string name="exchange_tokens_available_tokens_header">My tokens</string>
<string name="exchange_tokens_empty_tokens">You don\'t have any added tokens yet. Add tokens via Market to swap</string>
<string name="exchange_tokens_unavailable_tokens_header">Unavailable for swap from %s</string>
<string name="express_choose_providers_subtitle">Providers facilitate transactions, ensuring smooth and efficient token exchanges</string>
<string name="express_choose_providers_title">Choose Provider</string>
<string name="express_provider_best_rate">Best Rate</string>
<string name="express_provider_permission_needed">Permission Needed</string>
<string name="feedback_data_collection_message">The following information is optional. You can erase it if you don\'t want to share it.</string>
<string name="feedback_preface_rate_negative">Tell us what functions you are missing, and we will try to help you.</string>
<string name="feedback_preface_scan_failed">Please tell us what card do you have</string>
@ -224,6 +231,8 @@
<string name="main_tokens">Tokens</string>
<string name="manage_tokens_add">Add</string>
<string name="manage_tokens_edit">Edit</string>
<string name="manage_tokens_list_header_subtitle">Couldnt find this token, you can add it manually</string>
<string name="manage_tokens_list_header_title">Coin market cap</string>
<string name="manage_tokens_network_selector_native_subtitle">Blockchain the cryptocurrency was initially created</string>
<string name="manage_tokens_network_selector_native_title">Native network</string>
<string name="manage_tokens_network_selector_non_native_info">Using non-native networks for tokens enables cross-blockchain interoperability, allowing assets to be utilized in diverse decentralized applications and smart contracts across platforms. However, this often involves a custodian or smart contract to hold the original asset securely, introducing centralization and counterparty risk.</string>
@ -242,7 +251,9 @@
<string name="manage_tokens_title">Coin market cap</string>
<string name="manage_tokens_unavailable_description">The selected token is currently unavailable for actions within the crypto wallet. But worry not, you can express your interest by upvoting it.</string>
<string name="manage_tokens_unavailable_vote">Upvote</string>
<string name="manage_tokens_wallet_does_not_supported_blockchain">Wallet Incompatible with selected Coin</string>
<string name="manage_tokens_wallet_selector_title">Choose wallet</string>
<string name="manage_tokens_wallet_support_only_one_network_title">The wallet doesn\'t support more than one network</string>
<string name="onboarding_access_code_feature_1_description">You have to set up a single access code to protect all your wallets</string>
<string name="onboarding_access_code_feature_1_title">Protect</string>
<string name="onboarding_access_code_feature_2_description">You can set up an individual access code on each card later</string>
@ -392,10 +403,10 @@
<string name="scan_card_settings_title">Get your card ready!</string>
<string name="send_amount_label">Amount</string>
<string name="send_amount_substract">Subtract from send amount</string>
<string name="send_amount_substract_footer">The recipient will receive %1$s</string>
<string name="send_amount_substract_footer">The recipient will receive %s</string>
<string name="send_date_format">%1$s at %2$s</string>
<string name="send_destination_hint_address">Address</string>
<string name="send_destination_tag_field">Destination Tag</string>
<string name="send_datetime_formatter">%1$s at %2$s</string>
<string name="send_enter_address_field">Enter address</string>
<string name="send_error_address_same_as_wallet">Address is the same as wallet address</string>
<string name="send_extras_error_invalid_destination_tag">Invalid Tag. It won\'t be added to the transaction.</string>
@ -408,47 +419,47 @@
<string name="send_fee_picker_low">Low</string>
<string name="send_fee_picker_normal">Normal</string>
<string name="send_fee_picker_priority">Priority</string>
<string name="send_fee_unreachable_error_title">Network fee info unreachable</string>
<string name="send_fee_unreachable_error_text">Check your network connection</string>
<string name="send_fee_unreachable_error_title">Network fee info unreachable</string>
<string name="send_from_wallet_android">From</string>
<string name="send_gas_limit">Gas limit</string>
<string name="send_gas_limit_footer">Gas Limit is auto-calculated; raise it during network congestion</string>
<string name="send_gas_price">Gas price</string>
<string name="send_gas_price_footer">Gas Price affects transaction speed. If it\'s too low, the transaction might not be processed.</string>
<string name="send_max_amount_label">Maximum amount</string>
<string name="send_gas_price_footer">Gas Price affects transaction speed. If it\'s too low, the transaction might not be processed</string>
<string name="send_max_amount">Max</string>
<string name="send_max_fee_footer">Maximum fee amount</string>
<string name="send_max_amount_label">Maximum amount</string>
<string name="send_max_fee">Max fee</string>
<string name="send_max_fee_footer">Maximum fee amount</string>
<string name="send_memo_destination_tag_error">Numbers only for Destination Tag</string>
<string name="send_network_fee_title">Network fee</string>
<string name="send_network_fee_warning_title">Network fee coverage</string>
<string name="send_network_fee_warning_content">Sending amount will be reduced to cover the selected fee level</string>
<string name="send_notification_transaction_delay_text">Kindly be aware that your transaction may experience delays under specific fee settings</string>
<string name="send_network_fee_warning_title">Network fee coverage</string>
<string name="send_notification_exceed_balance_text">Insufficient funds for the transfer, as the total of the fee and transfer amount exceeds the existing balance</string>
<string name="send_notification_exceed_balance_title">Total exceeds balance</string>
<string name="send_notification_exceed_balance_text">Insufficient funds for the transfer, as the total of the fee and transfer amount exceeds the existing balance.</string>
<string name="send_notification_invalid_amount_title">Invalid amount</string>
<string name="send_notification_invalid_amount_text">The included commission exceeds the transfer amount, leading to a negative value.</string>
<string name="send_notification_invalid_minimum_amount_text">The minimum sending amount is %1$s. Please ensure that the remaining balance after sending will not be less than %1$s.</string>
<string name="send_notification_invalid_reserve_amount_title">The balance amount must be at least %1$s.</string>
<string name="send_notification_invalid_reserve_amount_text">Please top up your balance to continue.</string>
<string name="send_notification_high_fee_title">Fee is increased</string>
<string name="send_notification_high_fee_text">The fee for transferring the entire balance is higher. To reduce the commission, you can leave 0.01.</string>
<string name="send_notification_exceed_fee_title">Fee exceeds balance</string>
<string name="send_notification_exceed_fee_text">The commission fee exceeds the network balance. To continue, it is necessary to replenish the network balance.</string>
<string name="send_notification_exceed_fee_title">Fee exceeds balance</string>
<string name="send_notification_high_fee_text">The fee for transferring the entire balance is higher. To reduce the commission, you can leave 0.01.</string>
<string name="send_notification_high_fee_title">Fee is increased</string>
<string name="send_notification_invalid_amount_text">The included commission exceeds the transfer amount, leading to a negative value</string>
<string name="send_notification_invalid_amount_title">Invalid amount</string>
<string name="send_notification_invalid_minimum_amount_text">The minimum sending amount is %1$s. Please ensure that the remaining balance after sending will not be less than %1$s.</string>
<string name="send_notification_invalid_reserve_amount_text">Please top up your balance to continue</string>
<string name="send_notification_invalid_reserve_amount_title">The balance amount must be at least %1$s</string>
<string name="send_notification_transaction_delay_text">Kindly be aware that your transaction may experience delays under specific fee settings</string>
<string name="send_notification_transaction_delay_title">Transaction delays are possible</string>
<string name="send_optional_field">Optional</string>
<string name="send_qrcode_scan_info">Please align your QR code with the square to scan it. Ensure you scan %s network address.</string>
<string name="send_qrcode_scan_address_success">Recipients address scanned</string>
<string name="send_qrcode_scan_memo_success">Sending amount was changed</string>
<string name="send_qrcode_scan_amount_alert_title">QR code contains information about the sending amount equal to %s</string>
<string name="send_qrcode_scan_amount_alert_text">Change the entered amount?</string>
<string name="send_qrcode_alert_change">Change</string>
<string name="send_qrcode_alert_decline">Decline</string>
<string name="send_qrcode_scan_address_success">Recipients address scanned</string>
<string name="send_qrcode_scan_amount_alert_text">Change the entered amount?</string>
<string name="send_qrcode_scan_amount_alert_title">QR code contains information about the sending amount equal to %s</string>
<string name="send_qrcode_scan_info">Please align your QR code with the square to scan it. Ensure you scan %s network address.</string>
<string name="send_recent_transactions">Recent</string>
<string name="send_recipient">Recipient</string>
<string name="send_recipient_address_error">Not a valid address</string>
<string name="send_recipient_address_footer">Ensure that you are sending funds to an %s wallet address. Errors may result in the loss of your tokens</string>
<string name="send_recipient_memo_footer">A Memo/Destination Tag is a unique ID for differentiating transactions sent to the same recipient on the same network. Caution: Omitting a memo may lead to misplaced funds</string>
<string name="send_recipient_wallets_title">My wallets</string>
<string name="send_recipient_address_footer">Ensure that you are sending funds to an %1$s wallet address. Errors may result in the loss of your tokens.</string>
<string name="send_recipient_memo_footer">A Memo/Destination Tag is a unique ID for differentiating transactions sent to the same recipient on the same network. Caution: Omitting a memo may lead to misplaced funds.</string>
<string name="send_title_currency_format">Sending %s</string>
<string name="send_total_label">Total</string>
<string name="send_total_subtitle_asset_format">%1$s and %2$s will be sent</string>
@ -456,6 +467,7 @@
<string name="send_total_subtitle_format">%s will be sent</string>
<string name="send_transaction_success">Transaction has been successfully signed and sent to the blockchain node. Wallet balance will be updated in a while</string>
<string name="send_validation_invalid_address">Invalid address</string>
<string name="sent_transaction_sent_title">Transaction sent</string>
<string name="shop_buy_now">Buy now</string>
<string name="shop_i_have_a_promo_code">I have a promo code…</string>
<string name="shop_one_wallet">Tangem Wallet</string>

View file

@ -14,6 +14,7 @@ dependencies {
/** Project - Core */
implementation(projects.core.res)
implementation(projects.core.utils)
/** AndroidX libraries */
implementation(deps.androidx.fragment.ktx)

View file

@ -1,4 +1,4 @@
package com.tangem.tap.features.details.ui.common
package com.tangem.core.ui.components
import androidx.compose.animation.animateColor
import androidx.compose.animation.core.*

View file

@ -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 = {},
)
}
}

View file

@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.graphics.Shape
@ -108,7 +109,7 @@ private inline fun RowScope.ButtonContentContainer(
if (showProgress) {
progressIndicator()
} else {
Column {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Row(horizontalArrangement = Arrangement.Center) {
if (buttonIcon is TangemButtonIconPosition.Start) {
icon(buttonIcon.iconResId)

View file

@ -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
}
},
)
}
}

View file

@ -55,6 +55,7 @@ data class TangemDimens internal constructor(
val size20: Dp = 20.dp,
val size24: Dp = 24.dp,
val size28: Dp = 28.dp,
val size30: Dp = 30.dp,
val size32: Dp = 32.dp,
val size34: Dp = 34.dp,
val size36: Dp = 36.dp,

View file

@ -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)
}

View file

@ -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()))
}

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M15.316,6.992C14.311,6.992 13.538,7.568 12.995,8.794C10.963,6.753 7.995,5.045 5.043,4.806C4.152,4.746 4.031,5.519 4.011,5.98C3.816,9.464 6.319,12.606 9.521,13.127C8.61,15.057 7.359,16.684 5.776,18.093C5.387,18.421 5.545,18.934 5.727,19.241C6.61,20.688 9.408,21.692 11.137,21.056C11.667,20.863 11.74,20.513 11.74,20.163V17.849C13.534,17.653 16.32,16.201 15.927,12.273L15.899,11.979C17.227,11.573 18.158,10.6 18.158,9.549C18.158,8.298 16.858,6.992 15.316,6.992ZM15.389,8.388C16.219,8.388 16.867,9.011 16.867,9.549C16.867,10.015 16.316,10.557 15.008,10.749C14.68,10.792 14.49,11.018 14.514,11.33L14.579,12.299C14.809,15.795 12.283,16.265 10.854,16.389C10.558,16.415 10.364,16.641 10.364,16.931V19.736C10.364,19.851 10.339,19.916 10.202,19.928C9.278,20.039 7.687,19.442 7.149,18.81C7.08,18.72 7.08,18.643 7.132,18.596C8.837,17.072 10.214,15.095 11.149,12.747C11.34,12.29 11.121,11.898 10.7,11.893C7.768,11.868 5.549,9.575 5.387,6.45C5.383,6.326 5.428,6.3 5.533,6.322C8.145,6.736 10.574,8.303 12.085,9.848C12.437,10.207 12.667,10.361 13,10.361C13.538,10.361 13.805,10.096 14.081,9.498C14.368,8.862 14.656,8.388 15.389,8.388ZM13.996,8.26L15.283,7.782C14.344,5.408 13.016,3.769 11.242,2.68C10.586,2.283 9.918,2.56 9.732,3.342C9.4,4.691 9.258,5.912 9.449,7.218L10.987,7.995C10.655,6.714 10.663,5.647 10.902,4.418C10.922,4.285 11.028,4.242 11.129,4.319C12.34,5.22 13.255,6.458 13.996,8.26ZM16.749,10.77L19.417,11.185C19.988,11.27 20.256,10.408 19.677,10.143L17.263,9.058L16.749,10.77Z"
android:fillColor="#0099FF"/>
</vector>

View file

@ -0,0 +1,7 @@
<vector android:height="24dp" android:viewportHeight="25"
android:viewportWidth="25" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<group>
<clip-path android:pathData="M0.676,0.968h24v24h-24z"/>
<path android:fillColor="#909090" android:pathData="M5.676,19.968H6.938L17.174,9.731L15.913,8.47L5.676,18.706V19.968ZM4.176,21.468V18.083L17.366,4.899C17.518,4.761 17.685,4.655 17.867,4.58C18.05,4.505 18.242,4.468 18.442,4.468C18.643,4.468 18.837,4.503 19.025,4.575C19.213,4.646 19.379,4.759 19.524,4.914L20.745,6.151C20.9,6.295 21.011,6.462 21.077,6.651C21.143,6.839 21.176,7.028 21.176,7.216C21.176,7.417 21.142,7.609 21.073,7.792C21.004,7.975 20.895,8.142 20.745,8.293L7.561,21.468H4.176ZM16.532,9.112L15.913,8.47L17.174,9.731L16.532,9.112Z"/>
</group>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M15.564,18.109C16.941,18.109 17.59,17.357 17.59,16.486C17.59,14.706 16.348,14.153 14.183,14.153C13.347,14.153 13.085,14.205 12.874,14.253L12.432,13.087C11.6,10.885 10.06,9.95 8.352,9.95C8.097,9.95 7.799,9.95 7.493,10.018C7.071,10.109 6.896,10.288 6.896,10.555C6.896,10.937 7.186,11.2 7.58,11.196C7.815,11.196 8.066,11.156 8.336,11.156C9.682,11.156 10.601,11.916 11.389,13.819L12.078,15.51C12.619,15.403 13.184,15.307 14.135,15.307C15.262,15.307 16.121,15.67 16.121,16.314C16.121,16.561 15.934,16.776 15.564,16.776C14.919,16.776 14.438,16.633 13.821,16.633C13.16,16.633 12.424,16.697 12.189,16.697C11.922,16.697 11.715,16.681 11.512,16.493L8.667,13.711C6.593,13.827 5.268,13.341 4.452,12.211C3.887,12.255 3.465,12.056 3.465,11.646C3.465,11.228 3.918,10.981 4.519,10.981C5.148,9.003 6.322,8.107 8.054,8.107C10.044,8.107 11.767,9.648 13.574,10.543C14.115,10.802 14.712,10.921 15.071,10.921C16.034,10.921 16.702,10.356 16.917,9.305L12.858,6.539C12.555,6.336 12.209,6.05 12.209,5.902C12.209,5.584 12.814,5.401 13.295,5.401C14.207,5.401 14.896,5.879 15.588,6.671L17.705,9.102C19.504,9.106 20.667,10.372 20.667,11.988C20.667,12.935 20.249,13.206 18.983,13.206C18.259,13.206 17.343,12.931 16.476,12.462C15.226,12.776 13.916,13.465 13.092,14.281L14.235,14.691C14.971,14.217 15.779,13.891 16.316,13.684C17.212,14.197 18.195,14.42 18.947,14.416C20.901,14.408 22,13.707 22,11.98C22,9.795 20.44,8.167 18.318,8.048L17.033,6.209C16.034,4.788 14.876,4 13.28,4C12.113,4 10.601,4.633 10.601,5.767C10.601,6.48 11.174,6.933 11.994,7.479C12.754,7.98 14.398,9.003 15.278,9.556C15.21,9.596 15.126,9.628 15.027,9.628C14.792,9.628 14.362,9.465 14.056,9.258C11.648,7.992 10.104,6.675 8.066,6.675C5.829,6.675 4.197,7.845 3.417,10.149C2.537,10.308 2,10.906 2,11.753C2,12.776 2.792,13.377 3.755,13.389C4.364,14.424 6.004,15.256 8.03,15.097L10.573,17.52C10.983,17.922 11.5,18.07 12.161,18.07C12.706,18.07 13.192,17.942 13.861,17.942C14.557,17.942 14.999,18.109 15.564,18.109ZM7.389,18.838C8.826,18.838 10.247,18.352 11.063,17.385L10.107,16.593C9.69,17.234 8.639,17.624 7.429,17.624C6.649,17.624 6.088,17.369 6.088,16.927C6.088,16.72 6.243,16.553 6.529,16.553C6.907,16.553 7.282,16.661 7.755,16.661C8.499,16.661 9.132,16.549 9.63,16.115L8.758,15.252C8.452,15.467 8.078,15.526 7.759,15.526C7.158,15.522 6.808,15.403 6.326,15.403C5.339,15.403 4.675,16.004 4.675,16.907C4.675,18.113 5.761,18.838 7.389,18.838ZM18.537,11.622C18.876,11.622 19.15,11.347 19.15,11.009C19.15,10.675 18.876,10.396 18.537,10.396C18.203,10.396 17.924,10.675 17.924,11.009C17.924,11.347 18.203,11.622 18.537,11.622Z"
android:fillColor="#909090"/>
</vector>

View file

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<group>
<clip-path
android:pathData="M0,0h24v24h-24z"/>
<path
android:pathData="M4,4H10V10H4V4ZM20,4V10H14V4H20ZM14,15H16V13H14V11H16V13H18V11H20V13H18V15H20V18H18V20H16V18H13V20H11V16H14V15ZM16,15V18H18V15H16ZM4,20V14H10V20H4ZM6,6V8H8V6H6ZM16,6V8H18V6H16ZM6,16V18H8V16H6ZM4,11H6V13H4V11ZM9,11H13V15H11V13H9V11ZM11,6H13V10H11V6ZM2,2V6H0V2C0,1.47 0.211,0.961 0.586,0.586C0.961,0.211 1.47,0 2,0L6,0V2H2ZM22,0C22.53,0 23.039,0.211 23.414,0.586C23.789,0.961 24,1.47 24,2V6H22V2H18V0H22ZM2,18V22H6V24H2C1.47,24 0.961,23.789 0.586,23.414C0.211,23.039 0,22.53 0,22V18H2ZM22,22V18H24V22C24,22.53 23.789,23.039 23.414,23.414C23.039,23.789 22.53,24 22,24H18V22H22Z"
android:fillColor="#1E1E1E"/>
</group>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M9.408,17.317C11.952,17.317 12.77,15.774 14.392,15.699C16.461,15.551 17.283,14.326 18.957,14.326C20.756,14.326 22,13.667 22,12.497C22,10.076 20.634,8.665 18.879,8.665C17.231,8.665 16.213,9.635 15.865,11.172C15.739,11.701 15.506,12.038 15.177,12.267L14.943,11.842C13.348,8.917 11.848,7.732 9.416,7.725C6.906,7.732 5.469,8.876 3.703,12.179C2.985,13.53 2,13.708 2,14.618C2,15.277 2.529,15.699 3.462,15.699C3.666,15.699 3.881,15.699 4.088,15.699C6.002,15.699 7.043,17.317 9.408,17.317ZM9.408,15.87C7.513,15.87 6.332,14.267 4.54,14.267C4.344,14.267 4.155,14.27 3.925,14.281L3.899,14.189C4.281,13.844 4.632,13.489 4.943,12.863C6.391,10.09 7.454,9.176 9.416,9.168C11.493,9.176 12.548,10.394 13.755,12.612C14.166,13.367 14.484,13.878 14.895,14.252C14.736,14.244 14.54,14.252 14.336,14.27C12.352,14.415 11.293,15.87 9.408,15.87ZM16.424,13.963C16.247,13.807 16.076,13.608 15.932,13.348C16.528,13.145 16.924,12.663 17.105,11.79C17.335,10.623 17.924,10.05 18.879,10.05C19.856,10.05 20.663,10.972 20.663,12.319C20.663,12.66 19.916,12.974 19.175,12.974C17.816,12.974 17.131,13.422 16.424,13.963ZM19.012,11.982C19.323,11.982 19.582,11.723 19.582,11.412C19.582,11.097 19.323,10.842 19.012,10.842C18.697,10.842 18.442,11.097 18.442,11.412C18.442,11.723 18.697,11.982 19.012,11.982ZM9.364,12.73C11.367,12.73 12.851,11.805 13.74,10.864L13.207,9.913C12.193,11.035 10.852,11.649 9.364,11.649C7.853,11.649 6.513,11.042 5.517,9.909L4.988,10.868C5.869,11.801 7.354,12.73 9.364,12.73ZM6.935,15.614C7.587,14.94 8.201,13.434 8.268,12.438L7.328,12.149C7.287,12.941 6.639,14.552 5.976,15.189L6.935,15.614ZM11.893,15.614L12.855,15.189C12.189,14.548 11.541,12.945 11.5,12.149L10.56,12.43C10.597,13.419 11.215,14.922 11.893,15.614ZM3.907,18.024C5.195,18.024 6.484,17.476 7.476,16.451L6.476,15.884C5.491,16.673 4.71,17.014 3.977,17.014C3.551,17.014 3.325,16.869 3.325,16.658C3.325,16.507 3.455,16.399 3.755,16.318C4.04,16.233 5.151,15.907 5.676,15.573L4.74,15.229C4.366,15.392 3.87,15.466 3.455,15.611C2.792,15.811 2.159,16.062 2.159,16.832C2.159,17.58 2.889,18.024 3.907,18.024ZM14.736,18.024C15.754,18.024 16.487,17.584 16.487,16.832C16.487,15.996 15.762,15.748 15.021,15.551C14.636,15.448 14.229,15.374 13.903,15.233L12.97,15.577C13.496,15.907 14.603,16.24 14.892,16.318C15.191,16.399 15.317,16.507 15.317,16.662C15.317,16.873 15.091,17.017 14.666,17.017C13.933,17.017 13.151,16.677 12.17,15.884L11.171,16.451C12.163,17.447 13.455,18.024 14.736,18.024Z"
android:fillColor="#909090"/>
</vector>

View file

@ -10,6 +10,7 @@ import com.tangem.blockchain.blockchains.solana.RentProvider
import com.tangem.blockchain.common.*
import com.tangem.blockchain.common.address.Address
import com.tangem.blockchain.common.address.AddressType
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.blockchain.common.txhistory.TransactionHistoryRequest
import com.tangem.blockchain.extensions.Result
import com.tangem.blockchain.extensions.SimpleResult
@ -323,6 +324,10 @@ class DefaultWalletManagersFacade(
}
override suspend fun getAddress(userWalletId: UserWalletId, network: Network): List<Address> {
return getAddresses(userWalletId, network).sortedBy { it.type }
}
override suspend fun getAddresses(userWalletId: UserWalletId, network: Network): Set<Address> {
val blockchain = Blockchain.fromId(network.id.value)
return getOrCreateWalletManager(
@ -332,7 +337,6 @@ class DefaultWalletManagersFacade(
)
?.wallet
?.addresses
?.sortedBy { it.type }
.orEmpty()
}
@ -408,6 +412,24 @@ class DefaultWalletManagersFacade(
}
}
override suspend fun getFee(
amount: Amount,
destination: String,
userWalletId: UserWalletId,
network: Network,
): Result<TransactionFee>? {
val blockchain = Blockchain.fromId(network.id.value)
val walletManager = getOrCreateWalletManager(
userWalletId = userWalletId,
blockchain = blockchain,
derivationPath = network.derivationPath.value,
)
return (walletManager as? TransactionSender)?.getFee(
amount = amount,
destination = destination,
)
}
private fun updateWalletManagerTokensIfNeeded(walletManager: WalletManager, tokens: Set<CryptoCurrency.Token>) {
if (tokens.isEmpty()) return

View file

@ -2,10 +2,13 @@ package com.tangem.domain.walletmanager
import arrow.core.Either
import com.tangem.blockchain.blockchains.solana.RentProvider
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.WalletManager
import com.tangem.blockchain.common.address.Address
import com.tangem.blockchain.common.address.AddressType
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.blockchain.extensions.Result
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning
@ -114,6 +117,13 @@ interface WalletManagersFacade {
*/
suspend fun getAddress(userWalletId: UserWalletId, network: Network): List<Address>
/** 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<Address>
/**
* Returns info about rent if wallet manager implemented [RentProvider], otherwise null
*
@ -136,4 +146,19 @@ interface WalletManagersFacade {
network: Network,
signedHashes: Int,
): Either<Throwable, Unit>
/**
* Returns fee for transaction
*
* @param amount of transaction
* @param destination address
* @param userWalletId selected wallet id
* @param network network of currency
*/
suspend fun getFee(
amount: Amount,
destination: String,
userWalletId: UserWalletId,
network: Network,
): Result<TransactionFee>?
}

1
domain/transaction/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,23 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
id("configuration")
}
android {
namespace = "com.tangem.domain.transaction"
}
dependencies {
implementation(deps.kotlin.coroutines)
implementation(deps.arrow.core)
implementation(projects.core.utils)
implementation(deps.tangem.blockchain)
implementation(projects.domain.legacy)
implementation(projects.domain.wallets.models)
implementation(projects.domain.tokens)
implementation(projects.domain.tokens.models)
}

View file

@ -0,0 +1,5 @@
package com.tangem.domain.transaction.error
sealed class GetFeeError {
object DataError : GetFeeError()
}

View file

@ -0,0 +1,65 @@
package com.tangem.domain.transaction.usecase
import arrow.core.Either
import arrow.core.left
import arrow.core.right
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.AmountType
import com.tangem.blockchain.common.Token
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.blockchain.extensions.Result
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import java.math.BigDecimal
/**
* Use case to get transaction fee
*/
class GetFeeUseCase(
private val walletManagersFacade: WalletManagersFacade,
private val dispatcher: CoroutineDispatcherProvider,
) {
suspend operator fun invoke(
amount: BigDecimal,
destination: String,
userWalletId: UserWalletId,
cryptoCurrency: CryptoCurrency,
): Flow<Either<GetFeeError.DataError, TransactionFee>> {
return flow {
val result = walletManagersFacade.getFee(
amount = convertCryptoCurrencyToAmount(cryptoCurrency, amount),
destination = destination,
userWalletId = userWalletId,
network = cryptoCurrency.network,
)
val maybeFee = when (result) {
is Result.Success -> result.data.right()
else -> GetFeeError.DataError.left()
}
emit(maybeFee)
}.flowOn(dispatcher.io)
}
private fun convertCryptoCurrencyToAmount(cryptoCurrency: CryptoCurrency, amount: BigDecimal) = Amount(
currencySymbol = cryptoCurrency.symbol,
value = amount,
decimals = cryptoCurrency.decimals,
type = when (cryptoCurrency) {
is CryptoCurrency.Coin -> AmountType.Coin
is CryptoCurrency.Token -> AmountType.Token(
token = Token(
symbol = cryptoCurrency.symbol,
contractAddress = cryptoCurrency.contractAddress,
decimals = cryptoCurrency.decimals,
),
)
},
)
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,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<Item>,
) : SendRecipientListContent()
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,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<SendUiState>,
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
) {
fun convert(wallets: List<AvailableWallet?>, txHistory: PagingData<TxHistoryItem>) {
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<TxHistoryItem, SendRecipientListContent> { 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<SendRecipientListContent>.insertWallets(
wallets: List<AvailableWallet>,
): PagingData<SendRecipientListContent> {
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))
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,4 @@
package com.tangem.features.send.impl.presentation.ui
package com.tangem.features.send.impl.presentation.ui.amount
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
@ -6,10 +6,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import com.tangem.core.ui.components.SecondaryButton
import com.tangem.core.ui.components.buttons.common.TangemButtonSize
import com.tangem.core.ui.components.buttons.segmentedbutton.SegmentedButtons
@ -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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,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<SendRecipientListContent>,
) {
if (uiState == null) return
val address = uiState.addressTextField.collectAsState().value
val memo = uiState.memoTextField?.collectAsState()?.value
LazyColumn(
modifier = Modifier
.fillMaxSize()
.background(TangemTheme.colors.background.tertiary)
.padding(horizontal = TangemTheme.dimens.spacing16),
) {
item(key = ADDRESS_FIELD_KEY) {
TextFieldWithPasteAndIcon(
value = address.value,
label = address.label,
placeholder = address.placeholder,
footer = stringResource(R.string.send_recipient_address_footer, uiState.network),
onValueChange = address.onValueChange,
onPasteClick = clickIntents::onRecipientAddressValueChange,
singleLine = true,
modifier = Modifier.padding(top = TangemTheme.dimens.spacing4),
isError = address.isError,
error = address.error,
)
}
memo?.let { memoField ->
item(key = MEMO_FIELD_KEY) {
TextFieldWithPaste(
value = memoField.value,
label = memoField.label,
placeholder = memoField.placeholder,
footer = stringResource(R.string.send_recipient_memo_footer),
onValueChange = memoField.onValueChange,
onPasteClick = clickIntents::onRecipientMemoValueChange,
modifier = Modifier.padding(top = TangemTheme.dimens.spacing20),
isError = memoField.isError,
error = memoField.error,
)
}
}
recipientListItem(
recipientList = recipientList,
clickIntents = clickIntents,
)
}
}
@OptIn(ExperimentalFoundationApi::class)
private fun LazyListScope.recipientListItem(
recipientList: LazyPagingItems<SendRecipientListContent>,
clickIntents: SendClickIntents,
) {
items(
count = recipientList.itemCount,
key = recipientList.itemKey {
when (it) {
is SendRecipientListContent.Wallets -> MY_WALLETS_HEADER_KEY
is SendRecipientListContent.Item -> it.id
}
},
contentType = recipientList.itemContentType { it::class.java },
) { index ->
recipientList[index]?.let { item ->
when (item) {
is SendRecipientListContent.Wallets -> {
RecipientWalletListItem(
item = item,
clickIntents = clickIntents,
modifier = Modifier
.animateItemPlacement()
.padding(top = TangemTheme.dimens.spacing20)
.then(
if (index == 0) {
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,
),
)
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<TokenDetailsState>,
@ -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()))
}
}

View file

@ -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
@ -88,7 +88,7 @@ spr-client = "3.6.2"
# endregion Other libraries
# region Tangem
tangemBlockchainSdk = "develop-384"
tangemBlockchainSdk = "develop-387"
#tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds
tangemCardSdk = "develop-310"
#tangemCardSdk = "0.0.1" # Keep it! - used for local builds
@ -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

View file

@ -127,6 +127,7 @@ include(":domain:app-theme")
include(":domain:app-theme:models")
include(":domain:balance-hiding")
include(":domain:balance-hiding:models")
include(":domain:transaction")
// endregion Domain modules