Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-02 13:03:57 +05:00
commit 1f8386c05e
17 changed files with 1531 additions and 9 deletions

View file

@ -106,6 +106,7 @@
<string name="address_book_contact">Kontakt</string>
<string name="address_book_contact_name">Name der Kontaktperson</string>
<string name="address_book_copy_address">Adresse kopieren</string>
<string name="address_book_create_success_message">Kontakt hinzugefügt</string>
<string name="address_book_creating_error">Es konnte kein Kontakt hergestellt werden. Bitte versuchen Sie es später erneut.</string>
<string name="address_book_delete_contact_description">Dieser Kontakt wird aus all Ihren Adressbüchern gelöscht.</string>
<string name="address_book_deleting_error">Der Kontakt konnte nicht gelöscht werden. Bitte versuchen Sie es später erneut.</string>
@ -115,10 +116,17 @@
<string name="address_book_enter_address">Adresse eingeben</string>
<string name="address_book_invalid_address_error">Ungültige Adresse</string>
<string name="address_book_keep_editing">Weiter bearbeiten</string>
<string name="address_book_max_networks_alert_description">Sie können maximal 20 Adressen erstellen. Löschen Sie eine, um eine neue hinzuzufügen.</string>
<string name="address_book_max_networks_alert_title">Neue Adresse kann nicht hinzugefügt werden</string>
<string name="address_book_name_empty_error">Der Name des Ansprechpartners ist erforderlich</string>
<string name="address_book_name_invalid_chars_error">Der Name des Ansprechpartners enthält ungültige Zeichen</string>
<string name="address_book_name_max_chars_error">Der Name des Ansprechpartners darf nicht länger als 50 Zeichen sein</string>
<string name="address_book_name_taken_error">Dieser Name ist bei dieser Wallet bereits vergeben.</string>
<string name="address_book_new_contact">Neuer Kontakt</string>
<string name="address_book_no_contacts">Noch keine Kontakte</string>
<string name="address_book_no_contacts_description">Die von Ihnen hinzugefügten Kontakte werden hier angezeigt</string>
<string name="address_book_remove_address">Adresse entfernen</string>
<string name="address_book_save_to_wallet_title">In Wallet speichern</string>
<string name="address_book_save_wallet_to_description">Dieser Kontakt wird mit dem Adressbuch dieser Wallet verknüpft.</string>
<string name="address_book_select_network">Netzwerk auswählen</string>
<string name="address_book_title">Adressbuch</string>
@ -348,10 +356,12 @@
<string name="common_from">Vom</string>
<string name="common_from_wallet_name">Von %s</string>
<string name="common_generate_addresses">Adressen synchronisieren</string>
<string name="common_get">Erhalten</string>
<string name="common_get_started">Erste Schritte</string>
<string name="common_get_token">Token erhalten</string>
<string name="common_go_to_provider">Zum Anbieter gehen</string>
<string name="common_go_to_token">Zum Token</string>
<string name="common_go_to_verification">Zur Verifizierung gehen</string>
<string name="common_got_it">Verstanden</string>
<string name="common_hide">Ausblenden</string>
<string name="common_hold_to">Halten bis %s</string>
@ -410,6 +420,7 @@
<string name="common_privacy_policy">Datenschutzrichtlinie</string>
<string name="common_range">%1$s-%2$s</string>
<string name="common_range_with_space">%1$s — %2$s</string>
<string name="common_rate">Zinssatz</string>
<string name="common_read_more">Weiterlesen</string>
<string name="common_receive">Empfangen</string>
<string name="common_received">Erhalten</string>
@ -471,6 +482,7 @@
<item quantity="other">%d Token</item>
</plurals>
<string name="common_transaction_failed">Transaktion fehlgeschlagen</string>
<string name="common_transaction_id">Transaktions-ID</string>
<string name="common_transaction_status">Transaktionsstatus</string>
<string name="common_transactions">Transaktionen</string>
<string name="common_transfer">Überweisung</string>
@ -566,7 +578,7 @@
<string name="dynamic_addresses_disable_main_button_title">Dynamische Adressen deaktivieren</string>
<string name="dynamic_addresses_disable_title">Dynamische Adressen deaktivieren</string>
<string name="dynamic_addresses_disabled_popup_title">Dynamische Adressen deaktiviert</string>
<string name="dynamic_addresses_enabled_toast_title">Einige Adressen fehlen</string>
<string name="dynamic_addresses_enabled_toast_title">Dynamische Adressen aktiviert</string>
<string name="dynamic_addresses_enter_features_privacy_description">Verwenden Sie für jede Transaktion eine neue Adresse, um die Rückverfolgbarkeit zu verringern und den Datenschutz in der Kette zu verbessern.</string>
<string name="dynamic_addresses_enter_features_privacy_title">Verbesserter Datenschutz</string>
<string name="dynamic_addresses_enter_features_receving_description">Einfacher Geldempfang in UTXO-basierten Netzwerken mit automatischer Adressgenerierung - keine manuelle Adressverwaltung erforderlich.</string>
@ -700,6 +712,8 @@
<string name="feedback_subject_support_tangem">Feedback zu Tangem</string>
<string name="feedback_subject_tx_failed">Eine Transaktion kann nicht gesendet werden</string>
<string name="feedback_token_description_error">Fehler in der Coinbeschreibung</string>
<string name="for_you_description">Portfolio prüfen und Verdienstmöglichkeiten erkunden</string>
<string name="for_you_title">Für dich</string>
<string name="force_update_action">Jetzt aktualisieren</string>
<string name="force_update_banner_message">Aktualisiere die Anwendung auf die neueste Version, um die ordnungsgemäße Funktionalität zu gewährleisten</string>
<string name="force_update_banner_title">Aktualisierung erforderlich</string>
@ -731,6 +745,8 @@
<string name="hardware_wallet_key_feature_title">Schlüsselgenerierung</string>
<string name="hardware_wallet_security_feature_description">Alle kryptografischen Vorgänge finden innerhalb des sicheren Chips statt, der gegen Klonen und physische Manipulation zertifiziert ist.</string>
<string name="hardware_wallet_security_feature_title">Sicherheit auf Hardwareebene</string>
<string name="high_fee_warning_description">Das Netzwerk ist derzeit stark ausgelastet. Sie können jetzt fortfahren oder es später erneut versuchen, wenn die Gebühren möglicherweise niedriger sind.</string>
<string name="high_fee_warning_title">Die Netzwerkgebühr ist höher als üblich</string>
<string name="home_button_add_existing_wallet">Vorhandene Wallet hinzufügen</string>
<string name="home_button_create_new_wallet">Neues Wallet erstellen</string>
<string name="home_button_order">Karte oder Ring bestellen</string>
@ -1215,6 +1231,7 @@
<string name="onramp_min_amount_restriction">Der zu kaufende Betrag muss mindestens %s betragen</string>
<string name="onramp_native_payment_cumulative_limit" formatted="false">Kumulierte Transaktionsbeträge über %1s können eine Identitätsüberprüfung mit %2s</string>
<string name="onramp_native_payment_cumulative_limit_equivalent" formatted="false">Kumulierte Transaktionsbeträge über dem Gegenwert von %1s können eine Identitätsprüfung mit %2s</string>
<string name="onramp_native_payment_identity_verification">Apple Pay-Transaktionen erfordern möglicherweise eine Identitätsprüfung mit %1s</string>
<string name="onramp_native_payment_legal_notice" formatted="false">Indem du auf \"Bezahlen\" klicken, stimmen Sie %1s\'s %2s und %3szu.</string>
<string name="onramp_no_available_providers">Keine verfügbaren Anbieter für diese Währung</string>
<string name="onramp_offer_type_fastet">Schnellste Bearbeitung</string>
@ -1401,6 +1418,7 @@
<string name="send_extras_hint_memo">Memo</string>
<string name="send_fee_unreachable_error_text">Überprüfe deine Netzwerkverbindung</string>
<string name="send_fee_unreachable_error_title">Informationen zur Netzwerkgebühr nicht erreichbar</string>
<string name="send_from_account_in_wallet_name">aus „ %1$s “ in %2$s</string>
<string name="send_from_title">Sie senden</string>
<string name="send_from_wallet_name">Von %s</string>
<string name="send_gas_limit">Grenzwert Gasgebühr</string>
@ -1551,6 +1569,7 @@
<string name="staking_error_no_validators_message">Zurzeit sind keine Validierer verfügbar. Bitte versuche es später noch einmal.</string>
<string name="staking_error_no_validators_title">Staking nicht verfügbar</string>
<string name="staking_error_unavailable_region">Staking ist in Ihrer Region nicht verfügbar.</string>
<string name="staking_error_unavailable_region_description">Staking ist in Ihrer Region derzeit nicht verfügbar. Falls Sie ein VPN aktiviert haben, deaktivieren Sie es bitte.</string>
<string name="staking_give_permission_fee_footer">Das Netzwerk erhebt eine Token-Genehmigungsgebühr, um zu überprüfen, ob Du die Verwendung Deines Tokens für das Staking genehmigst.</string>
<string name="staking_legal">Indem Du die Staking-Funktionalität nutzt, stimmst Du den %1$s und %2$s des Anbieters zu.</string>
<string name="staking_locked">Gesperrt</string>
@ -1846,6 +1865,10 @@
<string name="tangempay_change_pin_code">PIN-Code ändern</string>
<string name="tangempay_come_back_if_forget_pin">Kehren Sie zur App zurück, falls Sie ihn vergessen.</string>
<string name="tangempay_common_card">Karte</string>
<string name="tangempay_current_plan_change">Tarif wechseln</string>
<string name="tangempay_current_plan_section_card">Kartenbezogen</string>
<string name="tangempay_current_plan_section_plan">Planbezogen</string>
<string name="tangempay_current_plan_title">Aktueller Plan</string>
<string name="tangempay_daily_limit_hint" formatted="false">Limit von %s bis %s festlegen</string>
<string name="tangempay_daily_limit_set_button">Limits festlegen</string>
<string name="tangempay_declined_reason_1">Unzureichendes Guthaben</string>
@ -2059,6 +2082,7 @@
<string name="twins_scan_twin_with_number">Tippe auf die Doppelkarte oder Ring mit der Nummer %s und entferne sie erst am Ende des Vorgangs.</string>
<string name="tx_history_onramp_top_up">Aufladen</string>
<string name="tx_history_onramp_topped_up">Aufgeladen</string>
<string name="tx_history_you_paid">Du hast bezahlt</string>
<string name="unexpected_error_description">Bitte versuche es später noch einmal. Sollte das Problem weiterhin bestehen, wende Dich bitte an den Support.</string>
<string name="unexpected_error_title">Etwas ist schiefgelaufen!</string>
<string name="universal_error">Es ist ein Fehler aufgetreten. Fehlercode: %s. Bitte kontaktiere unseren Support.</string>

