Updated on 2026-08-14
This commit is contained in:
commit
1f8386c05e
17 changed files with 1531 additions and 9 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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 wallet’s 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">Can’t 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 provider’s %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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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()
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue