diff --git a/core/res/src/main/res/values-de/strings.xml b/core/res/src/main/res/values-de/strings.xml
index 525e778b71..bc30111006 100644
--- a/core/res/src/main/res/values-de/strings.xml
+++ b/core/res/src/main/res/values-de/strings.xml
@@ -106,6 +106,7 @@
Kontakt
Name der Kontaktperson
Adresse kopieren
+ Kontakt hinzugefügt
Es konnte kein Kontakt hergestellt werden. Bitte versuchen Sie es später erneut.
Dieser Kontakt wird aus all Ihren Adressbüchern gelöscht.
Der Kontakt konnte nicht gelöscht werden. Bitte versuchen Sie es später erneut.
@@ -115,10 +116,17 @@
Adresse eingeben
Ungültige Adresse
Weiter bearbeiten
+ Sie können maximal 20 Adressen erstellen. Löschen Sie eine, um eine neue hinzuzufügen.
+ Neue Adresse kann nicht hinzugefügt werden
+ Der Name des Ansprechpartners ist erforderlich
+ Der Name des Ansprechpartners enthält ungültige Zeichen
+ Der Name des Ansprechpartners darf nicht länger als 50 Zeichen sein
+ Dieser Name ist bei dieser Wallet bereits vergeben.
Neuer Kontakt
Noch keine Kontakte
Die von Ihnen hinzugefügten Kontakte werden hier angezeigt
Adresse entfernen
+ In Wallet speichern
Dieser Kontakt wird mit dem Adressbuch dieser Wallet verknüpft.
Netzwerk auswählen
Adressbuch
@@ -348,10 +356,12 @@
Vom
Von %s
Adressen synchronisieren
+ Erhalten
Erste Schritte
Token erhalten
Zum Anbieter gehen
Zum Token
+ Zur Verifizierung gehen
Verstanden
Ausblenden
Halten bis %s
@@ -410,6 +420,7 @@
Datenschutzrichtlinie
%1$s-%2$s
%1$s — %2$s
+ Zinssatz
Weiterlesen
Empfangen
Erhalten
@@ -471,6 +482,7 @@
- %d Token
Transaktion fehlgeschlagen
+ Transaktions-ID
Transaktionsstatus
Transaktionen
Überweisung
@@ -566,7 +578,7 @@
Dynamische Adressen deaktivieren
Dynamische Adressen deaktivieren
Dynamische Adressen deaktiviert
- Einige Adressen fehlen
+ Dynamische Adressen aktiviert
Verwenden Sie für jede Transaktion eine neue Adresse, um die Rückverfolgbarkeit zu verringern und den Datenschutz in der Kette zu verbessern.
Verbesserter Datenschutz
Einfacher Geldempfang in UTXO-basierten Netzwerken mit automatischer Adressgenerierung - keine manuelle Adressverwaltung erforderlich.
@@ -700,6 +712,8 @@
Feedback zu Tangem
Eine Transaktion kann nicht gesendet werden
Fehler in der Coinbeschreibung
+ Portfolio prüfen und Verdienstmöglichkeiten erkunden
+ Für dich
Jetzt aktualisieren
Aktualisiere die Anwendung auf die neueste Version, um die ordnungsgemäße Funktionalität zu gewährleisten
Aktualisierung erforderlich
@@ -731,6 +745,8 @@
Schlüsselgenerierung
Alle kryptografischen Vorgänge finden innerhalb des sicheren Chips statt, der gegen Klonen und physische Manipulation zertifiziert ist.
Sicherheit auf Hardwareebene
+ 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.
+ Die Netzwerkgebühr ist höher als üblich
Vorhandene Wallet hinzufügen
Neues Wallet erstellen
Karte oder Ring bestellen
@@ -1215,6 +1231,7 @@
Der zu kaufende Betrag muss mindestens %s betragen
Kumulierte Transaktionsbeträge über %1s können eine Identitätsüberprüfung mit %2s
Kumulierte Transaktionsbeträge über dem Gegenwert von %1s können eine Identitätsprüfung mit %2s
+ Apple Pay-Transaktionen erfordern möglicherweise eine Identitätsprüfung mit %1s
Indem du auf \"Bezahlen\" klicken, stimmen Sie %1s\'s %2s und %3szu.
Keine verfügbaren Anbieter für diese Währung
Schnellste Bearbeitung
@@ -1401,6 +1418,7 @@
Memo
Überprüfe deine Netzwerkverbindung
Informationen zur Netzwerkgebühr nicht erreichbar
+ aus „ %1$s “ in %2$s
Sie senden
Von %s
Grenzwert Gasgebühr
@@ -1551,6 +1569,7 @@
Zurzeit sind keine Validierer verfügbar. Bitte versuche es später noch einmal.
Staking nicht verfügbar
Staking ist in Ihrer Region nicht verfügbar.
+ Staking ist in Ihrer Region derzeit nicht verfügbar. Falls Sie ein VPN aktiviert haben, deaktivieren Sie es bitte.
Das Netzwerk erhebt eine Token-Genehmigungsgebühr, um zu überprüfen, ob Du die Verwendung Deines Tokens für das Staking genehmigst.
Indem Du die Staking-Funktionalität nutzt, stimmst Du den %1$s und %2$s des Anbieters zu.
Gesperrt
@@ -1846,6 +1865,10 @@
PIN-Code ändern
Kehren Sie zur App zurück, falls Sie ihn vergessen.
Karte
+ Tarif wechseln
+ Kartenbezogen
+ Planbezogen
+ Aktueller Plan
Limit von %s bis %s festlegen
Limits festlegen
Unzureichendes Guthaben
@@ -2059,6 +2082,7 @@
Tippe auf die Doppelkarte oder Ring mit der Nummer %s und entferne sie erst am Ende des Vorgangs.
Aufladen
Aufgeladen
+ Du hast bezahlt
Bitte versuche es später noch einmal. Sollte das Problem weiterhin bestehen, wende Dich bitte an den Support.
Etwas ist schiefgelaufen!
Es ist ein Fehler aufgetreten. Fehlercode: %s. Bitte kontaktiere unseren Support.
diff --git a/core/res/src/main/res/values-es/strings.xml b/core/res/src/main/res/values-es/strings.xml
index 3a5403aaec..303fe0d6c6 100644
--- a/core/res/src/main/res/values-es/strings.xml
+++ b/core/res/src/main/res/values-es/strings.xml
@@ -1208,6 +1208,7 @@
La cantidad a comprar debe ser como mínimo %s
El importe acumulado de la transacción superior a %1s puede requerir la verificación de la identidad con %2s
El importe acumulado de la transacción superior al equivalente de %1s puede requerir la verificación de la identidad con %2s
+ Las transacciones de Apple Pay pueden requerir verificación de identidad con %1s
Al hacer clic en Pagar, usted acepta %1s\'s %2s y %3s.
No hay proveedores disponibles para esta moneda
Procesamiento más rápido
diff --git a/core/res/src/main/res/values-fr/strings.xml b/core/res/src/main/res/values-fr/strings.xml
index c1d9e544ef..98fe3c378b 100644
--- a/core/res/src/main/res/values-fr/strings.xml
+++ b/core/res/src/main/res/values-fr/strings.xml
@@ -1112,6 +1112,7 @@
Le montant à acheter doit être au moins %s
Si le montant cumulé des transactions dépasse %1s, une vérification d\'identité via %2s pourrait être requise
Si le montant cumulé des transactions dépasse l\'équivalent de %1s, une vérification d\'identité via %2s pourrait être requise
+ Les transactions Apple Pay peuvent nécessiter une vérification d\'identité avec %1s
En appuyant sur Acheter, vous acceptez %1s %2s et %3s.
Aucun fournisseur disponible pour cette devise
Le plus rapide
diff --git a/core/res/src/main/res/values-ja/strings.xml b/core/res/src/main/res/values-ja/strings.xml
index 1a9a334e93..95f8447cb8 100644
--- a/core/res/src/main/res/values-ja/strings.xml
+++ b/core/res/src/main/res/values-ja/strings.xml
@@ -1191,6 +1191,7 @@
買付金額は少なくとも%sである必要があります
累計取引額が%1sを超えると、%2sでの本人確認が必要になる場合があります。
累計取引額が%1s相当額を超えると、%2sでの本人確認が必要になる場合があります。
+ Apple Payの取引では、%1sによる本人確認が必要になる場合があります
「支払う」をタップすると、%1sの%2sおよび%3sに同意したものとみなされます。
この通貨で利用可能なプロバイダーはありません
最短で処理
diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml
index 74baf3a874..e8dd470622 100644
--- a/core/res/src/main/res/values-ru/strings.xml
+++ b/core/res/src/main/res/values-ru/strings.xml
@@ -1251,6 +1251,7 @@
Сумма покупки должна составлять минимум %s
Общая сумма транзакций свыше %1s может потребовать верификации личности через %2s
Общая сумма транзакций, превышающая эквивалент %1s, может потребовать верификации личности через %2s
+ Для транзакций через Apple Pay может потребоваться верификация личности в %1s
Нажимая «Оплатить», вы соглашаетесь с %1s\'s %2s и %3s.
Нет доступных провайдеров для выбранной валюты
Самый быстрый
diff --git a/core/res/src/main/res/values-uk-rUA/strings.xml b/core/res/src/main/res/values-uk-rUA/strings.xml
index 7924e3da11..bf61ef1442 100644
--- a/core/res/src/main/res/values-uk-rUA/strings.xml
+++ b/core/res/src/main/res/values-uk-rUA/strings.xml
@@ -1251,6 +1251,7 @@
Сума покупки повинна бути не менше %s
Загальна сума транзакцій понад %1s може вимагати верифікації особи через %2s
Загальна сума транзакцій, що перевищує еквівалент %1s, може вимагати верифікації особи через %2s
+ Для транзакцій через Apple Pay може знадобитися верифікація особи в %1s
Натискаючи «Оплатити», ви погоджуєтеся з %1s\'s %2s і %3s.
Для данної валюти немає доступних провайдерів
Найшвидший
diff --git a/core/res/src/main/res/values-zh-rCN/strings.xml b/core/res/src/main/res/values-zh-rCN/strings.xml
index e1ea9ae35f..a6c24d30c2 100644
--- a/core/res/src/main/res/values-zh-rCN/strings.xml
+++ b/core/res/src/main/res/values-zh-rCN/strings.xml
@@ -1185,6 +1185,7 @@
购买金额必须至少 %s
累计交易金额超过 %1s 时,可能需要通过 %2s进行身份验证
累计交易金额超过等值金额 %1s 可能需要通过 %2s进行身份验证
+ Apple Pay 交易可能需要通过以下方式进行身份验证: %1s
点击“支付”即表示您同意 %1s的 %2s 和 %3s。
目前没有提供此货币的供应商
最快处理
diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml
index 0c454a990d..31787d3bb7 100644
--- a/core/res/src/main/res/values/strings.xml
+++ b/core/res/src/main/res/values/strings.xml
@@ -98,6 +98,8 @@
Add address
Add address and select network
Add contact
+ Address copied
+ This address is already saved as %1$s
- %d address
- %d addresses
@@ -106,6 +108,7 @@
Contact
Contact name
Copy address
+ Contact added
Couldn\'t create contact. Please try again later.
This contact will be deleted from all your address books
Couldn\'t delete contact. Please try again later.
@@ -116,7 +119,7 @@
Invalid address
Keep editing
You can not create more than 20 addresses. Delete one to add new.
- Can\'t add new address
+ Can\'t add new address
Contact name is required
Contact name contains invalid characters
Contact name must not exceed 50 characters
@@ -125,8 +128,10 @@
No contacts yet
Contacts added will appear here
Remove address
+ Save contact
Save to Wallet
This contact will be linked to this wallet’s address book.
+ No results found.\nTry another name
Select network
Address book
Unsaved changes
@@ -355,6 +360,7 @@
From
From %s
Synchronize addresses
+ Get
Get started
Get token
Go to provider
@@ -480,6 +486,7 @@
- %d tokens
Transaction failed
+ Transaction ID
Transaction status
Transactions
Transfer
@@ -880,6 +887,16 @@
The selected token is currently unavailable for actions within the crypto wallet. But worry not, you can express your interest by upvoting it.
Upvote
The wallet doesn\'t support more than one network
+ AI Total:
+ Ask for AI summary
+
+ - %d asset
+ - %d assets
+
+ No data
+ Total value
+ Can’t load data
+ Top holding %s%%
About coin
To buy, exchange, or receive this asset, add it to your portfolio
This asset is currently not supported in the wallet
@@ -1229,6 +1246,7 @@
The amount to buy must be at least %s
Cumulative transaction amount over %1s may require identity verification with %2s
Cumulative transaction amount over equivalent of %1s may require identity verification with %2s
+ Apple Pay transactions may require identity verification with %1s
By clicking Pay, you agree to %1s\'s %2s and %3s.
No available providers for this currency
Quickest processing
@@ -1566,6 +1584,7 @@
No available validators at the moment. Please try again later.
Staking Unavailable
Staking is unavailable in your region
+ Staking is currently unavailable in your region. If you have VPN enabled – try disabling it.
The network will charge a token approval fee to verify that you are authorizing the use of your token for the staking.
By using staking functionality, you agree with provider’s %1$s and %2$s
Locked
@@ -1861,6 +1880,10 @@
Change PIN-code
Come back to the app if you forget it.
Card
+ Change plan
+ Card related
+ Plan related
+ Current plan
Set a limit from %s to %s
Set limits
insufficient funds
@@ -1951,10 +1974,6 @@
Pay Support
Payment account
Session expired
- Current plan
- Card related
- Plan related
- Change plan
Invalid PIN: avoid sequences or repeats
Replace card
This generates a new set of card details. Your old details will stop working. You can\'t undo this.
diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds2/surface/TangemSurface.kt b/core/ui/src/main/java/com/tangem/core/ui/ds2/surface/TangemSurface.kt
index 2b4f731dac..87249b47d9 100644
--- a/core/ui/src/main/java/com/tangem/core/ui/ds2/surface/TangemSurface.kt
+++ b/core/ui/src/main/java/com/tangem/core/ui/ds2/surface/TangemSurface.kt
@@ -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,
diff --git a/features/for-you/impl/build.gradle.kts b/features/for-you/impl/build.gradle.kts
index 93ffbfa390..d2009892cf 100644
--- a/features/for-you/impl/build.gradle.kts
+++ b/features/for-you/impl/build.gradle.kts
@@ -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)
diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutChart.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutChart.kt
new file mode 100644
index 0000000000..962d1c96cf
--- /dev/null
+++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutChart.kt
@@ -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,
+ 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(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,
+ 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(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
\ No newline at end of file
diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutSegmentTooltip.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutSegmentTooltip.kt
new file mode 100644
index 0000000000..5f4ec15898
--- /dev/null
+++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutSegmentTooltip.kt
@@ -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,
+ 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
\ No newline at end of file
diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/GradientDivider.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/GradientDivider.kt
new file mode 100644
index 0000000000..b7a2c85135
--- /dev/null
+++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/GradientDivider.kt
@@ -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 {
+ 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
\ No newline at end of file
diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/MarketChart.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/MarketChart.kt
new file mode 100644
index 0000000000..582c492702
--- /dev/null
+++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/MarketChart.kt
@@ -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(null) }
+ val segments = donutChartUM.donutSegmentList
+ val scope = rememberCoroutineScope()
+ var dismissJob by remember { mutableStateOf(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,
+ 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(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 {
+ override val values: Sequence
+ 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
\ No newline at end of file
diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/SegmentTooltipPositioning.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/SegmentTooltipPositioning.kt
new file mode 100644
index 0000000000..bf03d8e6cf
--- /dev/null
+++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/SegmentTooltipPositioning.kt
@@ -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,
+ 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)
+ }
+}
\ No newline at end of file
diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/state/DonutSegmentUM.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/state/DonutSegmentUM.kt
new file mode 100644
index 0000000000..df7e33719c
--- /dev/null
+++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/state/DonutSegmentUM.kt
@@ -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,
+)
\ No newline at end of file
diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/state/MarketChartUM.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/state/MarketChartUM.kt
new file mode 100644
index 0000000000..ce825b8336
--- /dev/null
+++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/state/MarketChartUM.kt
@@ -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,
+) {
+ data class Loaded(
+ val totalAmount: String,
+ override val donutSegmentList: List,
+ ) : 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()
+}
\ No newline at end of file