View file

@ -1208,6 +1208,7 @@
<string name="onramp_min_amount_restriction">La cantidad a comprar debe ser como mínimo %s</string>
<string name="onramp_native_payment_cumulative_limit" formatted="false">El importe acumulado de la transacción superior a %1s puede requerir la verificación de la identidad con %2s</string>
<string name="onramp_native_payment_cumulative_limit_equivalent" formatted="false">El importe acumulado de la transacción superior al equivalente de %1s puede requerir la verificación de la identidad con %2s</string>
<string name="onramp_native_payment_identity_verification">Las transacciones de Apple Pay pueden requerir verificación de identidad con %1s</string>
<string name="onramp_native_payment_legal_notice" formatted="false">Al hacer clic en Pagar, usted acepta %1s\'s %2s y %3s.</string>
<string name="onramp_no_available_providers">No hay proveedores disponibles para esta moneda</string>
<string name="onramp_offer_type_fastet">Procesamiento más rápido</string>

View file

@ -1112,6 +1112,7 @@
<string name="onramp_min_amount_restriction">Le montant à acheter doit être au moins %s</string>
<string name="onramp_native_payment_cumulative_limit" formatted="false">Si le montant cumulé des transactions dépasse %1s, une vérification d\'identité via %2s pourrait être requise</string>
<string name="onramp_native_payment_cumulative_limit_equivalent" formatted="false">Si le montant cumulé des transactions dépasse l\'équivalent de %1s, une vérification d\'identité via %2s pourrait être requise</string>
<string name="onramp_native_payment_identity_verification">Les transactions Apple Pay peuvent nécessiter une vérification d\'identité avec %1s</string>
<string name="onramp_native_payment_legal_notice" formatted="false">En appuyant sur Acheter, vous acceptez %1s %2s et %3s.</string>
<string name="onramp_no_available_providers">Aucun fournisseur disponible pour cette devise</string>
<string name="onramp_offer_type_fastet">Le plus rapide</string>

View file

@ -1191,6 +1191,7 @@
<string name="onramp_min_amount_restriction">買付金額は少なくとも%sである必要があります</string>
<string name="onramp_native_payment_cumulative_limit" formatted="false">累計取引額が%1sを超えると、%2sでの本人確認が必要になる場合があります。</string>
<string name="onramp_native_payment_cumulative_limit_equivalent" formatted="false">累計取引額が%1s相当額を超えると、%2sでの本人確認が必要になる場合があります。</string>
<string name="onramp_native_payment_identity_verification">Apple Payの取引では、%1sによる本人確認が必要になる場合があります</string>
<string name="onramp_native_payment_legal_notice" formatted="false">「支払う」をタップすると、%1sの%2sおよび%3sに同意したものとみなされます。</string>
<string name="onramp_no_available_providers">この通貨で利用可能なプロバイダーはありません</string>
<string name="onramp_offer_type_fastet">最短で処理</string>

View file

@ -1251,6 +1251,7 @@
<string name="onramp_min_amount_restriction">Сумма покупки должна составлять минимум %s</string>
<string name="onramp_native_payment_cumulative_limit" formatted="false">Общая сумма транзакций свыше %1s может потребовать верификации личности через %2s</string>
<string name="onramp_native_payment_cumulative_limit_equivalent" formatted="false">Общая сумма транзакций, превышающая эквивалент %1s, может потребовать верификации личности через %2s</string>
<string name="onramp_native_payment_identity_verification">Для транзакций через Apple Pay может потребоваться верификация личности в %1s</string>
<string name="onramp_native_payment_legal_notice" formatted="false">Нажимая «Оплатить», вы соглашаетесь с %1s\'s %2s и %3s.</string>
<string name="onramp_no_available_providers">Нет доступных провайдеров для выбранной валюты</string>
<string name="onramp_offer_type_fastet">Самый быстрый</string>

View file

@ -1251,6 +1251,7 @@
<string name="onramp_min_amount_restriction">Сума покупки повинна бути не менше %s</string>
<string name="onramp_native_payment_cumulative_limit" formatted="false">Загальна сума транзакцій понад %1s може вимагати верифікації особи через %2s</string>
<string name="onramp_native_payment_cumulative_limit_equivalent" formatted="false">Загальна сума транзакцій, що перевищує еквівалент %1s, може вимагати верифікації особи через %2s</string>
<string name="onramp_native_payment_identity_verification">Для транзакцій через Apple Pay може знадобитися верифікація особи в %1s</string>
<string name="onramp_native_payment_legal_notice" formatted="false">Натискаючи «Оплатити», ви погоджуєтеся з %1s\'s %2s і %3s.</string>
<string name="onramp_no_available_providers">Для данної валюти немає доступних провайдерів</string>
<string name="onramp_offer_type_fastet">Найшвидший</string>

View file

@ -1185,6 +1185,7 @@
<string name="onramp_min_amount_restriction">购买金额必须至少 %s</string>
<string name="onramp_native_payment_cumulative_limit" formatted="false">累计交易金额超过 %1s 时,可能需要通过 %2s进行身份验证</string>
<string name="onramp_native_payment_cumulative_limit_equivalent" formatted="false">累计交易金额超过等值金额 %1s 可能需要通过 %2s进行身份验证</string>
<string name="onramp_native_payment_identity_verification">Apple Pay 交易可能需要通过以下方式进行身份验证: %1s</string>
<string name="onramp_native_payment_legal_notice" formatted="false">点击“支付”即表示您同意 %1s的 %2s 和 %3s。</string>
<string name="onramp_no_available_providers">目前没有提供此货币的供应商</string>
<string name="onramp_offer_type_fastet">最快处理</string>

View file

@ -98,6 +98,8 @@
<string name="address_book_add_address">Add address</string>
<string name="address_book_add_address_description">Add address and select network</string>
<string name="address_book_add_contact">Add contact</string>
<string name="address_book_address_copied">Address copied</string>
<string name="address_book_address_taken_error">This address is already saved as %1$s</string>
<plurals name="address_book_addresses">
<item quantity="one">%d address</item>
<item quantity="other">%d addresses</item>
@ -106,6 +108,7 @@
<string name="address_book_contact">Contact</string>
<string name="address_book_contact_name">Contact name</string>
<string name="address_book_copy_address">Copy address</string>
<string name="address_book_create_success_message">Contact added</string>
<string name="address_book_creating_error">Couldn\'t create contact. Please try again later.</string>
<string name="address_book_delete_contact_description">This contact will be deleted from all your address books</string>
<string name="address_book_deleting_error">Couldn\'t delete contact. Please try again later.</string>
@ -116,7 +119,7 @@
<string name="address_book_invalid_address_error">Invalid address</string>
<string name="address_book_keep_editing">Keep editing</string>
<string name="address_book_max_networks_alert_description">You can not create more than 20 addresses. Delete one to add new.</string>
<string name="address_book_max_networks_alert_title">Can\'t add new address </string>
<string name="address_book_max_networks_alert_title">Can\'t add new address</string>
<string name="address_book_name_empty_error">Contact name is required</string>
<string name="address_book_name_invalid_chars_error">Contact name contains invalid characters</string>
<string name="address_book_name_max_chars_error">Contact name must not exceed 50 characters</string>
@ -125,8 +128,10 @@
<string name="address_book_no_contacts">No contacts yet</string>
<string name="address_book_no_contacts_description">Contacts added will appear here</string>
<string name="address_book_remove_address">Remove address</string>
<string name="address_book_save_contact">Save contact</string>
<string name="address_book_save_to_wallet_title">Save to Wallet</string>
<string name="address_book_save_wallet_to_description">This contact will be linked to this wallets address book.</string>
<string name="address_book_search_no_results">No results found.\nTry another name</string>
<string name="address_book_select_network">Select network</string>
<string name="address_book_title">Address book</string>
<string name="address_book_unsaved_changes">Unsaved changes</string>
@ -355,6 +360,7 @@
<string name="common_from">From</string>
<string name="common_from_wallet_name">From %s</string>
<string name="common_generate_addresses">Synchronize addresses</string>
<string name="common_get">Get</string>
<string name="common_get_started">Get started</string>
<string name="common_get_token">Get token</string>
<string name="common_go_to_provider">Go to provider</string>
@ -480,6 +486,7 @@
<item quantity="other">%d tokens</item>
</plurals>
<string name="common_transaction_failed">Transaction failed</string>
<string name="common_transaction_id">Transaction ID</string>
<string name="common_transaction_status">Transaction status</string>
<string name="common_transactions">Transactions</string>
<string name="common_transfer">Transfer</string>
@ -880,6 +887,16 @@
<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_support_only_one_network_title">The wallet doesn\'t support more than one network</string>
<string name="market_chart_ai_total">AI Total:</string>
<string name="market_chart_ask_for_ai_summary_button">Ask for AI summary</string>
<plurals name="market_chart_assets_android">
<item quantity="one">%d asset</item>
<item quantity="other">%d assets</item>
</plurals>
<string name="market_chart_bubble_no_data">No data</string>
<string name="market_chart_bubble_total_value">Total value</string>
<string name="market_chart_can_not_load_data">Cant load data</string>
<string name="market_chart_top_holding">Top holding %s%%</string>
<string name="markets_about_coin_header">About coin</string>
<string name="markets_add_to_my_portfolio_description">To buy, exchange, or receive this asset, add it to your portfolio</string>
<string name="markets_add_to_my_portfolio_unavailable_description">This asset is currently not supported in the wallet</string>
@ -1229,6 +1246,7 @@
<string name="onramp_min_amount_restriction">The amount to buy must be at least %s</string>
<string name="onramp_native_payment_cumulative_limit" formatted="false">Cumulative transaction amount over %1s may require identity verification with %2s</string>
<string name="onramp_native_payment_cumulative_limit_equivalent" formatted="false">Cumulative transaction amount over equivalent of %1s may require identity verification with %2s</string>
<string name="onramp_native_payment_identity_verification">Apple Pay transactions may require identity verification with %1s</string>
<string name="onramp_native_payment_legal_notice" formatted="false">By clicking Pay, you agree to %1s\'s %2s and %3s.</string>
<string name="onramp_no_available_providers">No available providers for this currency</string>
<string name="onramp_offer_type_fastet">Quickest processing</string>
@ -1566,6 +1584,7 @@
<string name="staking_error_no_validators_message">No available validators at the moment. Please try again later.</string>
<string name="staking_error_no_validators_title">Staking Unavailable</string>
<string name="staking_error_unavailable_region">Staking is unavailable in your region</string>
<string name="staking_error_unavailable_region_description">Staking is currently unavailable in your region. If you have VPN enabled try disabling it.</string>
<string name="staking_give_permission_fee_footer">The network will charge a token approval fee to verify that you are authorizing the use of your token for the staking.</string>
<string name="staking_legal">By using staking functionality, you agree with providers %1$s and %2$s</string>
<string name="staking_locked">Locked</string>
@ -1861,6 +1880,10 @@
<string name="tangempay_change_pin_code">Change PIN-code</string>
<string name="tangempay_come_back_if_forget_pin">Come back to the app if you forget it.</string>
<string name="tangempay_common_card">Card</string>
<string name="tangempay_current_plan_change">Change plan</string>
<string name="tangempay_current_plan_section_card">Card related</string>
<string name="tangempay_current_plan_section_plan">Plan related</string>
<string name="tangempay_current_plan_title">Current plan</string>
<string name="tangempay_daily_limit_hint" formatted="false">Set a limit from %s to %s</string>
<string name="tangempay_daily_limit_set_button">Set limits</string>
<string name="tangempay_declined_reason_1">insufficient funds</string>
@ -1951,10 +1974,6 @@
<string name="tangempay_pay_support">Pay Support</string>
<string name="tangempay_payment_account">Payment account</string>
<string name="tangempay_payment_account_sync_needed">Session expired</string>
<string name="tangempay_current_plan_title">Current plan</string>
<string name="tangempay_current_plan_section_card">Card related</string>
<string name="tangempay_current_plan_section_plan">Plan related</string>
<string name="tangempay_current_plan_change">Change plan</string>
<string name="tangempay_pin_validation_error_message">Invalid PIN: avoid sequences or repeats</string>
<string name="tangempay_reissue_card_confirm">Replace card</string>
<string name="tangempay_reissue_card_description">This generates a new set of card details. Your old details will stop working. You can\'t undo this.</string>

View file

@ -19,6 +19,7 @@ import androidx.compose.ui.graphics.LinearGradientShader
import androidx.compose.ui.graphics.Shader
import androidx.compose.ui.graphics.ShaderBrush
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.haze.hazeEffectTangem
@ -70,6 +71,7 @@ fun TangemSurface(
onClick: (() -> Unit)? = null,
enabled: Boolean = true,
interactionSource: MutableInteractionSource? = null,
shadowRadius: Dp = 40.dp,
content: @Composable () -> Unit,
) {
val resolvedInteractionSource = interactionSource ?: remember { MutableInteractionSource() }
@ -77,7 +79,7 @@ fun TangemSurface(
val surface: @Composable () -> Unit = {
Box(
modifier = modifier
.conditionalCompose(isMaterial) { materialShadow(shape) }
.conditionalCompose(isMaterial) { materialShadow(shape, shadowRadius) }
.conditionalCompose(border != null) { border(border!!, shape) }
.conditionalCompose(isMaterial) { materialBorder(shape) }
.clip(shape)
@ -115,8 +117,8 @@ fun TangemSurface(
* `isAlphaContentClip`) to avoid the dark blur bleeding through the surface.
*/
@Composable
private fun Modifier.materialShadow(shape: Shape): Modifier = softLayerShadow(
radius = 40.dp,
private fun Modifier.materialShadow(shape: Shape, radius: Dp): Modifier = softLayerShadow(
radius = radius,
color = Color.Black.copy(alpha = 0.12f),
shape = shape,
spread = 0.dp,

View file

@ -8,6 +8,12 @@ plugins {
android {
namespace = "com.tangem.features.foryou.impl"
packaging {
resources {
merges += "paymentrequest.proto"
}
}
}
dependencies {
@ -22,8 +28,10 @@ dependencies {
implementation(deps.compose.ui)
implementation(deps.compose.foundation)
implementation(deps.compose.animation)
implementation(deps.lifecycle.compose)
implementation(deps.compose.material3)
implementation(deps.compose.ui.tooling)
/** DI */
implementation(deps.hilt.android)

View file

@ -0,0 +1,424 @@
package com.tangem.features.foryou.impl.components
import android.content.res.Configuration
import android.graphics.BlurMaskFilter
import android.graphics.PorterDuff
import android.graphics.PorterDuffXfermode
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.toSize
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.features.foryou.impl.R
import com.tangem.features.foryou.impl.components.state.DonutSegmentUM
import java.math.BigDecimal
import kotlin.math.atan2
import kotlin.math.hypot
import kotlin.math.min
/**
* Ring (donut) chart drawn behind a center [content] slot.
*
* The ring is painted via [Modifier.drawBehind] so the center label stays a normal composable
* ([content]) laid out on top no manual text measuring inside the canvas.
*
* Paint order (bottom top), all inside the canvas:
* 1. The full-circle [trackColor] track (always drawn it is the empty-state look when [segments] is empty),
* with its own inner shadow.
* 2. Each slice (reverse list order, so slice 0 ends up on top its round cap tucks over the next one):
* the solid stroke, then **its own** white inner shadow (Figma: X0 Y4 Blur8, white 24%) drawn right on
* top of it. Per-slice (not one shadow over the whole ring) is what gives each pill its glossy, raised
* look and the highlight at the colour seams. No colored glow/halo is used.
*
* Empty state: pass an empty [segments] list only the track + its inner shadow render, and [content] can
* show the "No data" label.
*
* Selection: when [selectedIndex] is non-null, everything except the chosen slice is dimmed with a
* theme-adaptive overlay ([TangemTheme.colors3.border.inverse.tertiary]) both the other slices and the
* track (unfilled remainder) so only the selected slice stays at full strength. Only one slice can be
* selected at a time selection is hoisted (the index is the slice's identity, as there are no segment ids
* yet). Taps are hit-tested against the ring band only and reported via [onSegmentClick]; the chart is
* interactive only when [onSegmentClick] is set **and** [segments] is non-empty.
*
* @param segments Slices, in priority order (index 0 is painted on top). See [DonutSegmentUM.weight].
* @param modifier Modifier; should carry the overall size (e.g. `Modifier.size(240.dp)`).
* @param selectedIndex Index of the currently selected slice, or `null` for no selection (nothing dimmed).
* @param onSegmentClick Invoked on every tap inside the chart: with the tapped slice index, or with `null`
* when the tap missed all slices (the center hole or the unfilled track). Passing `null` for the whole
* callback makes the chart non-interactive. Toggling/switching/clearing the selection is the caller's
* responsibility e.g. map a repeat tap or a miss to deselection, and a tap on another slice to a switch.
* @param strokeWidth Thickness of the ring.
* @param trackColor Fill of the unfilled remainder of the circle (and the empty-state ring).
* @param startAngle Angle (degrees) where the first slice starts. `-90f` = 12 o'clock.
* @param content Centered content (e.g. total value + caption, or the "No data" label).
*/
@Suppress("MagicNumber", "LongParameterList", "LongMethod", "NamedArguments")
@Composable
internal fun DonutChart(
segments: List<DonutSegmentUM>,
modifier: Modifier = Modifier,
selectedIndex: Int? = null,
onSegmentClick: ((index: Int?) -> Unit)? = null,
strokeWidth: Dp = 28.dp,
trackColor: Color = TangemTheme.colors3.border.tertiary,
startAngle: Float = -90f,
content: @Composable ColumnScope.() -> Unit,
) {
val strokePx = with(LocalDensity.current) { strokeWidth.toPx() }
val dimOverlayColor = TangemTheme.colors3.border.inverse.tertiary
// Fade the dim in/out in step with the segment tooltip's pop-in (same spring as DonutSegmentTooltip).
val dimProgress by animateFloatAsState(
targetValue = if (selectedIndex != null) 1f else 0f,
animationSpec = spring(dampingRatio = DIM_SPRING_DAMPING, stiffness = DIM_SPRING_STIFFNESS),
label = "donutDim",
)
var highlightedIndex by remember { mutableStateOf<Int?>(null) }
if (selectedIndex != null) highlightedIndex = selectedIndex
val latestSelectedIndex by rememberUpdatedState(selectedIndex)
val latestOnSegmentClick by rememberUpdatedState(onSegmentClick)
val clickModifier = if (onSegmentClick != null && segments.isNotEmpty()) {
Modifier.pointerInput(segments, startAngle, strokePx) {
detectTapGestures { tap ->
val clickedIndex = segmentIndexAt(tap, size.toSize(), strokePx, segments, startAngle)
if (latestSelectedIndex != clickedIndex) latestOnSegmentClick?.invoke(clickedIndex)
}
}
} else {
Modifier
}
Box(
modifier = modifier
.then(clickModifier)
.drawBehind {
val arc = arcRect(strokePx)
// Inner shadow params from Figma: X0 Y4 Blur8 Spread0, white 24%.
val innerDx = 0f
val innerDy = 4.dp.toPx()
val innerBlur = 8.dp.toPx()
// 1. Track — full circle behind everything, plus its inner shadow.
drawArc(
color = trackColor,
startAngle = 0f,
sweepAngle = 360f,
useCenter = false,
topLeft = arc.topLeft,
size = arc.size,
style = Stroke(width = strokePx, cap = StrokeCap.Round),
)
drawInnerShadowArc(arc, 0f, 360f, strokePx, InnerShadowColor, innerBlur, innerDx, innerDy)
// Dim intensity animates 0f..1f; scale the overlay's own alpha by it so the dim fades.
val dim = dimProgress.coerceIn(0f, 1f)
val dimColor = dimOverlayColor.copy(alpha = dimOverlayColor.alpha * dim)
// Once a selection exists, dim the whole track too, so the unfilled remainder fades
// along with the non-selected slices instead of staying bright. Drawn before the
// slices, so each slice (selected included) paints on top at full strength.
if (dim > 0f) {
drawArc(
color = dimColor,
startAngle = 0f,
sweepAngle = 360f,
useCenter = false,
topLeft = arc.topLeft,
size = arc.size,
style = Stroke(width = strokePx, cap = StrokeCap.Round),
)
}
// Precompute each slice's [start, sweep] once.
val sweeps = segments.map { it.weight.toFloat().coerceIn(0f, 1f) * 360f }
val starts = sweeps.runningFold(startAngle) { acc, sweep -> acc + sweep }
// 2. Slices — reversed so slice 0 sits on top of its neighbor. Each slice gets its own
// inner shadow right after its fill, so the glossy highlight follows every pill (and
// every colour seam), not just the ring's outer/inner contour.
for (i in segments.indices.reversed()) {
if (sweeps[i] <= 0f) continue
drawArc(
color = segments[i].color,
startAngle = starts[i],
sweepAngle = sweeps[i],
useCenter = false,
topLeft = arc.topLeft,
size = arc.size,
style = Stroke(width = strokePx, cap = StrokeCap.Round),
)
drawInnerShadowArc(
arc = arc,
startAngle = starts[i],
sweepAngle = sweeps[i],
strokePx = strokePx,
shadowColor = InnerShadowColor,
blurPx = innerBlur,
dx = innerDx,
dy = innerDy,
)
// Dim every non-highlighted slice while a selection is active — the chosen one stays
// bright. Uses the animated [dim] so it fades, and [highlightedIndex] (not selectedIndex)
// so the right slice stays bright through the fade-out after deselection.
if (dim > 0f && i != highlightedIndex) {
drawArc(
color = dimColor,
startAngle = starts[i],
sweepAngle = sweeps[i],
useCenter = false,
topLeft = arc.topLeft,
size = arc.size,
style = Stroke(width = strokePx, cap = StrokeCap.Round),
)
}
}
},
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(horizontal = 36.dp),
) {
content()
}
}
}
/**
* Maps a tap [tap] to the index of the slice under it, or `null` if the tap is outside the ring band or
* lands on a gap/track. Uses the same geometry as the drawing pass: the ring is centered, its outer radius
* is half the min side and its inner radius is `outer - strokePx`. The angular test reuses the
* `runningFold` start/sweep layout. A small radial tolerance makes the thin ring comfortable to hit.
*/
@Suppress("MagicNumber", "ReturnCount")
private fun segmentIndexAt(
tap: Offset,
size: Size,
strokePx: Float,
segments: List<DonutSegmentUM>,
startAngle: Float,
): Int? {
val cx = size.width / 2f
val cy = size.height / 2f
val dx = tap.x - cx
val dy = tap.y - cy
val outer = min(size.width, size.height) / 2f
val inner = outer - strokePx
val tolerance = strokePx * 0.4f
val dist = hypot(dx, dy)
if (dist < inner - tolerance || dist > outer + tolerance) return null
// Degrees clockwise from 3 o'clock — same convention as Canvas.drawArc.
val angle = Math.toDegrees(atan2(dy, dx).toDouble()).toFloat().mod(360f)
val sweeps = segments.map { it.weight.toFloat().coerceIn(0f, 1f) * 360f }
val starts = sweeps.runningFold(startAngle) { acc, sweep -> acc + sweep }
for (i in segments.indices) {
if (sweeps[i] <= 0f) continue
val relative = (angle - starts[i].mod(360f)).mod(360f)
if (relative <= sweeps[i]) return i
}
return null
}
/** Square arc bounds, centered in this [DrawScope], inset by half the stroke so the ring fits inside. */
private fun DrawScope.arcRect(strokePx: Float): ArcRect {
val diameter = min(size.width, size.height)
val side = diameter - strokePx
val left = (size.width - diameter) / 2f + strokePx / 2f
val top = (size.height - diameter) / 2f + strokePx / 2f
return ArcRect(topLeft = Offset(left, top), size = Size(side, side))
}
private data class ArcRect(val topLeft: Offset, val size: Size)
/**
* Draws an inset (inner) shadow confined to a single stroked arc the canvas equivalent of CSS
* `box-shadow: inset`. Works on every API level (uses [BlurMaskFilter], not RenderEffect).
*
* Technique: in an isolated layer, paint the arc silhouette in [shadowColor], then "punch out" the same
* arc offset by ([dx], [dy]) and blurred via [PorterDuff.Mode.DST_OUT]. What survives is a blurred band
* of [shadowColor] hugging the edge opposite the offset i.e. the inner shadow.
*/
@Suppress("LongParameterList")
private fun DrawScope.drawInnerShadowArc(
arc: ArcRect,
startAngle: Float,
sweepAngle: Float,
strokePx: Float,
shadowColor: Color,
blurPx: Float,
dx: Float,
dy: Float,
) {
if (sweepAngle <= 0f || blurPx <= 0f) return
drawIntoCanvas { canvas ->
val native = canvas.nativeCanvas
val l = arc.topLeft.x
val t = arc.topLeft.y
val r = l + arc.size.width
val b = t + arc.size.height
val layer = native.saveLayer(null, null)
// 1. The slice silhouette in the shadow color.
val basePaint = android.graphics.Paint().apply {
isAntiAlias = true
style = android.graphics.Paint.Style.STROKE
strokeWidth = strokePx
strokeCap = android.graphics.Paint.Cap.ROUND
this.color = shadowColor.toArgb()
}
native.drawArc(l, t, r, b, startAngle, sweepAngle, false, basePaint)
// 2. Punch out an offset, blurred copy — leaves the shadow only along the inner edge.
val cutPaint = android.graphics.Paint().apply {
isAntiAlias = true
style = android.graphics.Paint.Style.STROKE
strokeWidth = strokePx
strokeCap = android.graphics.Paint.Cap.ROUND
this.color = android.graphics.Color.BLACK
xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_OUT)
maskFilter = BlurMaskFilter(blurPx, BlurMaskFilter.Blur.NORMAL)
}
native.drawArc(l + dx, t + dy, r + dx, b + dy, startAngle, sweepAngle, false, cutPaint)
native.restoreToCount(layer)
}
}
/** Inner shadow — Figma #FFFFFF at 24% opacity (theme-independent). */
private val InnerShadowColor = Color.White.copy(alpha = 0.24f)
// Selection-dim spring, mirroring DonutSegmentTooltip's pop-in so the dim and the tooltip move together.
private const val DIM_SPRING_DAMPING = 0.82f
private const val DIM_SPRING_STIFFNESS = 1100f
// region Previews
@Suppress("MagicNumber")
@Preview(name = "DonutChart • Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(name = "DonutChart • Light", showBackground = true)
@Composable
private fun PreviewDonutChart() {
TangemThemePreviewRedesign {
// Tap a slice to select it (others dim); tap it again to clear. Caller owns the selection.
var selectedIndex by remember { mutableStateOf<Int?>(null) }
Box(
modifier = Modifier
.background(TangemTheme.colors3.bg.primary)
.padding(32.dp),
contentAlignment = Alignment.Center,
) {
DonutChart(
modifier = Modifier.size(260.dp),
selectedIndex = selectedIndex,
onSegmentClick = { index -> selectedIndex = index.takeIf { it != selectedIndex } },
segments = listOf(
DonutSegmentUM(
weight = BigDecimal(0.55),
color = TangemTheme.colors3.border.brand,
title = stringReference("Ethereum"),
fiatValue = stringReference("$5,720.22"),
),
DonutSegmentUM(
weight = BigDecimal(0.07),
color = TangemTheme.colors3.border.accent.violet,
title = stringReference("Solana"),
fiatValue = stringReference("$728.30"),
),
DonutSegmentUM(
weight = BigDecimal(0.06),
color = TangemTheme.colors3.border.accent.red,
title = stringReference("Polkadot"),
fiatValue = stringReference("$624.26"),
),
DonutSegmentUM(
weight = BigDecimal(0.05),
color = TangemTheme.colors3.border.accent.green,
title = stringReference("Tether"),
fiatValue = stringReference("$520.18"),
),
),
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "$10,000.1333",
color = TangemTheme.colors3.text.primary,
style = TangemTheme.typography3.heading.medium,
)
Text(
text = stringResourceSafe(R.string.market_chart_bubble_total_value),
color = TangemTheme.colors3.text.secondary,
style = TangemTheme.typography3.body.medium,
)
}
}
}
}
}
@Preview(name = "DonutChart Empty • Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(name = "DonutChart Empty • Light", showBackground = true)
@Composable
private fun PreviewDonutChartEmpty() {
TangemThemePreviewRedesign {
Box(
modifier = Modifier
.background(TangemTheme.colors3.bg.primary)
.padding(32.dp),
contentAlignment = Alignment.Center,
) {
DonutChart(
modifier = Modifier.size(260.dp),
segments = emptyList(),
) {
Text(
text = stringResourceSafe(R.string.market_chart_bubble_no_data),
color = TangemTheme.colors3.text.primary,
style = TangemTheme.typography3.heading.medium,
)
}
}
}
}
// endregion

View file

@ -0,0 +1,199 @@
package com.tangem.features.foryou.impl.components
import android.content.res.Configuration
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.rememberTransition
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider
import androidx.compose.ui.window.PopupProperties
import com.tangem.core.ui.ds2.surface.TangemSurface
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
/**
* Frosted-glass selection tooltip for a [DonutChart] slice.
*
* Modeled on [com.tangem.core.ui.ds.contextmenu.TangemContextMenu]: it shows a [Popup] with the same
* springy pop-in animation, but renders the content on a translucent [TangemSurface] (`isMaterial = true`)
* for the glass-morphism look instead of the context menu's opaque card. The pill is centered over the
* chart's anchor (see [CenteredOverAnchorPositionProvider]).
*
* The popup is intentionally **non-focusable**, so its window is not modal: taps outside the pill pass
* straight through to the [DonutChart] underneath, letting the user switch to another slice in a single
* tap. `dismissOnClickOutside` stays on, so a tap anywhere off the pill including outside the chart card,
* elsewhere on the host screen fires [onDismissRequest].
*
* Caveat the caller must handle: that outside-tap detection fires on the **DOWN**, before the chart's tap
* resolves on the UP, and it also fires for taps that land on the chart. So [onDismissRequest] alone can't
* tell "tapped the chart" from "tapped elsewhere" the caller must veto/defer it when the press actually
* landed on the chart (see `MarketChart`), otherwise selecting a slice would briefly clear the selection
* first and flicker the popup.
*
* @param expanded Whether the tooltip is shown. Toggling to `false` plays the scale-out before the popup
* leaves the composition.
* @param title Asset name shown on the first line (e.g. `"Ethereum"`).
* @param fiatValue Pre-formatted fiat value shown on the second line (e.g. `"$5,720.22"`).
* @param percent Pre-formatted share shown after the value, dimmed (e.g. `"57.5%"`).
* @param onDismissRequest Fired on a tap anywhere off the pill (DOWN). Clear the selection here, but see the
* caveat above: defer it so a press on the chart can veto it before it commits.
* @param modifier Modifier applied to the pill surface.
* @param positionProvider Where the pill is placed. Defaults to centered over the anchor; pass a
* [SegmentTooltipPositionProvider] to anchor it to the end of the selected slice.
*/
@Suppress("LongParameterList")
@Composable
internal fun DonutSegmentTooltip(
expanded: Boolean,
title: TextReference,
fiatValue: TextReference,
percent: String,
positionProvider: PopupPositionProvider,
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
) {
val expandedStates = remember { MutableTransitionState(false) }
expandedStates.targetState = expanded
if (expandedStates.currentState || expandedStates.targetState) {
Popup(
onDismissRequest = onDismissRequest,
popupPositionProvider = positionProvider,
properties = PopupProperties(focusable = false, dismissOnClickOutside = true),
) {
TooltipPill(
expandedStates = expandedStates,
title = title,
fiatValue = fiatValue,
percent = percent,
modifier = modifier,
)
}
}
}
private const val OUT_TRANSITION_DURATION = 75
private const val ENTER_SPRING_DAMPING = 0.82f
private const val ENTER_SPRING_STIFFNESS = 1100f
private const val DISMISSED_SCALE = 0.8f
@Suppress("MagicNumber")
@Composable
private fun TooltipPill(
expandedStates: MutableTransitionState<Boolean>,
title: TextReference,
fiatValue: TextReference,
percent: String,
modifier: Modifier = Modifier,
) {
val transition = rememberTransition(expandedStates, label = "DonutSegmentTooltip")
val scale by transition.animateFloat(
transitionSpec = {
if (false isTransitioningTo true) {
spring(dampingRatio = ENTER_SPRING_DAMPING, stiffness = ENTER_SPRING_STIFFNESS)
} else {
tween(durationMillis = 1, delayMillis = OUT_TRANSITION_DURATION - 1)
}
},
label = "scale",
) { isExpanded -> if (isExpanded) 1f else DISMISSED_SCALE }
val alpha by transition.animateFloat(
transitionSpec = {
if (false isTransitioningTo true) {
tween(durationMillis = 30)
} else {
tween(durationMillis = OUT_TRANSITION_DURATION)
}
},
label = "alpha",
) { isExpanded -> if (isExpanded) 1f else 0f }
TangemSurface(
modifier = modifier.graphicsLayer {
scaleX = scale
scaleY = scale
this.alpha = alpha
transformOrigin = TransformOrigin.Center
},
isMaterial = true,
shape = RoundedCornerShape(percent = 50),
shadowRadius = 10.dp,
) {
Column(
modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
text = title.resolveReference(),
color = TangemTheme.colors3.text.primary,
style = TangemTheme.typography3.caption.medium,
maxLines = 1,
)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = fiatValue.resolveReference(),
color = TangemTheme.colors3.text.primary,
style = TangemTheme.typography3.caption.medium,
maxLines = 1,
)
Text(
text = "$percent",
color = TangemTheme.colors3.text.tertiary,
style = TangemTheme.typography3.caption.medium,
maxLines = 1,
)
}
}
}
}
// region Preview
@Suppress("MagicNumber")
@Preview(name = "DonutSegmentTooltip • Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(name = "DonutSegmentTooltip • Light", showBackground = true)
@Composable
private fun PreviewDonutSegmentTooltip() {
TangemThemePreviewRedesign {
Box(
modifier = Modifier
.background(TangemTheme.colors3.bg.secondary)
.padding(32.dp),
contentAlignment = Alignment.Center,
) {
TooltipPill(
expandedStates = remember { MutableTransitionState(true) },
title = stringReference("Ethereum"),
fiatValue = stringReference("$5,720.22"),
percent = "57.5%",
)
}
}
}
// endregion

View file

@ -0,0 +1,219 @@
package com.tangem.features.foryou.impl.components
import android.content.res.Configuration
import android.graphics.BlurMaskFilter
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.dropShadow
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Paint
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.shadow.Shadow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
/**
* One blurred color blob painted inside [CanvasGradientDivider].
*
* @param color Blob color.
* @param offset Blob center, relative to the line's top-center.
* @param height Blob height (its vertical diameter); the width is fixed by [DotWidth].
* @param blur Mask-blur radius applied via [BlurMaskFilter].
*/
internal data class CanvasGlowDot(
val color: Color,
val offset: DpOffset,
val height: Dp,
val blur: Dp = 8.dp,
)
/**
* Canvas-based glow divider: the line background and every blurred color blob are painted by hand inside a
* single `Box(Modifier.drawBehind { })`, with no child composables. Inside [Modifier.drawBehind] we clip
* to the capsule path, fill the base [LineColor], then draw each [CanvasGlowDot] as an oval whose native
* [Paint] carries a [BlurMaskFilter] the canvas equivalent of a Gaussian layer blur. The clip means a blob
* drawn past the line's bounds only paints its color onto the visible capsule.
*
* @param modifier Modifier for positioning; the capsule height comes from the parent, so the glow scales
* with the divider's length.
* @param lineWidth Width of the capsule.
*/
@Suppress("MagicNumber", "LongParameterList", "LongMethod")
@Composable
internal fun CanvasGradientDivider(modifier: Modifier = Modifier, lineWidth: Dp = 2.dp) {
val shape = RoundedCornerShape(size = 100.dp)
val infiniteTransition = rememberInfiniteTransition(label = "GlowDividerShadow")
val shadowAlpha by infiniteTransition.animateFloat(
initialValue = GLOW_MIN_ALPHA,
targetValue = GLOW_MAX_ALPHA,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1600, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse,
),
label = "GlowDividerShadowAlpha",
)
val accent = TangemTheme.colors3.icon.accent
val dotColors = GlowDotColors(
violet = accent.violet,
green = accent.green,
blue = accent.blue,
orange = accent.orange,
)
Box(
modifier = modifier
.width(lineWidth)
.drawBehind {
val resolvedDots = canvasDefaultDots(size.height.toDp(), dotColors)
val cornerPx = size.width / 2f
val capsule = Path().apply {
addRoundRect(
RoundRect(
left = 0f,
top = 0f,
right = size.width,
bottom = size.height,
cornerRadius = CornerRadius(cornerPx, cornerPx),
),
)
}
clipPath(capsule) {
drawRect(color = LineColor)
// Blurred color blobs, drawn with a native BlurMaskFilter paint.
drawIntoCanvas { canvas ->
resolvedDots.forEach { dot ->
val blurPx = dot.blur.toPx()
val paint = Paint().apply {
color = dot.color
if (blurPx > 0f) {
asFrameworkPaint().maskFilter =
BlurMaskFilter(blurPx, BlurMaskFilter.Blur.NORMAL)
}
}
val ovalWidth = DotWidth.toPx()
val ovalHeight = dot.height.toPx()
val centerX = size.width / 2f + dot.offset.x.toPx()
val centerY = dot.offset.y.toPx() + ovalHeight / 2f
canvas.drawOval(
left = centerX - ovalWidth / 2f,
top = centerY - ovalHeight / 2f,
right = centerX + ovalWidth / 2f,
bottom = centerY + ovalHeight / 2f,
paint = paint,
)
}
}
}
}
.dropShadow(
shape = shape,
shadow = Shadow(
radius = 12.dp,
spread = 0.dp,
color = TangemTheme.colors3.bg.brand.copy(alpha = shadowAlpha),
),
)
.border(width = 0.5.dp, color = TangemTheme.colors3.border.secondary, shape = shape),
)
}
/** Glow pulse bounds for the animated drop shadow alpha. */
private const val GLOW_MIN_ALPHA = 0.3f
private const val GLOW_MAX_ALPHA = 0.65f
/** Line fill — Figma "Background color" #0000F9 at 56% opacity (alpha 0x8F). */
private val LineColor = Color(0x8F0000F9)
/** Fixed blob width (the oval's horizontal diameter). */
private val DotWidth = 12.dp
/** Accent colors for the glow blobs, resolved from `colors3.icon.accent.*` in the composable. */
private data class GlowDotColors(
val violet: Color,
val green: Color,
val blue: Color,
val orange: Color,
)
@Suppress("MagicNumber")
private fun canvasDefaultDots(lineHeight: Dp, colors: GlowDotColors): List<CanvasGlowDot> {
val step = lineHeight / 5
return listOf(
CanvasGlowDot(colors.violet, DpOffset(x = 0.5.dp, y = (-3).dp), height = step), // purple
CanvasGlowDot(colors.green, DpOffset(x = 4.dp, y = step * 1.7f), height = step), // green
CanvasGlowDot(colors.blue, DpOffset(x = (-2.5).dp, y = step * 2.2f), height = step), // blue
CanvasGlowDot(colors.orange, DpOffset(x = (-3.5).dp, y = step * 3), height = step + step / 2),
CanvasGlowDot(colors.violet, DpOffset(x = 0.5.dp, y = step * 4 + 4.dp), height = step),
)
}
// region Previews
@Preview(name = "Canvas glow • Light", showBackground = true)
@Preview(name = "Canvas glow • Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PreviewCanvasGradientDivider() {
TangemThemePreviewRedesign {
Box(
modifier = Modifier
.background(TangemTheme.colors3.bg.primary)
.padding(64.dp),
contentAlignment = Alignment.Center,
) {
// Height comes from the parent — here a fixed 46.dp.
CanvasGradientDivider(modifier = Modifier.height(46.dp))
}
}
}
@Preview(name = "Length variants • Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PreviewCanvasLengthVariants() {
TangemThemePreviewRedesign {
Row(
modifier = Modifier
.background(TangemTheme.colors3.bg.primary)
.padding(64.dp),
horizontalArrangement = Arrangement.spacedBy(48.dp),
verticalAlignment = Alignment.CenterVertically,
) {
CanvasGradientDivider(modifier = Modifier.height(20.dp))
CanvasGradientDivider(modifier = Modifier.height(46.dp))
CanvasGradientDivider(modifier = Modifier.height(80.dp))
CanvasGradientDivider(modifier = Modifier.height(140.dp))
CanvasGradientDivider(modifier = Modifier.height(220.dp))
}
}
}
// endregion

View file

@ -0,0 +1,432 @@
package com.tangem.features.foryou.impl.components
import android.content.res.Configuration
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.TextAutoSize
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.tangem.core.ui.components.haze.hazeSourceTangem
import com.tangem.core.ui.ds.button.SecondaryTangemButton
import com.tangem.core.ui.ds.button.TangemButtonSize
import com.tangem.core.ui.ds2.surface.TangemSurface
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.pluralStringResourceSafe
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.percent
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.features.foryou.impl.R
import com.tangem.features.foryou.impl.components.state.AiInsightUM
import com.tangem.features.foryou.impl.components.state.DonutChartUM
import com.tangem.features.foryou.impl.components.state.DonutSegmentUM
import com.tangem.features.foryou.impl.components.state.MarketChartUM
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import java.math.BigDecimal
@Composable
internal fun MarketChart(marketChart: MarketChartUM, modifier: Modifier = Modifier) {
var cardBoundsInWindow by remember { mutableStateOf(Rect.Zero) }
TangemSurface(
modifier = modifier
.hazeSourceTangem()
.onGloballyPositioned { cardBoundsInWindow = it.boundsInWindow() },
color = TangemTheme.colors3.bg.secondary,
) {
Column {
DonutChartBlock(marketChart.donutChart, cardBoundsInWindow)
Spacer(modifier = Modifier.height(16.dp))
if (marketChart is MarketChartUM.Loaded) {
TopHoldingBlock(
assetCount = marketChart.assetCount,
topHoldingPercent = marketChart.topHoldingPercent,
)
} else {
CantLoadDataBlock()
}
Spacer(modifier = Modifier.height(16.dp))
AiInsightContent(marketChart.aiInsight)
}
}
}
@Suppress("LongMethod")
@Composable
private fun ColumnScope.DonutChartBlock(donutChartUM: DonutChartUM, cardBoundsInWindow: Rect) {
var selectedIndex by remember { mutableStateOf<Int?>(null) }
val segments = donutChartUM.donutSegmentList
val scope = rememberCoroutineScope()
var dismissJob by remember { mutableStateOf<Job?>(null) }
var chartSize by remember { mutableStateOf(IntSize.Zero) }
var chartWindowOffset by remember { mutableStateOf(Offset.Zero) }
Box(
modifier = Modifier
.padding(32.dp)
.align(Alignment.CenterHorizontally)
.size(200.dp),
contentAlignment = Alignment.Center,
) {
DonutChart(
modifier = Modifier
.fillMaxSize()
.onGloballyPositioned { coordinates ->
chartSize = coordinates.size
chartWindowOffset = coordinates.localToWindow(Offset.Zero)
}
// A press on the chart means this tap is "on the chart", not "outside" — veto the pending
// outside-dismiss before it commits.
.pointerInput(Unit) {
awaitEachGesture {
awaitFirstDown(requireUnconsumed = false)
dismissJob?.cancel()
}
},
selectedIndex = selectedIndex,
strokeWidth = DonutStrokeWidth,
startAngle = DonutStartAngle,
// Tap a slice → select; tap it again or miss (null) → deselect; tap another slice → switch.
onSegmentClick = { index ->
selectedIndex = index?.takeIf { it != selectedIndex }
},
segments = segments,
) {
if (donutChartUM is DonutChartUM.Loaded) {
Text(
text = donutChartUM.totalAmount,
color = TangemTheme.colors3.text.primary,
style = TangemTheme.typography3.body.medium,
maxLines = 1,
autoSize = TextAutoSize.StepBased(
minFontSize = 8.sp,
maxFontSize = TangemTheme.typography3.body.medium.fontSize,
),
)
Text(
text = stringResourceSafe(R.string.market_chart_bubble_total_value),
color = TangemTheme.colors3.text.secondary,
style = TangemTheme.typography3.caption.medium,
maxLines = 1,
autoSize = TextAutoSize.StepBased(
maxFontSize = TangemTheme.typography3.caption.medium.fontSize,
),
)
} else {
Text(
text = stringResourceSafe(R.string.market_chart_bubble_no_data),
color = TangemTheme.colors3.text.secondary,
style = TangemTheme.typography3.body.medium,
maxLines = 1,
autoSize = TextAutoSize.StepBased(
maxFontSize = TangemTheme.typography3.caption.medium.fontSize,
),
)
}
}
DonutSegmentTooltipBlock(
selectedIndex = selectedIndex,
segments = segments,
chartSize = chartSize,
chartWindowOffset = chartWindowOffset,
cardBoundsInWindow = cardBoundsInWindow,
onDismissRequest = {
dismissJob?.cancel()
dismissJob = scope.launch {
withFrameNanos { }
selectedIndex = null
}
},
)
}
}
@Suppress("LongParameterList")
@Composable
private fun DonutSegmentTooltipBlock(
selectedIndex: Int?,
segments: List<DonutSegmentUM>,
chartSize: IntSize,
chartWindowOffset: Offset,
cardBoundsInWindow: Rect,
onDismissRequest: () -> Unit,
) {
val density = LocalDensity.current
val gapPx = with(density) { 8.dp.roundToPx() }
val strokePx = with(density) { DonutStrokeWidth.toPx() }
var shownIndex by remember { mutableStateOf<Int?>(null) }
if (selectedIndex != null) shownIndex = selectedIndex
val isExpanded = selectedIndex?.let(segments::getOrNull) != null
val shownSegment = shownIndex?.let(segments::getOrNull) ?: return
val positionProvider = remember(
shownIndex,
segments,
chartSize,
chartWindowOffset,
cardBoundsInWindow,
strokePx,
gapPx,
) {
segmentTooltipPositionProvider(
selectedIndex = shownIndex,
segments = segments,
chartSize = chartSize,
chartWindowOffset = chartWindowOffset,
strokePx = strokePx,
startAngle = DonutStartAngle,
cardBoundsInWindow = cardBoundsInWindow,
gapPx = gapPx,
)
}
DonutSegmentTooltip(
expanded = isExpanded,
positionProvider = positionProvider,
title = shownSegment.title,
fiatValue = shownSegment.fiatValue,
percent = shownSegment.weight.format { percent() },
onDismissRequest = onDismissRequest,
)
}
private val DonutStrokeWidth = 28.dp
private val DonutStartAngle = -90f
@Composable
private fun ColumnScope.TopHoldingBlock(assetCount: Int, topHoldingPercent: TextReference) {
Text(
modifier = Modifier.padding(horizontal = 16.dp),
text = pluralStringResourceSafe(R.plurals.market_chart_assets_android, assetCount, assetCount),
color = TangemTheme.colors3.text.secondary,
style = TangemTheme.typography3.heading.small,
)
Text(
modifier = Modifier.padding(horizontal = 16.dp),
text = topHoldingPercent.resolveReference(),
color = TangemTheme.colors3.text.primary,
style = TangemTheme.typography3.heading.small,
)
}
@Composable
private fun ColumnScope.CantLoadDataBlock() {
Text(
modifier = Modifier.padding(horizontal = 16.dp),
text = stringResourceSafe(R.string.market_chart_can_not_load_data),
color = TangemTheme.colors3.text.secondary,
style = TangemTheme.typography3.heading.small,
)
}
// IntrinsicSize.Min lets the gradient divider match the AI text height — heightIn wouldn't achieve that.
@Suppress("ModifierHeightWithText")
@Composable
private fun AiInsightContent(aiInsightUM: AiInsightUM) {
AnimatedContent(
targetState = aiInsightUM,
transitionSpec = { fadeIn().togetherWith(fadeOut()) },
) { currentState ->
when (currentState) {
is AiInsightUM.AskAiInsight -> {
SecondaryTangemButton(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
onClick = currentState.askAiInsightClick,
size = TangemButtonSize.X9,
text = resourceReference(R.string.market_chart_ask_for_ai_summary_button),
)
}
is AiInsightUM.Displayed -> {
Row(
modifier = Modifier
.height(IntrinsicSize.Min)
.padding(top = 8.dp, start = 16.dp, end = 16.dp, bottom = 16.dp),
) {
CanvasGradientDivider(
modifier = Modifier
.fillMaxHeight()
.padding(vertical = 2.dp),
)
Text(
modifier = Modifier
.fillMaxWidth()
.padding(start = 12.dp),
text = buildAnnotatedString {
withStyle(
SpanStyle(
brush = Brush.horizontalGradient(
listOf(
TangemTheme.colors3.icon.accent.violet,
TangemTheme.colors3.icon.accent.blue,
),
),
alpha = 1f,
),
) { append(stringResourceSafe(R.string.market_chart_ai_total)) }
append(" ")
append(currentState.text)
},
color = TangemTheme.colors3.text.secondary,
style = TangemTheme.typography3.caption.medium,
)
}
}
AiInsightUM.Hide -> {}
}
}
}
// region Previews
private enum class MarketChartPreviewScenario { DISPLAYED, ASK_AI, NO_AI, NO_DATA }
private class MarketChartPreviewProvider : PreviewParameterProvider<MarketChartPreviewScenario> {
override val values: Sequence<MarketChartPreviewScenario>
get() = MarketChartPreviewScenario.entries.asSequence()
}
@Preview(name = "MarketChart • Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(name = "MarketChart • Light", showBackground = true)
@Composable
private fun MarketChart_Preview(
@PreviewParameter(MarketChartPreviewProvider::class) scenario: MarketChartPreviewScenario,
) {
TangemThemePreviewRedesign {
Box(
modifier = Modifier
.background(TangemTheme.colors3.bg.primary)
.padding(16.dp),
) {
MarketChart(marketChart = previewMarketChartState(scenario))
}
}
}
/**
* Maps a [scenario] to the state shown. Built inside a `@Composable` (not the [PreviewParameterProvider])
* because the segment colors come from [TangemTheme.colors3], which can only be read in composition.
*/
@Suppress("MagicNumber")
@Composable
private fun previewMarketChartState(scenario: MarketChartPreviewScenario): MarketChartUM = when (scenario) {
MarketChartPreviewScenario.DISPLAYED -> MarketChartUM.Loaded(
topHoldingPercent = stringReference("Top holding 41%"),
aiInsight = AiInsightUM.Displayed(
"Your portfolio leans on a single asset BTC is 42% of holdings. Stablecoins add 23% " +
"buffer. Consider trimming concentration for a smoother ride",
),
donutChart = previewLoadedDonut(),
)
MarketChartPreviewScenario.ASK_AI -> MarketChartUM.Loaded(
topHoldingPercent = stringReference("Top holding 41%"),
aiInsight = AiInsightUM.AskAiInsight(askAiInsightClick = {}),
donutChart = previewLoadedDonut(),
)
MarketChartPreviewScenario.NO_AI -> MarketChartUM.Loaded(
topHoldingPercent = stringReference("Top holding 41%"),
aiInsight = AiInsightUM.Hide,
donutChart = DonutChartUM.Loaded(
totalAmount = "$10,12345678912.1333",
donutSegmentList = listOf(
DonutSegmentUM(
weight = BigDecimal(0.55),
color = TangemTheme.colors3.border.brand,
title = stringReference("Ethereum"),
fiatValue = stringReference("$5,720.22"),
),
DonutSegmentUM(
weight = BigDecimal(0.45),
color = TangemTheme.colors3.border.accent.green,
title = stringReference("Solana"),
fiatValue = stringReference("$728.30"),
),
),
),
)
MarketChartPreviewScenario.NO_DATA -> MarketChartUM.NoData
}
@Suppress("MagicNumber")
@Composable
private fun previewLoadedDonut(): DonutChartUM.Loaded = DonutChartUM.Loaded(
totalAmount = "$10,123456.1333",
donutSegmentList = listOf(
DonutSegmentUM(
weight = BigDecimal(0.55),
color = TangemTheme.colors3.border.brand,
title = stringReference("Ethereum"),
fiatValue = stringReference("$5,720.22"),
),
DonutSegmentUM(
weight = BigDecimal(0.077),
color = TangemTheme.colors3.border.accent.violet,
title = stringReference("Solana"),
fiatValue = stringReference("$728.30"),
),
DonutSegmentUM(
weight = BigDecimal(0.0666),
color = TangemTheme.colors3.border.accent.red,
title = stringReference("Polkadot"),
fiatValue = stringReference("$624.26"),
),
DonutSegmentUM(
weight = BigDecimal(0.05),
color = TangemTheme.colors3.border.accent.green,
title = stringReference("Tether"),
fiatValue = stringReference("$520.18"),
),
),
)
// endregion

View file

@ -0,0 +1,116 @@
package com.tangem.features.foryou.impl.components
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.window.PopupPositionProvider
import com.tangem.features.foryou.impl.components.state.DonutSegmentUM
import kotlin.math.cos
import kotlin.math.min
import kotlin.math.roundToInt
import kotlin.math.sin
/**
* Builds the [DonutSegmentTooltip] position provider anchored to the end of the selected slice. Returns the
* centered fallback while the chart hasn't been measured yet or nothing is selected.
*
* @param startAngle Angle (degrees) where the first slice starts must match the [DonutChart] drawing pass
* (`-90f` = 12 o'clock) so the anchor lands on the real slice end.
*/
@Suppress("MagicNumber", "LongParameterList", "ComplexCondition")
internal fun segmentTooltipPositionProvider(
selectedIndex: Int?,
segments: List<DonutSegmentUM>,
chartSize: IntSize,
chartWindowOffset: Offset,
strokePx: Float,
startAngle: Float,
cardBoundsInWindow: Rect,
gapPx: Int,
): PopupPositionProvider {
if (selectedIndex == null || selectedIndex !in segments.indices ||
chartSize.width == 0 || chartSize.height == 0
) {
// Not shown in this state (selectedIndex is null / chart not measured) — position is irrelevant.
return SegmentTooltipPositionProvider(Offset.Zero, Rect.Zero, gapPx)
}
val diameter = min(chartSize.width, chartSize.height).toFloat()
val centerX = chartSize.width / 2f
val centerY = chartSize.height / 2f
val innerRadius = diameter / 2f - strokePx / 2
// End angle of the selected slice (before its round cap) — same layout as DonutChart's drawing pass.
val sweeps = segments.map { it.weight.toFloat().coerceIn(0f, 1f) * 360f }
val endAngleDeg = startAngle + sweeps.take(selectedIndex + 1).sum()
val endAngleRad = Math.toRadians(endAngleDeg.toDouble())
val anchorLocal = Offset(
x = centerX + innerRadius * cos(endAngleRad).toFloat(),
y = centerY + innerRadius * sin(endAngleRad).toFloat() - strokePx / 2,
)
val anchorInWindow = chartWindowOffset + anchorLocal
return SegmentTooltipPositionProvider(
anchorInWindow = anchorInWindow,
cardBoundsInWindow = cardBoundsInWindow,
gapPx = gapPx,
strokePx = strokePx.toInt(),
)
}
/**
* Positions the pill relative to the **end of the selected slice**, per the agreed spec.
*
* - **Base:** the pill's bottom-center sits [gapPx] above [anchorInWindow] (screen-up). [anchorInWindow] is
* the slice-end point on the ring's inner edge, in window coordinates.
* - **Card fallback:** if that placement would push the pill above the top of [cardBoundsInWindow] (the
* `MarketChart` card), it flips to a side placement the pill's start-center sits [gapPx] to the right
* of the anchor.
* - **Screen clamp:** the result is finally kept inside the window with a [gapPx] margin (shifted back by
* however much it overflowed).
*
* @param anchorInWindow Slice-end / inner-edge point in window px.
* @param cardBoundsInWindow `MarketChart` card bounds in window px (only the top edge gates the fallback).
* @param gapPx The 8dp gap, in px.
*/
internal class SegmentTooltipPositionProvider(
private val anchorInWindow: Offset,
private val cardBoundsInWindow: Rect,
private val gapPx: Int,
private val strokePx: Int = 0,
) : PopupPositionProvider {
override fun calculatePosition(
anchorBounds: IntRect,
windowSize: IntSize,
layoutDirection: LayoutDirection,
popupContentSize: IntSize,
): IntOffset {
val w = popupContentSize.width
val h = popupContentSize.height
val ax = anchorInWindow.x.roundToInt()
val ay = anchorInWindow.y.roundToInt()
var x = ax - w / 2
var y = ay - h - gapPx
val isFlip = y < cardBoundsInWindow.top
if (isFlip) {
x = ax + gapPx + strokePx / 2
y = ay - h / 2 + strokePx / 2
}
// Keep the pill inside the card on every edge, shifting it back by however much it overflows
// (with a gap margin). The card sits within the screen, so this also keeps the pill on-screen.
val minX = cardBoundsInWindow.left.roundToInt() + gapPx
val minY = cardBoundsInWindow.top.roundToInt() + gapPx
val maxX = (cardBoundsInWindow.right.roundToInt() - w - gapPx).coerceAtLeast(minX)
val maxY = (cardBoundsInWindow.bottom.roundToInt() - h - gapPx).coerceAtLeast(minY)
val clampedX = x.coerceIn(minX, maxX)
val clampedY = y.coerceIn(minY, maxY)
return IntOffset(clampedX, clampedY)
}
}

View file

@ -0,0 +1,28 @@
package com.tangem.features.foryou.impl.components.state
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
import com.tangem.core.ui.extensions.TextReference
import java.math.BigDecimal
/**
* One colored slice of a [com.tangem.features.foryou.impl.components.DonutChart].
*
* @param weight Fraction of the full circle this slice occupies, in `0f..1f`. The slices are laid out
* contiguously; whatever is left after `sum(weight)` shows through as the track.
* For a portfolio where slices sum to 1f the ring fills completely and no track is visible.
* Also doubles as the slice's share for the selection tooltip (rendered as `weight * 100%`).
* @param color Solid fill of the slice.
* @param title Human-readable name of the asset this slice represents (e.g. `"Ethereum"`). Shown in the
* selection tooltip. Empty by default for slices that don't need a label.
* @param fiatValue Pre-formatted fiat value of the slice (e.g. `"$5,720.22"`). Shown in the selection
* tooltip next to the share. Empty by default.
*/
@Immutable
internal data class DonutSegmentUM(
val color: Color,
val weight: BigDecimal,
val title: TextReference,
val fiatValue: TextReference,
)

View file

@ -0,0 +1,45 @@
package com.tangem.features.foryou.impl.components.state
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.extensions.TextReference
@Immutable
internal sealed class MarketChartUM(
open val donutChart: DonutChartUM,
open val aiInsight: AiInsightUM,
) {
data class Loaded(
override val donutChart: DonutChartUM.Loaded,
override val aiInsight: AiInsightUM = AiInsightUM.Hide,
val topHoldingPercent: TextReference,
) : MarketChartUM(
donutChart = donutChart,
aiInsight = aiInsight,
) {
val assetCount: Int = donutChart.donutSegmentList.size
}
data object NoData : MarketChartUM(
donutChart = DonutChartUM.NoData,
aiInsight = AiInsightUM.Hide,
)
}
@Immutable
internal sealed class DonutChartUM(
open val donutSegmentList: List<DonutSegmentUM>,
) {
data class Loaded(
val totalAmount: String,
override val donutSegmentList: List<DonutSegmentUM>,
) : DonutChartUM(donutSegmentList = donutSegmentList)
data object NoData : DonutChartUM(donutSegmentList = emptyList())
}
@Immutable
internal sealed class AiInsightUM {
data object Hide : AiInsightUM()
data class AskAiInsight(val askAiInsightClick: () -> Unit) : AiInsightUM()
data class Displayed(val text: String) : AiInsightUM()
}