diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt index 97b1355571..74571d545b 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt @@ -52,6 +52,7 @@ import com.flipcash.app.myaccount.MyAccountScreen import com.flipcash.app.scanner.ScannerScreen import com.flipcash.app.shareapp.ShareAppScreen import com.flipcash.app.tokens.SwapFlowScreen +import com.flipcash.app.tokens.MarketCapExplainerScreen import com.flipcash.app.tokens.TokenInfoScreen import com.flipcash.app.tokens.TokenSelectScreen @@ -138,6 +139,9 @@ fun appEntryProvider( annotatedEntry(testTag = "transaction_history_screen") { key -> TransactionHistoryScreen(key.mint) } + annotatedEntry(testTag = "market_cap_explainer_screen") { key -> + MarketCapExplainerScreen(key.mint) + } annotatedEntry { key -> SwapFlowScreen(route = key, resultStateRegistry = resultStateRegistry) } diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt index 8edae926ca..7150d54524 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt @@ -231,6 +231,8 @@ sealed interface AppRoute : NavKey, Parcelable { @Serializable data class Transactions(val mint: Mint) : Token @Serializable + data class MarketCapExplainer(val mint: Mint) : Token + @Serializable data class Swap( val purpose: SwapPurpose, val shortfall: Fiat? = null, diff --git a/apps/flipcash/core/src/main/res/drawable/ic_market_cap_curve.xml b/apps/flipcash/core/src/main/res/drawable/ic_market_cap_curve.xml new file mode 100644 index 0000000000..6e42426103 --- /dev/null +++ b/apps/flipcash/core/src/main/res/drawable/ic_market_cap_curve.xml @@ -0,0 +1,14 @@ + + + + diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index a48d947a9e..102267a2ad 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1336,4 +1336,19 @@ Wallet You + + How Market Cap Works + See what moves market cap · 1 min + The more people buy %1$s, the more valuable %1$s becomes. + Amount of %1$s purchased by the community + Your %1$s balance would be worth + %1$s Today + Today + Your Ownership + Underlying tokens you own + Current price per token + Share of circulating supply + Share of max supply + Your current appreciation + Price curve for %1$s with %2$s purchased diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/MarketCapExplainerScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/MarketCapExplainerScreen.kt new file mode 100644 index 0000000000..d875edb1a7 --- /dev/null +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/MarketCapExplainerScreen.kt @@ -0,0 +1,37 @@ +package com.flipcash.app.tokens + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.app.tokens.internal.explainer.MarketCapExplainerContent +import com.flipcash.app.tokens.ui.MarketCapExplainerViewModel +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.solana.keys.Mint +import com.getcode.ui.components.AppBarWithTitle + +@Composable +fun MarketCapExplainerScreen(mint: Mint) { + val navigator = LocalCodeNavigator.current + val viewModel = hiltViewModel() + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AppBarWithTitle( + onBackIconClicked = { navigator.pop() }, + ) + MarketCapExplainerContent(state = state) + } + + LaunchedEffect(viewModel, mint) { + viewModel.dispatchEvent(MarketCapExplainerViewModel.Event.OnMintProvided(mint)) + } +} diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CurrencyInfoContentV2.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CurrencyInfoContentV2.kt index 89d6c5e7e1..82d59752d3 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CurrencyInfoContentV2.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CurrencyInfoContentV2.kt @@ -158,6 +158,11 @@ internal fun CurrencyInfoContentV2( val isUsdf = state.isCashReserve val isHeld = state.showTransactionHistory || state.balance.nativeAmount.isPositive + val openExplainer = { + dispatch( + TokenInfoViewModel.Event.OpenScreen(AppRoute.Token.MarketCapExplainer(token.address)) + ) + } // 1. Hero bill card item { @@ -275,6 +280,16 @@ internal fun CurrencyInfoContentV2( onPeriodSelected = { dispatch(TokenInfoViewModel.Event.OnMarketCapPeriodSelected(it)) }, + onHeaderClick = openExplainer, + ) + } + item { + MarketCapExplainerRow( + modifier = Modifier + .fillParentMaxWidth() + .padding(horizontal = inset) + .padding(top = grid.x3), + onClick = openExplainer, ) } } diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/MarketCapExplainerRow.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/MarketCapExplainerRow.kt new file mode 100644 index 0000000000..f6746a58f1 --- /dev/null +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/MarketCapExplainerRow.kt @@ -0,0 +1,82 @@ +package com.flipcash.app.tokens.internal.components.info + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.flipcash.core.R +import com.getcode.theme.CodeTheme +import com.getcode.theme.extraSmall + +/** Entry point to the explainer: "Row / How price works" (design node 10761:1879). */ +@Composable +internal fun MarketCapExplainerRow( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val shape = CodeTheme.shapes.extraSmall + Row( + modifier = modifier + .fillMaxWidth() + .clip(shape) + .background(CodeTheme.colors.brandLight, shape) + .clickable(onClick = onClick) + .padding(14.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier.size(40.dp).background(CodeTheme.colors.surfaceVariant, CircleShape), + contentAlignment = Alignment.Center, + ) { + Image( + modifier = Modifier.size(22.dp), + painter = painterResource(R.drawable.ic_market_cap_curve), + colorFilter = ColorFilter.tint(CodeTheme.colors.success), + contentDescription = null, + ) + } + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = stringResource(R.string.title_howMarketCapWorks), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + ) + Text( + text = stringResource(R.string.subtitle_howMarketCapWorksRow), + style = CodeTheme.typography.textSmall.copy(fontWeight = FontWeight.Medium), + color = CodeTheme.colors.textSecondary, + ) + } + // The shared chevron is 9x16; 12dp tall keeps its proportions. + Icon( + modifier = Modifier.height(12.dp).width(6.75.dp), + painter = painterResource(R.drawable.ic_chevron_right), + contentDescription = null, + tint = CodeTheme.colors.textSecondary, + ) + } +} diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/MarketCapSection.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/MarketCapSection.kt index a332e0eccb..b6f9452020 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/MarketCapSection.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/MarketCapSection.kt @@ -11,6 +11,8 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues @@ -77,7 +79,8 @@ internal fun MarketCapSection( contentPadding: PaddingValues = PaddingValues(), animateChartOpen: Boolean = true, onRetry: () -> Unit, - onPeriodSelected: (Period) -> Unit + onPeriodSelected: (Period) -> Unit, + onHeaderClick: (() -> Unit)? = null, ) { var highlightedCapPoint by remember { mutableStateOf(null) @@ -130,12 +133,27 @@ internal fun MarketCapSection( Column( modifier = modifier, ) { - Text( + // Only the label, value and change pill open the explainer; the chart and range chips keep + // their own gestures. + Column( + modifier = Modifier.then( + if (onHeaderClick != null) { + Modifier.clickable(onClick = onHeaderClick) + } else { + Modifier + } + ), + ) { + Row( modifier = Modifier.padding(start = contentPadding.calculateStartPadding()), - text = stringResource(R.string.subtitle_marketcap), - style = CodeTheme.typography.textMedium, - color = CodeTheme.colors.textSecondary, - ) + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.subtitle_marketcap), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textSecondary, + ) + } AnimatedNumberText( modifier = Modifier.padding(start = contentPadding.calculateStartPadding()), @@ -186,6 +204,8 @@ internal fun MarketCapSection( } } + } + MarketCapChart( modifier = Modifier .fillMaxWidth() diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/explainer/ExplainerLabelLayout.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/explainer/ExplainerLabelLayout.kt new file mode 100644 index 0000000000..d0e90d6ca3 --- /dev/null +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/explainer/ExplainerLabelLayout.kt @@ -0,0 +1,28 @@ +package com.flipcash.app.tokens.internal.explainer + +import kotlin.math.max + +/** A tick label measured in pixels: its [centre] on the track and its text [width]. */ +internal data class ExplainerLabelBox(val centre: Float, val width: Float, val isToday: Boolean) + +/** + * Decides which slider labels to draw and where. Today is always drawn; every other label is + * clamped inside `[0, totalWidth]` and dropped when its bounds come within [minGap] of Today's or of + * a label already drawn. Returns the left edge of each drawn label, or null for a dropped one. + */ +internal fun layoutExplainerLabels(labels: List, totalWidth: Float, minGap: Float): List { + val lefts = labels.map { (it.centre - it.width / 2f).coerceIn(0f, max(0f, totalWidth - it.width)) } + val drawn = mutableListOf() + val result = MutableList(labels.size) { null } + val order = labels.indices.sortedBy { if (labels[it].isToday) 0 else 1 } + for (i in order) { + val clashes = drawn.any { j -> + lefts[i] < lefts[j] + labels[j].width + minGap && lefts[j] < lefts[i] + labels[i].width + minGap + } + if (labels[i].isToday || !clashes) { + drawn += i + result[i] = lefts[i] + } + } + return result +} diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/explainer/MarketCapExplainerChart.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/explainer/MarketCapExplainerChart.kt new file mode 100644 index 0000000000..f5dbc3e1ef --- /dev/null +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/explainer/MarketCapExplainerChart.kt @@ -0,0 +1,145 @@ +package com.flipcash.app.tokens.internal.explainer + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import kotlin.math.abs +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.flipcash.app.tokens.bondingcurve.ExplainerChart +import com.getcode.theme.CodeTheme + +/** + * The bonding curve (price against tokens purchased), both axes linear. Solid with a fade-out fill up + * to the selected supply, dashed beyond it; a dashed line marks Today's price and a marker with a + * vertical scrub line sits at the selection. + */ +@Composable +internal fun MarketCapExplainerChart( + chart: ExplainerChart, + scrubLabel: String, + description: String, + modifier: Modifier = Modifier, +) { + val accent = CodeTheme.colors.success + val faded = accent.copy(alpha = 0.35f) + val guide = CodeTheme.colors.textSecondary.copy(alpha = 0.5f) + val todayDot = CodeTheme.colors.textSecondary + val density = LocalDensity.current + val labelAreaHeight = 24.dp + val padPx = with(density) { 12.dp.toPx() } + val topPx = with(density) { labelAreaHeight.toPx() } + + BoxWithConstraints( + modifier = modifier + .fillMaxWidth() + .height(220.dp) + .semantics { contentDescription = description }, + ) { + val widthPx = with(density) { maxWidth.toPx() } + val heightPx = with(density) { maxHeight.toPx() } + val plotW = widthPx - padPx * 2 + val plotH = heightPx - topPx - padPx + fun x(tokens: Double) = padPx + chart.xFraction(tokens) * plotW + fun y(price: Double): Float { + val span = (chart.yMax - chart.yMin).coerceAtLeast(1e-9) + val t = ((price - chart.yMin) / span).coerceIn(0.0, 1.0).toFloat() + return topPx + plotH * (1f - t) + } + + Canvas(Modifier.fillMaxWidth().height(maxHeight)) { + val solid = Path() + val dashed = Path() + var started = false + var dashedStarted = false + chart.points.forEach { point -> + val px = x(point.tokens) + val py = y(point.price) + if (point.tokens <= chart.selectedTokens) { + if (!started) solid.moveTo(px, py).also { started = true } else solid.lineTo(px, py) + } + if (point.tokens >= chart.selectedTokens) { + if (!dashedStarted) dashed.moveTo(px, py).also { dashedStarted = true } else dashed.lineTo(px, py) + } + } + + val selX = x(chart.selectedTokens) + val selY = y(chart.selectedPrice) + val baseline = topPx + plotH + + if (started) { + val fill = Path().apply { + addPath(solid) + lineTo(selX, baseline) + lineTo(x(chart.points.first().tokens), baseline) + close() + } + drawPath( + fill, + Brush.verticalGradient( + colors = listOf(accent.copy(alpha = 0.25f), accent.copy(alpha = 0f)), + startY = selY, + endY = baseline, + ), + ) + drawPath(solid, accent, style = Stroke(width = 2.5.dp.toPx())) + } + if (dashedStarted) { + drawPath( + dashed, + faded, + style = Stroke(width = 2.dp.toPx(), pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f))), + ) + } + + // Today's price, as a dashed horizontal reference. + val todayY = y(chart.todayPrice) + drawLine( + guide, + Offset(padPx, todayY), + Offset(padPx + plotW, todayY), + strokeWidth = 1.dp.toPx(), + pathEffect = PathEffect.dashPathEffect(floatArrayOf(8f, 8f)), + ) + + // Scrub line, then Today's small grey dot (only once the selection has left Today), then the + // solid selection dot - no halo, matching iOS. + drawLine(guide, Offset(selX, topPx - 4.dp.toPx()), Offset(selX, baseline), strokeWidth = 1.dp.toPx()) + if (abs(selX - x(chart.todayTokens)) > 1.dp.toPx()) { + drawCircle(todayDot, radius = 3.dp.toPx(), center = Offset(x(chart.todayTokens), todayY)) + } + drawCircle(accent, radius = 4.5.dp.toPx(), center = Offset(selX, selY)) + } + + Box(Modifier.fillMaxWidth().height(labelAreaHeight)) { + Text( + modifier = Modifier.layout { measurable, constraints -> + val placeable = measurable.measure(constraints.copy(minWidth = 0, minHeight = 0)) + layout(constraints.maxWidth, placeable.height) { + val px = x(chart.selectedTokens) - placeable.width / 2f + placeable.place( + px.coerceIn(0f, (constraints.maxWidth - placeable.width).toFloat()).toInt(), + 0, + ) + } + }, + text = scrubLabel, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textMain, + ) + } + } +} diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/explainer/MarketCapExplainerContent.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/explainer/MarketCapExplainerContent.kt new file mode 100644 index 0000000000..e4f079baaf --- /dev/null +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/explainer/MarketCapExplainerContent.kt @@ -0,0 +1,299 @@ +package com.flipcash.app.tokens.internal.explainer + +import androidx.annotation.StringRes +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.foundation.background +import androidx.compose.runtime.rememberCoroutineScope +import com.getcode.ui.core.verticalScrollStateGradient +import kotlinx.coroutines.launch +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewWrapper +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.flipcash.app.core.money.formattedAppreciation +import com.flipcash.app.core.util.abbreviated +import com.flipcash.app.theme.FlipcashThemeWrapper +import com.flipcash.app.tokens.bondingcurve.ExplainerChart +import com.flipcash.app.tokens.bondingcurve.ExplainerChartPoint +import com.getcode.opencode.model.financial.Fiat +import com.flipcash.app.tokens.bondingcurve.ExplainerTick +import com.flipcash.app.tokens.ui.MarketCapExplainerViewModel +import com.flipcash.core.R +import com.getcode.theme.CodeTheme +import com.getcode.theme.bolded +import com.getcode.theme.White05 +import com.getcode.ui.components.text.AnimatedNumberText +import java.math.BigDecimal + +/** Everything the screen draws, already formatted; the composables below do no math. */ +internal data class ExplainerUiModel( + val tokenName: String, + val position: Float, + val ticks: List, + val tickLabels: Map, + val reserveText: String, + val worthText: String, + val chart: ExplainerChart, + val scrubLabel: String, + val ownership: List, +) + +internal enum class Tone { Neutral, Positive, Negative } + +internal fun appreciationTone(appreciation: Fiat?): Tone = when { + appreciation == null || !appreciation.valueNonZero() -> Tone.Neutral + appreciation.toDouble() > 0 -> Tone.Positive + appreciation.toDouble() < 0 -> Tone.Negative + else -> Tone.Neutral +} + +internal data class OwnershipRow(@StringRes val label: Int, val value: String, val tone: Tone = Tone.Neutral) + +private const val Dash = "–" + +@Composable +internal fun MarketCapExplainerContent(state: MarketCapExplainerViewModel.State) { + val projection = state.projection + val token = state.token + if (projection == null || token == null) return + + // The thumb follows the finger while it is down (`dragged`), then springs back to Today on + // release; the readout and chart read the same position, so they roll back with it. + val today = projection.todayPosition.toFloat() + val scope = rememberCoroutineScope() + var dragged by remember { mutableStateOf(null) } + val settle = remember(today) { Animatable(today) } + val position = (dragged ?: settle.value).coerceIn(0f, 1f) + + // Keyed on position: the lambda captures the plain Float, so without it the snapshot (and the + // chart built from it) would freeze at its first value. + val snapshot by remember(projection, position) { + derivedStateOf { projection.snapshotAt(position.toDouble()) } + } + val chart by remember(projection, snapshot) { derivedStateOf { projection.chart(snapshot) } } + + // The labels that follow the finger are derived here; the fixed ones come formatted from the + // view model. The curve stays in USD and only what is drawn is converted. + val labels = state.labels ?: return + val reserveText = Fiat(snapshot.reserve.toDouble()).convertingTo(state.rate).abbreviated() + val scrubLabel = if (snapshot.isToday) stringResource(R.string.label_marketCapScrubToday, reserveText) else reserveText + val appreciation = state.appreciation + + val model = ExplainerUiModel( + tokenName = token.name, + position = position, + ticks = projection.ticks, + tickLabels = labels.ticks, + reserveText = reserveText, + worthText = snapshot.worth?.convertingTo(state.rate)?.formatted() ?: Dash, + chart = chart, + scrubLabel = scrubLabel, + ownership = listOf( + OwnershipRow(R.string.label_underlyingTokensYouOwn, labels.tokensHeld ?: Dash), + OwnershipRow(R.string.label_currentPricePerToken, labels.price), + OwnershipRow(R.string.label_shareOfCirculatingSupply, labels.shareOfCirculating ?: Dash), + OwnershipRow(R.string.label_shareOfMaxSupply, labels.shareOfMax ?: Dash), + OwnershipRow( + R.string.label_yourCurrentAppreciation, + appreciation?.formattedAppreciation() ?: Dash, + appreciationTone(appreciation), + ), + ), + ) + MarketCapExplainerBody( + model = model, + onPositionChange = { dragged = it }, + onDragEnd = { + val from = dragged ?: return@MarketCapExplainerBody + scope.launch { + settle.snapTo(from) + dragged = null + settle.animateTo(today, spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMediumLow)) + } + }, + ) +} + +@Composable +internal fun MarketCapExplainerBody( + model: ExplainerUiModel, + onPositionChange: (Float) -> Unit, + onDragEnd: () -> Unit = {}, +) { + val scrollState = rememberScrollState() + val inset = CodeTheme.dimens.inset + val grid = CodeTheme.dimens.grid + Column( + modifier = Modifier + .fillMaxSize() + .verticalScrollStateGradient(scrollState) + .verticalScroll(scrollState) + .padding(horizontal = inset) + .padding(top = grid.x3, bottom = grid.x8), + verticalArrangement = Arrangement.spacedBy(grid.x4), + ) { + Text( + modifier = Modifier.semantics { heading() }, + text = stringResource(R.string.title_marketCapExplainerHeadline, model.tokenName), + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + ) + + Card { + Text( + text = stringResource(R.string.label_marketCapAmountPurchased, model.tokenName), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + AnimatedNumberText( + modifier = Modifier.padding(top = grid.x1), + value = model.reserveText, + style = CodeTheme.typography.displayCompact, + color = CodeTheme.colors.textMain, + ) + MarketCapExplainerChart( + modifier = Modifier.padding(top = grid.x3), + chart = model.chart, + scrubLabel = model.scrubLabel, + description = stringResource( + R.string.label_marketCapChartAccessibility, model.tokenName, model.reserveText, + ), + ) + MarketCapExplainerSlider( + modifier = Modifier.padding(top = grid.x2), + position = model.position, + ticks = model.ticks, + tickLabel = { model.tickLabels.getValue(it) }, + onPositionChange = onPositionChange, + onDragEnd = onDragEnd, + ) + Text( + modifier = Modifier.padding(top = grid.x5), + text = stringResource(R.string.label_marketCapBalanceWouldBeWorth, model.tokenName), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + AnimatedNumberText( + modifier = Modifier.padding(top = grid.x1), + value = model.worthText, + style = CodeTheme.typography.displayMedium.bolded(), + color = CodeTheme.colors.textMain, + ) + } + + Card { + Text( + text = stringResource(R.string.title_yourOwnership), + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + ) + model.ownership.forEach { (label, value, tone) -> + Row( + modifier = Modifier.fillMaxWidth().padding(top = grid.x3), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.weight(1f), + text = stringResource(label), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textSecondary, + ) + Text( + text = value, + style = CodeTheme.typography.textMedium, + color = when (tone) { + Tone.Positive -> CodeTheme.colors.successText + Tone.Negative -> CodeTheme.colors.errorText + Tone.Neutral -> CodeTheme.colors.textMain + }, + textAlign = TextAlign.End, + ) + } + } + } + } +} + +@Composable +private fun Card(content: @Composable () -> Unit) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(White05, CodeTheme.shapes.medium) + .padding(CodeTheme.dimens.grid.x4), + ) { content() } +} + +// Hand-built from the contract's vectors (supply 1.25M, 12,400 held); the real tables are not +// available to previews. +private fun previewModel(todaySelected: Boolean): ExplainerUiModel { + val points = (0..80).map { + val t = it * 25_000.0 + ExplainerChartPoint(t, 0.01 + 8.78 * Math.pow(t / 7_300_000.0, 2.2)) + } + val ticks = listOf( + ExplainerTick(BigDecimal(5_000), 0.0, false, true), + ExplainerTick(BigDecimal(22_700), 0.26, true, true), + ExplainerTick(BigDecimal(100_000), 0.54, false, true), + ExplainerTick(BigDecimal(1_000_000), 0.81, false, true), + ExplainerTick(BigDecimal(10_000_000), 1.0, false, true), + ) + val tickLabels = ticks.associateWith { if (it.isToday) "Today" else "$" + it.reserve.toPlainString() } + return if (todaySelected) ExplainerUiModel( + tokenName = "Coin", position = 0.26f, ticks = ticks, tickLabels = tickLabels, reserveText = "$22.7K", worthText = "$369.18", + chart = ExplainerChart(points, 2_000_000.0, 0.01, points.last().price, 1_250_000.0, 0.02994, 1_250_000.0, 0.02994), + scrubLabel = "$22.7K Today", + ownership = previewOwnership(), + ) else ExplainerUiModel( + tokenName = "Coin", position = 0.54f, ticks = ticks, tickLabels = tickLabels, reserveText = "$100K", worthText = "$1,205.13", + chart = ExplainerChart(points, 3_500_000.0, 0.01, points.last().price, 2_750_000.0, 0.0977, 1_250_000.0, 0.02994), + scrubLabel = "$100K", + ownership = previewOwnership(), + ) +} + +private fun previewOwnership() = listOf( + OwnershipRow(R.string.label_underlyingTokensYouOwn, "12,400"), + OwnershipRow(R.string.label_currentPricePerToken, "$0.02994"), + OwnershipRow(R.string.label_shareOfCirculatingSupply, "0.99%"), + OwnershipRow(R.string.label_shareOfMaxSupply, "0.06%"), + OwnershipRow(R.string.label_yourCurrentAppreciation, "+$12.40", Tone.Positive), +) + +@Preview(name = "Today", heightDp = 900) +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_MarketCapExplainer_Today() { + MarketCapExplainerBody(previewModel(todaySelected = true), onPositionChange = {}) +} + +@Preview(name = "100K", heightDp = 900) +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_MarketCapExplainer_100K() { + MarketCapExplainerBody(previewModel(todaySelected = false), onPositionChange = {}) +} diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/explainer/MarketCapExplainerSlider.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/explainer/MarketCapExplainerSlider.kt new file mode 100644 index 0000000000..6385fcdc26 --- /dev/null +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/explainer/MarketCapExplainerSlider.kt @@ -0,0 +1,223 @@ +package com.flipcash.app.tokens.internal.explainer + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.interaction.collectIsDraggedAsState +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.foundation.layout.offset +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.unit.IntOffset +import kotlin.math.roundToInt +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.flipcash.app.tokens.bondingcurve.ExplainerTick +import com.flipcash.app.theme.FlipcashPreview +import com.flipcash.core.R +import com.getcode.theme.CodeTheme +import com.getcode.util.vibration.LocalVibrator +import androidx.compose.ui.tooling.preview.Preview + +/** + * A continuous slider whose track is log(reserve). The [ticks] are labelled marks, not snap points: + * the thumb moves freely, and a light haptic fires each time it crosses one. + * + * @param position 0..1 along the track. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun MarketCapExplainerSlider( + position: Float, + ticks: List, + tickLabel: (ExplainerTick) -> String, + onPositionChange: (Float) -> Unit, + modifier: Modifier = Modifier, + onDragEnd: () -> Unit = {}, +) { + val vibrator = LocalVibrator.current + val currentOnDragEnd by rememberUpdatedState(onDragEnd) + val currentPosition by rememberUpdatedState(position) + val currentTicks by rememberUpdatedState(ticks) + val change by rememberUpdatedState { next: Float -> + val previous = currentPosition + val crossed = currentTicks.any { tick -> + val p = tick.position.toFloat() + (previous < p && next >= p) || (previous > p && next <= p) + } + if (crossed) vibrator.tick() + onPositionChange(next.coerceIn(0f, 1f)) + } + + val tickColor = CodeTheme.colors.textSecondary.copy(alpha = 0.3f) + val todayTickColor = CodeTheme.colors.textMain + val density = LocalDensity.current + val interactionSource = remember { MutableInteractionSource() } + val colors = SliderDefaults.colors( + thumbColor = CodeTheme.colors.success, + activeTrackColor = CodeTheme.colors.success, + inactiveTrackColor = CodeTheme.colors.surfaceVariant, + ) + // The thumb is a 26dp disc and its centre travels from half a thumb in to half a thumb short of + // the far end; ticks and labels use the same geometry so they line up with it. + val thumbWidth = 38.dp + val thumbHeight = 24.dp + val sideInsetPx = with(density) { (thumbWidth / 2).toPx() } + + Column(modifier = modifier.fillMaxWidth()) { + // Slider supplies the drag, tap and progress semantics; the thumb is the stock M3 handle. + Slider( + value = position.coerceIn(0f, 1f), + onValueChange = { change(it) }, + onValueChangeFinished = { currentOnDragEnd() }, + interactionSource = interactionSource, + colors = colors, + thumb = { + // iOS: an opaque white capsule at rest; held, it grows ~1.5x and turns translucent glass + // (the track shows through). Android has no Liquid Glass, so the glass is a dark + // frosted fill with a light rim, sprung between the two states. + val pressed by interactionSource.collectIsPressedAsState() + val dragged by interactionSource.collectIsDraggedAsState() + val held = pressed || dragged + val glass by animateFloatAsState( + if (held) 1f else 0f, + spring(dampingRatio = 0.7f, stiffness = 500f), + label = "thumbGlass", + ) + Box( + Modifier + .size(thumbWidth, thumbHeight) + .graphicsLayer { + val s = 1f + 0.5f * glass + scaleX = s + scaleY = s + } + .shadow((4f * (1f - glass)).dp, CircleShape) + .background( + lerp(Color.White, CodeTheme.colors.toggleUncheckedTrackColor.copy(alpha = 0.45f), glass), + CircleShape, + ) + .border(1.dp, Color.White.copy(alpha = 0.28f * glass), CircleShape), + ) + }, + track = { state -> + Box(Modifier.fillMaxWidth().height(44.dp), contentAlignment = Alignment.Center) { + SliderDefaults.Track( + sliderState = state, + modifier = Modifier.height(10.dp), + colors = colors, + drawStopIndicator = null, + thumbTrackGapSize = 0.dp, + trackInsideCornerSize = 0.dp, + ) + Canvas(Modifier.fillMaxSize()) { + val travel = size.width - sideInsetPx * 2 + val thumbCentre = sideInsetPx + travel * state.value.coerceIn(0f, 1f) + ticks.forEach { tick -> + val tx = sideInsetPx + travel * tick.position.toFloat() + // The disc covers the track here; skip ticks that would peek out below it. + if (kotlin.math.abs(tx - thumbCentre) < sideInsetPx + 2.dp.toPx()) return@forEach + val (h, color) = if (tick.isToday) 10.dp.toPx() to todayTickColor + else 6.dp.toPx() to tickColor + val top = size.height / 2 + 9.dp.toPx() + drawRoundRect( + color, + Offset(tx - 1.dp.toPx(), top), + Size(2.dp.toPx(), h), + CornerRadius(1.dp.toPx()), + ) + } + } + } + }, + ) + + // Labels sit under the ticks, centred on them, clamped inside the track's bounds. Today is + // always drawn; a fixed label is dropped when its measured bounds come within 6dp of + // Today's or of one already drawn (labels are localized currency, so widths vary). + BoxWithConstraints(Modifier.fillMaxWidth()) { + val textMeasurer = rememberTextMeasurer() + val todayText = stringResource(R.string.label_marketCapTickToday) + val regular = CodeTheme.typography.textSmall.copy(fontSize = 12.sp, fontWeight = FontWeight.Medium) + val bold = regular.copy(fontWeight = FontWeight.Bold) + val shown = ticks.filter { it.isToday || it.labelVisible } + val texts = shown.map { if (it.isToday) todayText else tickLabel(it) } + val totalWidthPx = with(density) { maxWidth.toPx() } + val travelPx = totalWidthPx - sideInsetPx * 2 + val gapPx = with(density) { 6.dp.toPx() } + val boxes = shown.mapIndexed { i, tick -> + val width = textMeasurer.measure(texts[i], if (tick.isToday) bold else regular).size.width + ExplainerLabelBox( + centre = sideInsetPx + travelPx * tick.position.toFloat(), + width = width.toFloat(), + isToday = tick.isToday, + ) + } + val lefts = layoutExplainerLabels(boxes, totalWidthPx, gapPx) + shown.forEachIndexed { i, tick -> + val left = lefts[i] ?: return@forEachIndexed + Text( + modifier = Modifier.offset { IntOffset(left.roundToInt(), 0) }, + text = texts[i], + maxLines = 1, + softWrap = false, + style = if (tick.isToday) bold else regular, + color = if (tick.isToday) CodeTheme.colors.textMain else CodeTheme.colors.textSecondary, + ) + } + } + } +} + + +@Preview +@Composable +private fun Preview_Slider() { + FlipcashPreview(showBackground = true) { + MarketCapExplainerSlider( + position = 0.4f, + ticks = listOf( + ExplainerTick(java.math.BigDecimal(5_000), 0.0, false, true), + ExplainerTick(java.math.BigDecimal(22_700), 0.4, true, true), + ExplainerTick(java.math.BigDecimal(100_000), 0.58, false, true), + ExplainerTick(java.math.BigDecimal(1_000_000), 0.8, false, true), + ExplainerTick(java.math.BigDecimal(10_000_000), 1.0, false, true), + ), + tickLabel = { "$" + it.reserve.toPlainString() }, + onPositionChange = {}, + modifier = Modifier.padding(16.dp), + ) + } +} diff --git a/apps/flipcash/features/tokens/src/test/kotlin/com/flipcash/app/tokens/internal/explainer/ExplainerLabelLayoutTest.kt b/apps/flipcash/features/tokens/src/test/kotlin/com/flipcash/app/tokens/internal/explainer/ExplainerLabelLayoutTest.kt new file mode 100644 index 0000000000..9777a2f992 --- /dev/null +++ b/apps/flipcash/features/tokens/src/test/kotlin/com/flipcash/app/tokens/internal/explainer/ExplainerLabelLayoutTest.kt @@ -0,0 +1,25 @@ +package com.flipcash.app.tokens.internal.explainer + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class ExplainerLabelLayoutTest { + + @Test + fun `label layout always draws today and drops labels that come within the gap`() { + val labels = listOf( + ExplainerLabelBox(centre = 20f, width = 40f, isToday = false), + ExplainerLabelBox(centre = 100f, width = 40f, isToday = true), + ExplainerLabelBox(centre = 130f, width = 40f, isToday = false), + ExplainerLabelBox(centre = 300f, width = 40f, isToday = false), + ExplainerLabelBox(centre = 395f, width = 40f, isToday = false), + ) + val lefts = layoutExplainerLabels(labels, totalWidth = 400f, minGap = 12f) + assertEquals(0f, lefts[0]) // clamped inside, 0..40 vs today 80..120: gap 40 >= 12 + assertEquals(80f, lefts[1]) + assertNull(lefts[2]) // overlaps Today + assertEquals(280f, lefts[3]) + assertEquals(360f, lefts[4]) // clamped to the right edge + } +} diff --git a/apps/flipcash/features/tokens/src/test/kotlin/com/flipcash/app/tokens/internal/explainer/ExplainerToneTest.kt b/apps/flipcash/features/tokens/src/test/kotlin/com/flipcash/app/tokens/internal/explainer/ExplainerToneTest.kt new file mode 100644 index 0000000000..cf0aa675b4 --- /dev/null +++ b/apps/flipcash/features/tokens/src/test/kotlin/com/flipcash/app/tokens/internal/explainer/ExplainerToneTest.kt @@ -0,0 +1,16 @@ +package com.flipcash.app.tokens.internal.explainer + +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.Fiat +import kotlin.test.Test +import kotlin.test.assertEquals + +class ExplainerToneTest { + @Test + fun `appreciation tone follows the sign`() { + assertEquals(Tone.Positive, appreciationTone(Fiat(12.5, CurrencyCode.USD))) + assertEquals(Tone.Negative, appreciationTone(Fiat(-3.0, CurrencyCode.USD))) + assertEquals(Tone.Neutral, appreciationTone(Fiat(0.0, CurrencyCode.USD))) + assertEquals(Tone.Neutral, appreciationTone(null)) + } +} diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenCoordinator.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenCoordinator.kt index d2372382b2..943f2c72ea 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenCoordinator.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenCoordinator.kt @@ -367,6 +367,10 @@ class TokenCoordinator @Inject constructor( fun balanceForToken(tokenAddress: Mint): Flow = _state.map { it.balances[tokenAddress] ?: Fiat.Zero } + /** On-chain quarks held in [tokenAddress]; null while only a USD value is known (restored from Room). */ + fun heldQuarksForToken(tokenAddress: Mint): Flow = + _state.map { it.holdings[tokenAddress]?.tokenQuarks } + fun appreciationForToken(tokenAddress: Mint): Flow = _state.map { it.appreciation[tokenAddress] ?: Fiat.Zero } .map { appreciation -> diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/bondingcurve/BondingCurveProjection.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/bondingcurve/BondingCurveProjection.kt new file mode 100644 index 0000000000..efca27d050 --- /dev/null +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/bondingcurve/BondingCurveProjection.kt @@ -0,0 +1,252 @@ +package com.flipcash.app.tokens.bondingcurve + +import com.flipcash.libs.currency.math.Estimator +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.Token +import java.math.BigDecimal +import java.math.MathContext +import java.math.RoundingMode +import kotlin.math.exp +import kotlin.math.ln +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min + +/** + * One labelled mark on the explainer slider's track. + * + * @property reserve dollars in the curve at this mark. + * @property position 0..1 along the track, on a log(reserve) axis. + * @property labelVisible false for a fixed tick that sits within [BondingCurveProjection.HIDE_LABEL_WITHIN] + * of Today, so two labels do not collide. + */ +data class ExplainerTick( + val reserve: BigDecimal, + val position: Double, + val isToday: Boolean, + val labelVisible: Boolean, +) + +/** The curve evaluated at one slider position. */ +data class ExplainerSnapshot( + val reserve: BigDecimal, + val isToday: Boolean, + /** Supply at [reserve], in whole tokens. */ + val supply: BigDecimal, + /** [supply] in the token's quarks, the unit `Fiat.tokenBalance`'s `supplyOverride` takes. */ + val supplyQuarks: Long, + /** Spot price per token at [supply], in dollars. */ + val price: BigDecimal, + /** Fee-free sell value of the held tokens at [supply]; null when the held quarks are unknown. */ + val worth: Fiat?, +) + +data class ExplainerChartPoint(val tokens: Double, val price: Double) + +/** + * The chart for one [ExplainerSnapshot]. x runs over [0, [xMax]] tokens and y over [[yMin], [yMax]] + * dollars, both linear. The curve is solid up to [selectedTokens] and dashed beyond it. + */ +data class ExplainerChart( + val points: List, + val xMax: Double, + val yMin: Double, + val yMax: Double, + val selectedTokens: Double, + val selectedPrice: Double, + val todayTokens: Double, + val todayPrice: Double, +) { + /** [tokens] as a 0..1 fraction of the x-domain, clamped so nothing is ever positioned outside it. */ + fun xFraction(tokens: Double): Float = + if (xMax <= 0.0) 0f else (tokens / xMax).coerceIn(0.0, 1.0).toFloat() +} + +/** What the user owns today, independent of the slider. A null field is unknown. */ +data class ExplainerOwnership( + val tokensHeld: BigDecimal?, + val price: BigDecimal, + val shareOfCirculating: BigDecimal?, + val shareOfMax: BigDecimal?, +) + +/** + * The math behind the "How Market Cap Works" screen, free of Android and Compose types. + * + * The slider's axis is the reserve: the dollars in the curve, `tokensToValue(0, supply)`. The supply + * at a reserve is the curve's own inverse (`Estimator.supplyAtReserve`, which delegates to the shared + * KMP engine), so nothing here approximates the curve in closed form. + * + * @param todaySupplyQuarks the token's current circulating supply, in quarks. + * @param heldQuarks quarks the user holds; null when only a USD value is known (a balance restored + * from the local database), which leaves worth and the share rows unknown. + */ +class BondingCurveProjection( + private val token: Token, + private val todaySupplyQuarks: Long, + private val heldQuarks: Long?, +) { + private val quarksPerToken: BigDecimal = BigDecimal.TEN.pow(token.decimals) + + val todayReserve: BigDecimal = Estimator + .reserveAtSupply(todaySupplyQuarks.toBigDecimal().divide(quarksPerToken)) + .getOrThrow() + + /** The reserve at max supply: no stop is added beyond it, since the curve has no point there. */ + private val curveMaxReserve: BigDecimal? = + Estimator.reserveAtSupply(BigDecimal(MAX_SUPPLY_TOKENS)).getOrNull() + + /** The fixed stops for this token's Today: [FIXED_RESERVES], extended by [reserveStops]. */ + val stops: List = reserveStops(todayReserve, curveMaxReserve) + + private val minReserve: BigDecimal = stops.first().min(todayReserve) + private val maxReserve: BigDecimal = stops.last().max(todayReserve) + private val logMin = ln(minReserve.toDouble()) + private val logSpan = ln(maxReserve.toDouble()) - logMin + + /** Fixed ticks plus Today, ordered along the track. */ + val ticks: List = run { + val today = ExplainerTick(todayReserve, positionOf(todayReserve), isToday = true, labelVisible = true) + val fixed = stops.map { reserve -> + val nearToday = abs(ln(reserve.toDouble()) - ln(todayReserve.toDouble())) < + ln(1.0 + HIDE_LABEL_WITHIN) + ExplainerTick(reserve, positionOf(reserve), isToday = false, labelVisible = !nearToday) + } + (fixed + today).sortedBy { it.position } + } + + val todayPosition: Double get() = positionOf(todayReserve) + + /** 0..1 along the log(reserve) track. */ + fun positionOf(reserve: BigDecimal): Double { + if (logSpan <= 0.0) return 0.0 + return ((ln(reserve.toDouble()) - logMin) / logSpan).coerceIn(0.0, 1.0) + } + + fun reserveAt(position: Double): BigDecimal { + val clamped = position.coerceIn(0.0, 1.0) + return BigDecimal(exp(logMin + clamped * logSpan)) + } + + /** The slider's value for [position]; within [TODAY_MAGNET] of Today it is Today exactly. */ + fun snapshotAt(position: Double): ExplainerSnapshot = + if (abs(position - todayPosition) < TODAY_MAGNET) today() else snapshotAtReserve(reserveAt(position)) + + fun today(): ExplainerSnapshot = snapshot( + reserve = todayReserve, + supply = todaySupplyQuarks.toBigDecimal().divide(quarksPerToken), + supplyQuarks = todaySupplyQuarks, + isToday = true, + ) + + fun snapshotAtReserve(reserve: BigDecimal): ExplainerSnapshot { + val supply = Estimator.supplyAtReserve(reserve).getOrThrow() + val quarks = supply.multiply(quarksPerToken).setScale(0, RoundingMode.DOWN).longValueExact() + return snapshot(reserve, supply, quarks, isToday = false) + } + + private fun snapshot( + reserve: BigDecimal, + supply: BigDecimal, + supplyQuarks: Long, + isToday: Boolean, + ): ExplainerSnapshot { + val price = Estimator.currentPriceFor(supplyQuarks).getOrThrow() + // Only supply that exists can be sold, so a holding larger than the supply at a low reserve + // is valued as the whole supply. + val worth = heldQuarks?.let { Fiat.tokenBalance(min(it, supplyQuarks), token, supplyOverride = supplyQuarks) } + return ExplainerSnapshot(reserve, isToday, supply, supplyQuarks, price, worth) + } + + // Spot prices on a fixed grid. The table's step is 100 tokens, so the grid is a lookup, not a curve fit. + private val gridPrices: DoubleArray by lazy { + DoubleArray((MAX_SUPPLY_TOKENS / CHART_STEP_TOKENS) + 1) { priceAt((it * CHART_STEP_TOKENS).toDouble()) } + } + + private fun priceAt(tokens: Double): Double { + val quarks = BigDecimal(tokens).multiply(quarksPerToken).setScale(0, RoundingMode.DOWN).longValueExact() + return Estimator.currentPriceFor(quarks).getOrThrow().toDouble() + } + + fun chart(snapshot: ExplainerSnapshot): ExplainerChart { + val selected = snapshot.supply.toDouble() + val today = todaySupplyQuarks.toBigDecimal().divide(quarksPerToken).toDouble() + val xMax = domainMax(selected, today) + val grid = (0..(xMax / CHART_STEP_TOKENS).toInt()).map { + ExplainerChartPoint((it * CHART_STEP_TOKENS).toDouble(), gridPrices[it]) + } + val extras = listOf(selected, xMax).map { ExplainerChartPoint(it, priceAt(it)) } + val points = (grid + extras).sortedBy { it.tokens }.distinctBy { it.tokens } + return ExplainerChart( + points = points, + xMax = xMax, + yMin = CHART_MIN_PRICE, + yMax = points.maxOf { it.price }, + selectedTokens = selected, + selectedPrice = snapshot.price.toDouble(), + todayTokens = today, + todayPrice = today().price.toDouble(), + ) + } + + fun ownership(): ExplainerOwnership { + val held = heldQuarks?.toBigDecimal()?.divide(quarksPerToken) + val circulating = todaySupplyQuarks.toBigDecimal().divide(quarksPerToken) + return ExplainerOwnership( + tokensHeld = held, + price = Estimator.currentPriceFor(todaySupplyQuarks).getOrThrow(), + shareOfCirculating = held?.takeIf { circulating.signum() > 0 } + ?.multiply(HUNDRED)?.divide(circulating, MC), + shareOfMax = held?.multiply(HUNDRED)?.divide(MAX_SUPPLY_TOKENS.toBigDecimal(), MC), + ) + } + + companion object { + /** + * [FIXED_RESERVES] while the top stop is within twice Today's reserve, ten times the top stop + * is appended, so Today always sits strictly left of the last stop. A stop above [curveMax] + * (the reserve at max supply) is never added. + */ + fun reserveStops(todayReserve: BigDecimal, curveMax: BigDecimal?): List { + val stops = FIXED_RESERVES.toMutableList() + val twiceToday = todayReserve.multiply(BigDecimal(2)) + while (stops.last() <= twiceToday) { + val next = stops.last().multiply(BigDecimal.TEN) + if (curveMax == null || next > curveMax) break + stops += next + } + return stops + } + + /** + * The chart's x extent: both the selection and Today's dot must fit, so it is driven by the + * larger of the two plus a tail. Sizing it from the selection alone put Today's dot far past + * the right edge whenever the slider sat below Today. + */ + fun domainMax(selectedTokens: Double, todayTokens: Double): Double { + val rightmost = max(selectedTokens, todayTokens) + return (rightmost + CHART_TAIL_TOKENS) + .coerceAtMost(MAX_SUPPLY_TOKENS.toDouble() - 1.0) + .coerceAtLeast(rightmost) + .coerceAtLeast(1.0) + } + + /** $5K, $100K, $1M, $10M. */ + val FIXED_RESERVES: List = + listOf(5_000, 100_000, 1_000_000, 10_000_000).map { BigDecimal(it) } + + /** A fixed tick this close to Today (as a fraction) loses its label. */ + const val HIDE_LABEL_WITHIN = 0.10 + + /** Slider positions this close to Today (fraction of the track) read as Today. */ + const val TODAY_MAGNET = 0.012 + + const val MAX_SUPPLY_TOKENS = 21_000_000 + const val CHART_STEP_TOKENS = 25_000 + const val CHART_TAIL_TOKENS = 750_000.0 + const val CHART_MIN_PRICE = 0.01 + + private val HUNDRED = BigDecimal(100) + private val MC = MathContext(20) + } +} diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/MarketCapExplainerViewModel.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/MarketCapExplainerViewModel.kt new file mode 100644 index 0000000000..2b2ca4ceb1 --- /dev/null +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/MarketCapExplainerViewModel.kt @@ -0,0 +1,165 @@ +package com.flipcash.app.tokens.ui + +import androidx.lifecycle.viewModelScope +import com.flipcash.app.core.util.abbreviated +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.app.tokens.bondingcurve.BondingCurveProjection +import com.flipcash.app.tokens.bondingcurve.ExplainerTick +import com.flipcash.libs.coroutines.DispatcherProvider +import com.getcode.opencode.exchange.Exchange +import com.getcode.opencode.internal.extensions.fractionDigits +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.Rate +import com.getcode.opencode.model.financial.Token +import com.getcode.solana.keys.Mint +import com.getcode.view.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import java.math.BigDecimal +import java.math.MathContext +import java.math.RoundingMode +import java.text.NumberFormat +import javax.inject.Inject + +@HiltViewModel +class MarketCapExplainerViewModel @Inject constructor( + private val tokenCoordinator: TokenCoordinator, + private val exchange: Exchange, + private val dispatchers: DispatcherProvider, +) : BaseViewModel( + initialState = State(), + updateStateForEvent = updateStateForEvent, + defaultDispatcher = dispatchers.Default, +) { + data class State( + val token: Token? = null, + /** Null until the token is cached and its supply is known. */ + val projection: BondingCurveProjection? = null, + /** The pill's appreciation in the user's currency; null for reserves or before it loads. */ + val appreciation: Fiat? = null, + /** The user's preferred in-app currency rate, the one `TokenInfoViewModel` converts with. */ + val rate: Rate = Rate.oneToOne, + /** What the screen shows that does not move with the slider; null until [projection] loads. */ + val labels: Labels? = null, + ) + + /** Display strings in the preferred currency. A null field is unknown and reads as a dash. */ + data class Labels( + val ticks: Map, + val tokensHeld: String?, + val price: String, + val shareOfCirculating: String?, + val shareOfMax: String?, + ) + + sealed interface Event { + data class OnMintProvided(val mint: Mint) : Event + data class OnLoaded(val token: Token, val projection: BondingCurveProjection) : Event + data class OnAppreciationUpdated(val appreciation: Fiat?) : Event + data class OnRateUpdated(val rate: Rate) : Event + } + + init { + eventFlow + .filterIsInstance() + .map { it.mint } + .distinctUntilChanged() + .flatMapLatest { mint -> + combine( + tokenCoordinator.observeTokenCache().map { it[mint] }.onStart { tokenCoordinator.getTokenMetadata(mint) }, + tokenCoordinator.heldQuarksForToken(mint), + tokenCoordinator.balanceForToken(mint), + ) { token, heldQuarks, balance -> + // No quarks is "unknown" for a balance restored from Room, but a zero balance + // means the user holds none, which is a known 0. + val quarks = heldQuarks ?: if (balance.quarks == 0L) 0L else null + val supply = token?.launchpadMetadata?.currentCirculatingSupplyQuarks + if (token == null || supply == null) return@combine null + Event.OnLoaded( + token = token, + projection = BondingCurveProjection(token, supply, quarks), + ) + } + } + .flowOn(dispatchers.Default) + .onEach { it?.let(::dispatchEvent) } + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .flatMapLatest { event -> + combine( + tokenCoordinator.appreciationForToken(event.mint), + exchange.observePreferredRate(), + ) { appreciation, rate -> + // USD reserves track "no appreciation" as MIN_VALUE. + if (appreciation == Fiat.MIN_VALUE) null else appreciation.convertingTo(rate) + } + } + .onEach { dispatchEvent(Event.OnAppreciationUpdated(it)) } + .launchIn(viewModelScope) + + exchange.observePreferredRate() + .onEach { dispatchEvent(Event.OnRateUpdated(it)) } + .launchIn(viewModelScope) + } + + internal companion object { + val updateStateForEvent: (Event) -> (State.() -> State) = { event -> + when (event) { + is Event.OnMintProvided -> { state -> state } + is Event.OnLoaded -> { state -> + state.copy( + token = event.token, + projection = event.projection, + labels = labels(event.projection, state.rate), + ) + } + is Event.OnAppreciationUpdated -> { state -> state.copy(appreciation = event.appreciation) } + is Event.OnRateUpdated -> { state -> + state.copy(rate = event.rate, labels = state.projection?.let { labels(it, event.rate) }) + } + } + } + + private val MIN_SHOWN_PERCENT = BigDecimal("0.01") + + internal fun labels(projection: BondingCurveProjection, rate: Rate): Labels { + val ownership = projection.ownership() + return Labels( + ticks = projection.ticks.associateWith { Fiat(it.reserve.toDouble()).convertingTo(rate).abbreviated() }, + tokensHeld = ownership.tokensHeld?.let { + NumberFormat.getIntegerInstance().format(it.setScale(0, RoundingMode.DOWN)) + }, + price = price(ownership.price, rate), + shareOfCirculating = ownership.shareOfCirculating?.let(::percent), + shareOfMax = ownership.shareOfMax?.let(::percent), + ) + } + + /** A USD per-token price in [rate]'s currency, to four significant figures: `$0.02994`, `$8.78`. */ + internal fun price(usd: BigDecimal, rate: Rate): String { + val converted = BigDecimal(usd.toDouble() * rate.fx) + var rounded = converted.round(MathContext(4, RoundingMode.HALF_UP)).stripTrailingZeros() + val minDigits = rate.currency.fractionDigits + if (rounded.scale() < minDigits) rounded = rounded.setScale(minDigits) + return Fiat(fiat = rounded.toDouble(), currencyCode = rate.currency) + .formatted(rule = Fiat.FormattingRule.Length(rounded.scale())) + } + + /** A share as a percentage to two decimals, or `<0.01%` for a positive share below that. */ + internal fun percent(value: BigDecimal): String { + if (value.signum() == 0) return "0%" + if (value.signum() > 0 && value < MIN_SHOWN_PERCENT) return "<0.01%" + return value.setScale(2, RoundingMode.HALF_UP).toPlainString() + "%" + } + } +} diff --git a/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/bondingcurve/BondingCurveProjectionTest.kt b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/bondingcurve/BondingCurveProjectionTest.kt new file mode 100644 index 0000000000..841c0cc68a --- /dev/null +++ b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/bondingcurve/BondingCurveProjectionTest.kt @@ -0,0 +1,277 @@ +package com.flipcash.app.tokens.bondingcurve + +import com.flipcash.libs.currency.math.CurveTestInitializer +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.HolderMetrics +import com.getcode.opencode.model.financial.LaunchpadMetadata +import com.getcode.opencode.model.financial.MintMetadata +import com.getcode.opencode.model.financial.Rate +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.Token +import com.getcode.opencode.model.financial.VmMetadata +import com.getcode.solana.keys.Mint +import com.getcode.solana.keys.PublicKey +import org.junit.BeforeClass +import org.junit.Test +import java.math.BigDecimal +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The acceptance vectors from the shared Android/iOS contract: supply 1.25M tokens, 12,400 held, + * checked against the real curve tables. + */ +class BondingCurveProjectionTest { + + companion object { + @JvmStatic + @BeforeClass + fun initCurve() { + CurveTestInitializer.initialize() + } + + private const val WHOLE_TOKEN = 10_000_000_000L + private const val SUPPLY_QUARKS = 1_250_000L * WHOLE_TOKEN + private const val HELD_QUARKS = 12_400L * WHOLE_TOKEN + } + + private val dummyKey = PublicKey.fromBase58("11111111111111111111111111111111") + + private fun token(supplyQuarks: Long = SUPPLY_QUARKS): Token = MintMetadata( + address = Mint(List(32) { 1.toByte() }), + decimals = 10, + name = "TestCoin", + symbol = "TEST", + createdAt = null, + description = "", + imageUrl = "", + vmMetadata = VmMetadata(vm = dummyKey, authority = dummyKey, lockDurationInDays = 21), + launchpadMetadata = LaunchpadMetadata( + currencyConfig = dummyKey, + liquidityPool = dummyKey, + seed = dummyKey, + authority = dummyKey, + mintVault = dummyKey, + coreMintVault = dummyKey, + currentCirculatingSupplyQuarks = supplyQuarks, + sellFeeBps = 100, + price = Fiat(fiat = 0.01), + marketCap = Fiat(fiat = 10000.0), + ), + billCustomizations = null, + socialLinks = emptyList(), + holderMetrics = HolderMetrics.None, + ) + + private fun projection(held: Long? = HELD_QUARKS, supply: Long = SUPPLY_QUARKS) = + BondingCurveProjection(token(supply), supply, held) + + private fun assertWithin(expected: Double, actual: Double, tolerance: Double, label: String) { + println("VECTOR $label = $actual (expected $expected +/- $tolerance)") + assertTrue(kotlin.math.abs(expected - actual) <= tolerance, "$label: expected $expected, got $actual") + } + + @Test + fun `today vector`() { + val calc = projection() + val today = calc.today() + assertTrue(today.isToday) + assertWithin(22_700.0, calc.todayReserve.toDouble(), 50.0, "today reserve") + assertWithin(0.02994, today.price.toDouble(), 0.000005, "today price") + assertWithin(369.18, today.worth!!.decimalValue, 0.005, "today worth") + } + + @Test + fun `fixed tick vectors`() { + val calc = projection() + // reserve, price, worth of 12,400 tokens, price/worth display tolerance + val vectors = listOf( + Triple(5_000, 0.0144, 177.0), + Triple(100_000, 0.0977, 1_205.0), + Triple(1_000_000, 0.887, 10_941.0), + Triple(10_000_000, 8.78, 108_304.0), + ) + vectors.forEach { (reserve, price, worth) -> + val snap = calc.snapshotAtReserve(BigDecimal(reserve)) + assertFalse(snap.isToday) + assertWithin(price, snap.price.toDouble(), price * 0.005, "price @ $reserve") + assertWithin(worth, snap.worth!!.decimalValue, 1.0, "worth @ $reserve") + } + } + + @Test + fun `unknown holdings leave worth and shares unknown`() { + val calc = projection(held = null) + assertNull(calc.today().worth) + val ownership = calc.ownership() + assertNull(ownership.tokensHeld) + assertNull(ownership.shareOfCirculating) + assertNull(ownership.shareOfMax) + } + + @Test + fun `ownership shares`() { + val ownership = projection().ownership() + assertEquals(0, BigDecimal(12_400).compareTo(ownership.tokensHeld)) + assertWithin(0.992, ownership.shareOfCirculating!!.toDouble(), 0.0001, "share of circulating (%)") + assertWithin(0.0590476, ownership.shareOfMax!!.toDouble(), 0.00001, "share of max (%)") + } + + @Test + fun `track spans five thousand to ten million and today sits on it`() { + val calc = projection() + assertEquals(0.0, calc.positionOf(BigDecimal(5_000)), 1e-9) + assertEquals(1.0, calc.positionOf(BigDecimal(10_000_000)), 1e-9) + assertTrue(calc.todayPosition in 0.0..1.0) + assertEquals(5, calc.ticks.size) + assertEquals(calc.ticks.sortedBy { it.position }, calc.ticks) + } + + @Test + fun `today beyond the base stops extends the track past it`() { + // ~$100M reserve: past the $10M tick + val calc = projection(supply = 12_000_000L * WHOLE_TOKEN) + assertTrue(calc.todayReserve > BigDecimal(10_000_000)) + assertTrue(calc.todayPosition < 1.0) + assertTrue(calc.stops.last() > calc.todayReserve) + assertEquals(0.0, calc.positionOf(BigDecimal(5_000)), 1e-9) + } + + @Test + fun `a fixed tick within ten percent of today loses its label`() { + // Today at exactly the $1M reserve's supply + val supplyAt1M = projection().snapshotAtReserve(BigDecimal(1_000_000)).supplyQuarks + val calc = projection(supply = supplyAt1M) + val millionTick = calc.ticks.single { !it.isToday && it.reserve.compareTo(BigDecimal(1_000_000)) == 0 } + assertFalse(millionTick.labelVisible) + // the other fixed ticks keep theirs + assertTrue(calc.ticks.filter { !it.isToday && it !== millionTick }.all { it.labelVisible }) + assertTrue(calc.ticks.single { it.isToday }.labelVisible) + } + + @Test + fun `snapping near today returns today exactly`() { + val calc = projection() + val snap = calc.snapshotAt(calc.todayPosition + 0.005) + assertTrue(snap.isToday) + assertEquals(SUPPLY_QUARKS, snap.supplyQuarks) + assertFalse(calc.snapshotAt(calc.todayPosition + 0.2).isToday) + } + + @Test + fun `chart spans selected supply plus 750K tokens`() { + val calc = projection() + val snap = calc.snapshotAtReserve(BigDecimal(100_000)) + val chart = calc.chart(snap) + assertEquals( + maxOf(snap.supply.toDouble(), calc.today().supply.toDouble()) + 750_000.0, chart.xMax, 1e-6, + ) + assertEquals(0.01, chart.yMin) + assertEquals(chart.points.last().price, chart.yMax, 1e-12) + assertTrue(chart.points.any { it.tokens == chart.selectedTokens }) + assertEquals(calc.today().price.toDouble(), chart.todayPrice, 1e-12) + assertTrue(chart.points.zipWithNext().all { (a, b) -> b.tokens > a.tokens && b.price >= a.price }) + } + + @Test + fun `holding more than the supply at a low reserve is valued as the whole supply`() { + val calc = projection(held = 2_000_000L * WHOLE_TOKEN) + val snap = calc.snapshotAtReserve(BigDecimal(5_000)) + assertWithin(5_000.0, snap.worth!!.decimalValue, 1.0, "whole-supply worth at the reserve") + } + + @Test + fun `the fixed slider stops are usd reserves`() { + val usd = BondingCurveProjection.FIXED_RESERVES + assertEquals(listOf(5_000, 100_000, 1_000_000, 10_000_000).map { BigDecimal(it) }, usd) + } + + @Test + fun `marker, scrub line and today dot stay inside the chart at every slider position`() { + for (supply in listOf(30_000L, 1_250_000L, 12_000_000L, 20_900_000L)) { + val calc = projection(supply = supply * WHOLE_TOKEN) + for (i in 0..200) { + val position = i / 200.0 + val chart = calc.chart(calc.snapshotAt(position)) + for (tokens in listOf(chart.selectedTokens, chart.todayTokens)) { + assertTrue(tokens <= chart.xMax + 1e-6, "supply=$supply pos=$position: $tokens beyond ${chart.xMax}") + assertTrue(chart.xFraction(tokens) in 0f..1f) + } + } + for (reserve in listOf(5_000, 100_000, 1_000_000, 10_000_000)) { + val chart = calc.chart(calc.snapshotAtReserve(BigDecimal(reserve))) + assertTrue(chart.selectedTokens <= chart.xMax, "supply=$supply reserve=$reserve") + assertTrue(chart.todayTokens <= chart.xMax, "supply=$supply reserve=$reserve") + } + } + } + + @Test + fun `x fraction clamps out of range values`() { + val chart = projection().chart(projection().today()) + assertEquals(1f, chart.xFraction(chart.xMax * 3)) + assertEquals(0f, chart.xFraction(-5.0)) + } + + @Test + fun `marker x fraction rises with the slider position`() { + for (supply in listOf(30_000L, 1_250_000L, 12_000_000L)) { + val calc = projection(supply = supply * WHOLE_TOKEN) + val fractions = (0..20).map { calc.chart(calc.snapshotAt(it / 20.0)).let { c -> c.xFraction(c.selectedTokens) } } + assertTrue(fractions.zipWithNext().all { (a, b) -> b > a }, "supply=$supply: $fractions") + } + } + + private fun calcAtReserve(reserve: Long): BondingCurveProjection { + val quarks = projection().snapshotAtReserve(BigDecimal(reserve)).supplyQuarks + return projection(supply = quarks) + } + + private fun stopsOf(calc: BondingCurveProjection) = calc.stops.map { it.toLong() } + + private val baseStops = listOf(5_000L, 100_000, 1_000_000, 10_000_000) + + @Test + fun `small reserves keep the base stops`() { + assertEquals(baseStops, stopsOf(calcAtReserve(22_700))) + assertEquals(baseStops, stopsOf(calcAtReserve(171_000))) + assertEquals(baseStops, stopsOf(projection())) + } + + @Test + fun `stops extend by ten times while the top stop is within twice today`() { + assertEquals(baseStops + 100_000_000L, stopsOf(calcAtReserve(6_000_000))) + assertEquals(baseStops + 100_000_000L, stopsOf(calcAtReserve(10_000_000))) + assertEquals(baseStops + 100_000_000L + 1_000_000_000L, stopsOf(calcAtReserve(60_000_000))) + } + + @Test + fun `today sits strictly left of the last stop`() { + for (reserve in listOf(22_700L, 171_000L, 6_000_000L, 8_000_000L, 10_000_000L, 60_000_000L)) { + val calc = calcAtReserve(reserve) + assertTrue(calc.todayPosition < 1.0, "reserve=$reserve position=${calc.todayPosition}") + assertTrue(calc.todayReserve < calc.stops.last(), "reserve=$reserve") + } + } + + @Test + fun `stops are never beyond the reserve at max supply`() { + val calc = calcAtReserve(60_000_000) + val cap = com.flipcash.libs.currency.math.Estimator.reserveAtSupply(BigDecimal(21_000_000)).getOrThrow() + assertTrue(calc.stops.all { it <= cap }) + assertEquals(emptyList(), BondingCurveProjection.reserveStops(BigDecimal(6_000_000), BigDecimal(50_000_000)) + .filter { it > BigDecimal(50_000_000) }) + } + + @Test + fun `marker stays in bounds across the slider for a 60M reserve`() { + val calc = calcAtReserve(60_000_000) + val fractions = (0..100).map { calc.chart(calc.snapshotAt(it / 100.0)).let { c -> c.xFraction(c.selectedTokens) to c } } + for ((f, c) in fractions) { + assertTrue(f in 0f..1f) + assertTrue(c.selectedTokens <= c.xMax && c.todayTokens <= c.xMax) + } + } +} diff --git a/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/MarketCapExplainerViewModelTest.kt b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/MarketCapExplainerViewModelTest.kt new file mode 100644 index 0000000000..7217af26d9 --- /dev/null +++ b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/MarketCapExplainerViewModelTest.kt @@ -0,0 +1,211 @@ +package com.flipcash.app.tokens.ui + +import com.flipcash.app.core.MainCoroutineRule +import com.flipcash.app.core.dispatchers.TestDispatchers +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.libs.currency.math.CurveTestInitializer +import com.getcode.opencode.exchange.Exchange +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.HolderMetrics +import com.getcode.opencode.model.financial.LaunchpadMetadata +import com.getcode.opencode.model.financial.MintMetadata +import com.getcode.opencode.model.financial.Rate +import com.getcode.opencode.model.financial.Token +import com.getcode.opencode.model.financial.VmMetadata +import com.getcode.solana.keys.Mint +import com.getcode.solana.keys.PublicKey +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.BeforeClass +import org.junit.Rule +import org.junit.Test +import java.math.BigDecimal +import java.math.MathContext +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +@OptIn(ExperimentalCoroutinesApi::class) +class MarketCapExplainerViewModelTest { + + companion object { + @JvmStatic + @BeforeClass + fun initCurve() { + CurveTestInitializer.initialize() + } + + private const val WHOLE_TOKEN = 10_000_000_000L + private const val SUPPLY_QUARKS = 1_250_000L * WHOLE_TOKEN + } + + @get:Rule + var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) + + private val mint = Mint(List(32) { 1.toByte() }) + private val key = PublicKey.fromBase58("11111111111111111111111111111111") + + private val token: Token = MintMetadata( + address = mint, + decimals = 10, + name = "TestCoin", + symbol = "TEST", + createdAt = null, + description = "", + imageUrl = "", + vmMetadata = VmMetadata(vm = key, authority = key, lockDurationInDays = 21), + launchpadMetadata = LaunchpadMetadata( + currencyConfig = key, + liquidityPool = key, + seed = key, + authority = key, + mintVault = key, + coreMintVault = key, + currentCirculatingSupplyQuarks = SUPPLY_QUARKS, + sellFeeBps = 100, + price = Fiat(fiat = 0.01), + marketCap = Fiat(fiat = 10000.0), + ), + billCustomizations = null, + socialLinks = emptyList(), + holderMetrics = HolderMetrics.None, + ) + + private val appreciation = MutableStateFlow(Fiat.Zero) + private val rate = MutableStateFlow(Rate.oneToOne) + + + private fun kotlinx.coroutines.test.TestScope.newVm( + cache: Map = mapOf(mint to token), + held: Long? = 12_400L * WHOLE_TOKEN, + ): MarketCapExplainerViewModel { + val coordinator = mockk(relaxed = true) + every { coordinator.observeTokenCache() } returns MutableStateFlow(cache) + coEvery { coordinator.getTokenMetadata(any()) } returns Result.failure(IllegalStateException("cache only")) + every { coordinator.heldQuarksForToken(any()) } returns MutableStateFlow(held) + every { coordinator.balanceForToken(any()) } returns MutableStateFlow(Fiat.Zero) + every { coordinator.appreciationForToken(any()) } returns appreciation + val exchange = mockk(relaxed = true) + every { exchange.observePreferredRate() } returns rate + return MarketCapExplainerViewModel(coordinator, exchange, TestDispatchers(testScheduler)) + } + + @Test + fun `providing a cached mint loads the token and a projection at Today`() = + runTest(mainCoroutineRule.dispatcher) { + val vm = newVm() + vm.dispatchEvent(MarketCapExplainerViewModel.Event.OnMintProvided(mint)) + advanceUntilIdle() + + val state = vm.stateFlow.value + assertEquals(token, state.token) + val projection = assertNotNull(state.projection) + assertEquals(true, projection.today().isToday) + } + + @Test + fun `a token that is not cached leaves the projection null`() = + runTest(mainCoroutineRule.dispatcher) { + val vm = newVm(cache = emptyMap()) + vm.dispatchEvent(MarketCapExplainerViewModel.Event.OnMintProvided(mint)) + advanceUntilIdle() + + assertNull(vm.stateFlow.value.projection) + } + + @Test + fun `appreciation is converted to the preferred currency`() = + runTest(mainCoroutineRule.dispatcher) { + rate.value = Rate(fx = 2.0, currency = CurrencyCode.EUR) + appreciation.value = Fiat(5.0, CurrencyCode.USD) + val vm = newVm() + vm.dispatchEvent(MarketCapExplainerViewModel.Event.OnMintProvided(mint)) + advanceUntilIdle() + + val state = vm.stateFlow.value + assertEquals(Rate(fx = 2.0, currency = CurrencyCode.EUR), state.rate) + assertEquals(CurrencyCode.EUR, state.appreciation?.currencyCode) + assertEquals(10.0, state.appreciation!!.toDouble(), 1e-9) + } + + @Test + fun `negative appreciation keeps its sign after conversion`() = + runTest(mainCoroutineRule.dispatcher) { + rate.value = Rate(fx = 2.0, currency = CurrencyCode.EUR) + appreciation.value = Fiat(-5.0, CurrencyCode.USD) + val vm = newVm() + vm.dispatchEvent(MarketCapExplainerViewModel.Event.OnMintProvided(mint)) + advanceUntilIdle() + + assertEquals(-10.0, vm.stateFlow.value.appreciation!!.toDouble(), 1e-9) + } + + @Test + fun `zero appreciation stays zero`() = + runTest(mainCoroutineRule.dispatcher) { + rate.value = Rate.oneToOne + appreciation.value = Fiat.Zero + val vm = newVm() + vm.dispatchEvent(MarketCapExplainerViewModel.Event.OnMintProvided(mint)) + advanceUntilIdle() + + assertEquals(0.0, vm.stateFlow.value.appreciation!!.toDouble(), 0.0) + } + + @Test + fun `unknown cost basis surfaces as null appreciation`() = + runTest(mainCoroutineRule.dispatcher) { + rate.value = Rate(fx = 2.0, currency = CurrencyCode.EUR) + appreciation.value = Fiat.MIN_VALUE + val vm = newVm() + vm.dispatchEvent(MarketCapExplainerViewModel.Event.OnMintProvided(mint)) + advanceUntilIdle() + + assertNull(vm.stateFlow.value.appreciation) + } + + @Test + fun `fixed labels are formatted in the preferred currency and follow a rate change`() = + runTest(mainCoroutineRule.dispatcher) { + val vm = newVm() + vm.dispatchEvent(MarketCapExplainerViewModel.Event.OnMintProvided(mint)) + advanceUntilIdle() + val usdTicks = assertNotNull(vm.stateFlow.value.labels).ticks + assertEquals("$5K", usdTicks.entries.first { it.key.reserve.compareTo(BigDecimal(5_000)) == 0 }.value) + + rate.value = Rate(fx = 0.5, currency = CurrencyCode.EUR) + advanceUntilIdle() + + val labels = assertNotNull(vm.stateFlow.value.labels) + val byReserve = labels.ticks.mapKeys { it.key.reserve.toInt() } + // 5,000 USD is 2,500 EUR; 10M USD is 5M EUR. + assertEquals("\u20ac2.5K", byReserve[5_000]) + assertEquals("\u20ac5M", byReserve[10_000_000]) + assertEquals("12,400", labels.tokensHeld) + } + + @Test + fun `price keeps four significant figures in the preferred currency`() { + assertEquals("$0.02994", MarketCapExplainerViewModel.price(BigDecimal("0.02994"), Rate.oneToOne)) + // 0.02994 USD is 0.01497 EUR. + assertEquals("\u20ac0.01497", MarketCapExplainerViewModel.price(BigDecimal("0.02994"), Rate(fx = 0.5, currency = CurrencyCode.EUR))) + assertEquals("\u00a54.491", MarketCapExplainerViewModel.price(BigDecimal("0.02994"), Rate(fx = 150.0, currency = CurrencyCode.JPY))) + } + + @Test + fun `a tiny non-zero share reads as less than a hundredth of a percent`() { + val share = BigDecimal(597).multiply(BigDecimal(100)).divide(BigDecimal(21_000_000), MathContext(20)) + assertEquals("<0.01%", MarketCapExplainerViewModel.percent(share)) + assertEquals("<0.01%", MarketCapExplainerViewModel.percent(BigDecimal("0.0099"))) + assertEquals("0.01%", MarketCapExplainerViewModel.percent(BigDecimal("0.01"))) + assertEquals("0.99%", MarketCapExplainerViewModel.percent(BigDecimal("0.992"))) + assertEquals("0%", MarketCapExplainerViewModel.percent(BigDecimal.ZERO)) + } +} diff --git a/libs/currency-math/src/main/kotlin/com/flipcash/libs/currency/math/Estimator.kt b/libs/currency-math/src/main/kotlin/com/flipcash/libs/currency/math/Estimator.kt index 86ea54b028..cfebd646f3 100644 --- a/libs/currency-math/src/main/kotlin/com/flipcash/libs/currency/math/Estimator.kt +++ b/libs/currency-math/src/main/kotlin/com/flipcash/libs/currency/math/Estimator.kt @@ -351,6 +351,24 @@ object Estimator { } } + /** + * The reserve (dollars held by the discrete curve) at a supply: the value of every token from + * zero to [supplyInTokens]. + */ + fun reserveAtSupply(supplyInTokens: BigDecimal): Result = + runCatching { + DiscreteBondingCurve.getOrThrow() + .tokensToValue(currentSupply = BigDecimal.ZERO, tokens = supplyInTokens) + .getOrThrow() + } + + /** + * The inverse of [reserveAtSupply]: the supply, in whole (fractional) tokens, at which the + * discrete curve's reserve equals [reserve]. + */ + fun supplyAtReserve(reserve: BigDecimal): Result = + runCatching { DiscreteBondingCurve.getOrThrow().supplyFromValue(reserve).getOrThrow() } + fun currentMarketCap( currentSupplyInQuarks: Long, curveType: CurveType = DefaultCurveType, diff --git a/libs/currency-math/src/main/kotlin/com/flipcash/libs/currency/math/internal/curves/DiscreteBondingCurve.kt b/libs/currency-math/src/main/kotlin/com/flipcash/libs/currency/math/internal/curves/DiscreteBondingCurve.kt index c3c81b8b5b..e2858019ca 100644 --- a/libs/currency-math/src/main/kotlin/com/flipcash/libs/currency/math/internal/curves/DiscreteBondingCurve.kt +++ b/libs/currency-math/src/main/kotlin/com/flipcash/libs/currency/math/internal/curves/DiscreteBondingCurve.kt @@ -82,6 +82,11 @@ internal object DiscreteBondingCurve : BondingCurve { BigDecimal(result) } + /** Interpolated (fractional-token) supply whose cumulative reserve equals [value]. */ + fun supplyFromValue(value: BigDecimal): Result = runCatching { + BigDecimal(SharedBondingCurve.preciseSupplyFromValue(value.toPlainString())) + } + override fun tokensForValueExchange(currentValue: BigDecimal, value: BigDecimal): Result = runCatching { val result = SharedBondingCurve.tokensForValueExchange(currentValue.toPlainString(), value.toPlainString()) ?: throw IllegalArgumentException("Invalid exchange") diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/text/AnimatedNumberText.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/text/AnimatedNumberText.kt index 4e8916b3c3..19225a4081 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/text/AnimatedNumberText.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/text/AnimatedNumberText.kt @@ -1,34 +1,39 @@ package com.getcode.ui.components.text -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.EnterExitState -import androidx.compose.animation.EnterTransition -import androidx.compose.animation.ExitTransition -import androidx.compose.animation.SizeTransform -import androidx.compose.animation.core.animateFloat +import android.os.Build +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.height import androidx.compose.material.LocalTextStyle import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.BlurEffect import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.GraphicsLayerScope +import androidx.compose.ui.graphics.TileMode +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.layout import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextMeasurer import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.unit.Constraints @@ -36,8 +41,19 @@ import androidx.compose.ui.unit.dp import com.getcode.ui.utils.AutoSizeTextMeasurer import com.getcode.ui.utils.ConstraintMode import com.getcode.ui.utils.MeasureWidthFraction +import kotlin.math.abs +import kotlin.math.ceil import kotlin.math.roundToInt +/** + * A number whose digits roll like SwiftUI's `.contentTransition(.numericText())`. + * + * Characters are slots keyed by role (see [numberSlots]), so a longer or shorter string keeps each + * digit in its own slot. A digit slot's transition is an [Animatable] spring that is retargeted, + * keeping its offset and velocity, whenever the digit changes again mid-roll, so a value that moves + * every frame glides instead of restarting. Digits share the widest digit's width; symbols and + * suffixes stay still unless they change; a slot appearing or disappearing animates its width. + */ @Composable fun AnimatedNumberText( value: String, @@ -47,16 +63,18 @@ fun AnimatedNumberText( constraintMode: ConstraintMode = ConstraintMode.Free, ) { BoxWithConstraints(modifier = modifier) { + val textMeasurer = rememberTextMeasurer() val textSize = if (constraintMode is ConstraintMode.AutoSize) { - val textMeasurer = rememberTextMeasurer() val autosizeTextMeasurer = remember(textMeasurer) { AutoSizeTextMeasurer(textMeasurer) } val maxWidthPx = with(LocalDensity.current) { maxWidth.roundToPx() } + // Digits are drawn at the widest digit's width, so size the string as it will be drawn. + val widest = remember(style) { textMeasurer.digitMetrics(style).widest } - remember(value, style.fontSize, maxWidthPx) { + remember(value, style.fontSize, maxWidthPx, widest) { autosizeTextMeasurer.findFontSize( - text = AnnotatedString(value), + text = AnnotatedString(value.map { if (it.isDigit()) widest else it }.joinToString("")), style = style, constraints = Constraints( maxWidth = (maxWidthPx * MeasureWidthFraction).roundToInt(), @@ -72,120 +90,332 @@ fun AnimatedNumberText( } val resolvedStyle = style.copy(fontSize = textSize) - Row { - value.forEach { char -> - AnimatedDigit( - char = char, - style = resolvedStyle, - color = color, - ) - } - } + val digitWidthPx = remember(resolvedStyle) { textMeasurer.digitMetrics(resolvedStyle).widthPx } + + DeferredNumberRoll( + value = value, + style = resolvedStyle, + color = color, + digitWidthPx = digitWidthPx, + ) } } /** - * One character of [AnimatedNumberText]: a digit that rolls to its replacement, anything else plain. + * Shows [value] as plain text until it first changes, then mounts the roller. * - * The roller is only mounted once the digit has actually changed. `AnimatedContent` costs several - * times the `Text` it wraps to compose, and a screen that shows many numbers at once -- the wallet's - * card deck -- pays that for every character of every number before the first frame, to animate - * nothing. A number that never moves now costs a plain [Text]. + * The roller costs several times the `Text` it wraps to compose, and a screen that shows many + * numbers at once -- the wallet's card deck -- would pay that for every character of every number + * before the first frame, to animate nothing. A number that never moves costs a [Text] per character. * - * The first change still animates: [rolling] flips a frame ahead of [shown], so the roller mounts - * seeded with the digit already on screen and has something to animate to. + * The first change still rolls: the roller mounts seeded with the value already on screen, and only + * follows [value] from the next frame, so it has something to animate to. */ @Composable -private fun AnimatedDigit( - char: Char, +private fun DeferredNumberRoll( + value: String, style: TextStyle, color: Color, + digitWidthPx: Int, ) { - if (!char.isDigit()) { - Text( - text = char.toString(), + // What the static row shows, and what the roller is seeded with when it mounts. + val seed = remember { value } + var mounted by remember { mutableStateOf(false) } + var following by remember { mutableStateOf(false) } + + LaunchedEffect(value) { + if (mounted) return@LaunchedEffect + if (value != seed) mounted = true + } + // Restarts on the composition that mounted the roller. + LaunchedEffect(mounted) { + if (!mounted) return@LaunchedEffect + // Give the roller a frame of its own before moving it. Effects run inside the frame that + // composed them, so following [value] straight away would settle the mount and the move in + // one pass and the digits would snap instead of rolling. + withFrameNanos { } + following = true + } + + if (!mounted) { + StaticNumber(value = seed, style = style, color = color, digitWidthPx = digitWidthPx) + } else { + RollingNumber( + value = if (following) value else seed, style = style, color = color, + digitWidthPx = digitWidthPx, ) - return } +} - var rolling by remember { mutableStateOf(false) } - // What the roller is showing. Held one composition behind [char] on the change that mounts the - // roller, so that mount is seeded with the outgoing digit rather than the incoming one. - var shown by remember { mutableStateOf(char) } +@Composable +private fun StaticNumber( + value: String, + style: TextStyle, + color: Color, + digitWidthPx: Int, +) { + Row(verticalAlignment = Alignment.CenterVertically) { + value.forEach { char -> + Text( + modifier = if (char.isDigit()) Modifier.digitCell(digitWidthPx) else Modifier, + text = char.toString(), + style = style, + color = color, + maxLines = 1, + softWrap = false, + ) + } + } +} - LaunchedEffect(char) { - if (char != shown) rolling = true +@Composable +private fun RollingNumber( + value: String, + style: TextStyle, + color: Color, + digitWidthPx: Int, +) { + val direction = remember { DirectionHolder(value) } + if (direction.last != value) { + direction.rising = numericValue(value) >= numericValue(direction.last) + direction.last = value } - // Restarts on the composition that observes [rolling], which is the one that mounted the roller. - LaunchedEffect(rolling, char) { - if (!rolling) return@LaunchedEffect - // Give the roller a frame of its own before moving it. Effects run inside the frame that - // composed them, so writing [shown] straight away would settle the mount and the move in one - // pass and the digit would snap instead of rolling. - if (shown != char) withFrameNanos { } - shown = char + + // The largest extent seen is kept so a slot that empties can animate out instead of vanishing. + val layout = remember { SlotLayout() } + val chars = numberSlots(value) + layout.absorb(chars) + + Row(verticalAlignment = Alignment.CenterVertically) { + for (slotKey in layout.keys()) { + key(slotKey) { + NumberSlot( + char = chars[slotKey], + rising = direction.rising, + digitWidthPx = digitWidthPx, + style = style, + color = color, + ) + } + } } +} - if (!rolling) { - Text( - text = shown.toString(), - style = style, - color = color, - ) - return +private class SlotLayout { + private var prefix = 0 + private var left = 0 + private var right = 0 + private var suffix = 0 + private var dot = false + + fun absorb(chars: Map) { + for (k in chars.keys) { + val n = k.drop(1).toIntOrNull()?.plus(1) ?: 0 + when { + k == "dot" -> dot = true + k[0] == 'P' -> prefix = maxOf(prefix, n) + k[0] == 'L' -> left = maxOf(left, n) + k[0] == 'R' -> right = maxOf(right, n) + k[0] == 'S' -> suffix = maxOf(suffix, n) + } + } } - val textMeasurer = rememberTextMeasurer() - val height = remember(style) { - textMeasurer.measure("0", style).size.height + fun keys(): List = buildList { + for (i in 0 until prefix) add("P$i") + for (i in left - 1 downTo 0) add("L$i") + if (dot) add("dot") + for (i in 0 until right) add("R$i") + for (i in 0 until suffix) add("S$i") } +} + +@Composable +private fun NumberSlot( + char: Char?, + rising: Boolean, + digitWidthPx: Int, + style: TextStyle, + color: Color, +) { + // Keep the last real character so a slot that empties can fade and shrink out. + val shown = remember { CharHolder(char ?: ' ') } + if (char != null) shown.value = char + val shownChar = shown.value + val isDigit = shownChar.isDigit() + + val presence = remember { Animatable(if (char != null) 1f else 0f) } + LaunchedEffect(char != null) { + presence.animateTo(if (char != null) 1f else 0f, tween(220)) + } + + // Two-glyph transition like numericText: the old glyph slides out and the new one slides in from + // the other side, with nothing drawn for the digits in between. Offsets are in glyph heights: + // out = s + (-dir - s) * p, in = dir * (1 - p). A retarget keeps the glyph that is showing most as + // the new outgoing one, starting from its current offset and velocity. + val initial = if (isDigit) shownChar.digitToInt() else 0 + val outDigit = remember { mutableIntStateOf(initial) } + val inDigit = remember { mutableIntStateOf(initial) } + val startOffset = remember { mutableFloatStateOf(0f) } + val dir = remember { mutableFloatStateOf(1f) } + val progress = remember { Animatable(1f) } + val track = remember { SlotTrack(if (isDigit) shownChar.digitToInt() else -1) } + LaunchedEffect(char) { + if (char == null || !char.isDigit()) { + track.digit = -1 + return@LaunchedEffect + } + val next = char.digitToInt() + if (track.digit < 0) { + outDigit.intValue = next + inDigit.intValue = next + progress.snapTo(1f) + } else if (next != inDigit.intValue) { + val p = progress.value + val vp = progress.velocity + val dOut = startOffset.floatValue + (-dir.floatValue - startOffset.floatValue) * p + val dIn = dir.floatValue * (1f - p) + val inDominant = abs(dIn) <= abs(dOut) + val d0 = if (inDominant) dIn else dOut + val vDisp = if (inDominant) -dir.floatValue * vp else vp * (-dir.floatValue - startOffset.floatValue) + outDigit.intValue = if (inDominant) inDigit.intValue else outDigit.intValue + inDigit.intValue = next + startOffset.floatValue = d0 + dir.floatValue = if (rising) 1f else -1f + val denom = -dir.floatValue - d0 + val v0 = if (abs(denom) > 0.3f) vDisp / denom else 0f + progress.snapTo(0f) + progress.animateTo( + 1f, + spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = 380f), + initialVelocity = v0, + ) + } + track.digit = next + } + // The outgoing glyph is invisible from OutgoingFadeEnd on, so it leaves the tree there: a settled + // digit is one node, and TalkBack doesn't read the number twice. + val outgoingMounted by remember { derivedStateOf { progress.value < OutgoingFadeEnd } } + + val fade = remember { Animatable(1f) } + LaunchedEffect(char) { + if (char != null && !char.isDigit() && track.symbol != char) { + if (track.symbol != null) { + fade.snapTo(0f) + fade.animateTo(1f, tween(180)) + } + } + track.symbol = if (char != null && !char.isDigit()) char else null + } + + // A slot that has emptied and finished shrinking draws nothing, so it leaves nothing behind. + if (char == null && !presence.isRunning && presence.value == 0f) return Box( modifier = Modifier - .height(with(LocalDensity.current) { height.toDp() }) - ) { - AnimatedContent( - targetState = shown, - transitionSpec = { - if (targetState != initialState) { - val rising = (targetState.digitToIntOrNull() ?: 0) > - (initialState.digitToIntOrNull() ?: 0) - - // Enter: slide in from bottom (increasing) or top (decreasing) + fade - val enter = slideInVertically { height -> - if (rising) height else -height - } + fadeIn() - - // Exit: just fade/blur in place - no slide - val exit = fadeOut() - - enter togetherWith exit using SizeTransform() - } else { - EnterTransition.None togetherWith ExitTransition.None - } - }, - label = "digit" - ) { char -> - // Apply blur during exit transition - val blurRadius by transition.animateFloat( - transitionSpec = { tween(150) }, - label = "blur" - ) { state -> - when (state) { - EnterExitState.PreEnter -> 12f - EnterExitState.Visible -> 0f - EnterExitState.PostExit -> 12f + .layout { measurable, _ -> + val p = measurable.measure(Constraints()) + val w = if (isDigit) digitWidthPx else p.width + val shownWidth = (w * presence.value).roundToInt() + layout(shownWidth, p.height) { + p.place((shownWidth - p.width) / 2, 0) } } - + .clipToBounds() + .then(if (char == null) Modifier.clearAndSetSemantics { } else Modifier), + contentAlignment = Alignment.Center, + ) { + if (isDigit) { + if (outgoingMounted) { + Text( + modifier = Modifier + .digitCell(digitWidthPx) + .graphicsLayer { + val p = progress.value + val d = startOffset.floatValue + (-dir.floatValue - startOffset.floatValue) * p + applyGlyphMotion(d, outgoingFade(p), p, presence.value) + }, + text = outDigit.intValue.toString(), + style = style, + color = color, + maxLines = 1, + softWrap = false, + ) + } Text( - modifier = Modifier.blur(blurRadius.dp), - text = char.toString(), + modifier = Modifier + .digitCell(digitWidthPx) + .graphicsLayer { + val p = progress.value + val d = dir.floatValue * (1f - p) + applyGlyphMotion(d, incomingFade(p), p, presence.value) + }, + text = inDigit.intValue.toString(), + style = style, + color = color, + maxLines = 1, + softWrap = false, + ) + } else { + Text( + modifier = Modifier.graphicsLayer { alpha = fade.value * presence.value }, + text = shownChar.toString(), style = style, color = color, + maxLines = 1, + softWrap = false, ) } } } + +/** Lays a glyph out [widthPx] wide, centred, so every digit takes the same room. */ +private fun Modifier.digitCell(widthPx: Int): Modifier = layout { measurable, _ -> + val p = measurable.measure(Constraints()) + layout(widthPx, p.height) { + p.place((widthPx - p.width) / 2, 0) + } +} + +/** + * Applies [glyphMotion] to a glyph's layer. The blur is a `RenderEffect`, which only exists from + * API 31; below that the glyph keeps the short travel and the fade. + */ +private fun GraphicsLayerScope.applyGlyphMotion(d: Float, fade: Float, p: Float, presence: Float) { + val motion = glyphMotion(d, fade, p, presence) + translationY = motion.travel * size.height + alpha = motion.alpha + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val radius = MaxBlur.toPx() * motion.blur + renderEffect = if (radius > 0.5f) BlurEffect(radius, radius, TileMode.Decal) else null + } +} + +private val MaxBlur = 11.dp + +private const val Digits = "0123456789" + +private class DigitMetrics(val widest: Char, val widthPx: Int) + +/** The widest digit in [style] and its width, from one layout of all ten. */ +private fun TextMeasurer.digitMetrics(style: TextStyle): DigitMetrics { + val result = measure(Digits, style) + var widest = 0 + var width = 0f + for (i in Digits.indices) { + val w = result.getBoundingBox(i).width + if (w > width) { + width = w + widest = i + } + } + return DigitMetrics(Digits[widest], ceil(width).toInt()) +} + +private class CharHolder(var value: Char) + +private class SlotTrack(var digit: Int, var symbol: Char? = null) + +private class DirectionHolder(var last: String, var rising: Boolean = true) diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/text/NumberRollMotion.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/text/NumberRollMotion.kt new file mode 100644 index 0000000000..3989df4caa --- /dev/null +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/text/NumberRollMotion.kt @@ -0,0 +1,77 @@ +package com.getcode.ui.components.text + +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.sin + +/** + * Splits a readout into role-keyed characters: `P0..` prefix, `L0..` integer characters counted from + * the point, `dot`, `R0..` fraction digits, `S0..` suffix. Keying by role rather than position keeps + * each digit in its own slot when the string gets longer or shorter (`$170K` -> `$27.0K`). + */ +internal fun numberSlots(text: String): Map { + val out = LinkedHashMap() + val first = text.indexOfFirst { it.isDigit() } + if (first < 0) { + text.forEachIndexed { i, c -> out["P$i"] = c } + return out + } + val last = text.indexOfLast { it.isDigit() } + for (i in 0 until first) out["P$i"] = text[i] + for (i in last + 1 until text.length) out["S${i - last - 1}"] = text[i] + val middle = text.substring(first, last + 1) + val dot = middle.indexOf('.') + val left = if (dot >= 0) middle.substring(0, dot) else middle + left.forEachIndexed { i, c -> out["L${left.length - 1 - i}"] = c } + if (dot >= 0) { + out["dot"] = '.' + for (j in dot + 1 until middle.length) out["R${j - dot - 1}"] = middle[j] + } + return out +} + +/** The pure result of [glyphMotion]: [travel] in glyph heights, [alpha], and [blur] as a 0..1 fraction of the full radius. */ +internal data class GlyphMotion(val travel: Float, val alpha: Float, val blur: Float) + +/** + * numericText look: a glyph barely leaves the baseline (at most [MaxTravel] of its height) and + * mid-transition both glyphs are heavy blurred smudges. [d] is the normalised offset (-1..1), [fade] + * the glyph's own opacity ramp, [p] the transition progress that drives the blur. + */ +internal fun glyphMotion(d: Float, fade: Float, p: Float, presence: Float): GlyphMotion { + // A retarget restarts p at 0 with the glyph already displaced, so blur follows the larger of the + // transition arc and the glyph's own distance from rest; only a settled glyph is sharp. + val arc = sin(PI * p.coerceIn(0f, 1f)).toFloat() + return GlyphMotion( + travel = d * MaxTravel, + alpha = fade * presence, + blur = maxOf(arc, abs(d).coerceIn(0f, 1f)), + ) +} + +/** The outgoing glyph is fully faded by p = [OutgoingFadeEnd]. */ +internal fun outgoingFade(p: Float): Float = 1f - (p / OutgoingFadeEnd).coerceIn(0f, 1f) + +internal const val OutgoingFadeEnd = 0.6f + +/** The incoming glyph starts appearing at p = 0.4. */ +internal fun incomingFade(p: Float): Float = ((p - 0.4f) / 0.6f).coerceIn(0f, 1f) + +internal const val MaxTravel = 0.3f + +/** + * The number a formatted readout stands for, enough to tell which way it moved: `$22.7K` is 22,700 + * and `-$41.67` is below `-$44.02`. + */ +internal fun numericValue(text: String): Double { + val number = text.filter { it.isDigit() || it == '.' }.toDoubleOrNull() ?: return 0.0 + val scale = when { + text.endsWith("K") -> 1e3 + text.endsWith("M") -> 1e6 + text.endsWith("B") -> 1e9 + text.endsWith("T") -> 1e12 + else -> 1.0 + } + val sign = if (text.trimStart().let { it.startsWith("-") || it.startsWith("−") }) -1.0 else 1.0 + return sign * number * scale +} diff --git a/ui/components/src/test/kotlin/com/getcode/ui/components/text/AnimatedNumberTextTest.kt b/ui/components/src/test/kotlin/com/getcode/ui/components/text/AnimatedNumberTextTest.kt index 13bf70b287..1081018f2d 100644 --- a/ui/components/src/test/kotlin/com/getcode/ui/components/text/AnimatedNumberTextTest.kt +++ b/ui/components/src/test/kotlin/com/getcode/ui/components/text/AnimatedNumberTextTest.kt @@ -15,10 +15,10 @@ import kotlin.test.assertEquals import kotlin.test.assertTrue /** - * The roller inside [AnimatedNumberText] is only mounted once a digit has actually changed, because - * `AnimatedContent` costs several times the `Text` it wraps to compose and a wallet full of numbers - * pays that before its first frame. These cover what that defers: the digit still has to roll when - * the number moves, including the very first time. + * The roller inside [AnimatedNumberText] is only mounted once the number has actually changed, + * because it costs several times the `Text` it wraps to compose and a wallet full of numbers pays + * that before its first frame. These cover what that defers: the digit still has to roll when the + * number moves, including the very first time, and the outgoing digit leaves once the roll is done. */ @RunWith(RobolectricTestRunner::class) @Config(sdk = [34], qualifiers = "w411dp-h891dp") diff --git a/ui/components/src/test/kotlin/com/getcode/ui/components/text/NumberRollMotionTest.kt b/ui/components/src/test/kotlin/com/getcode/ui/components/text/NumberRollMotionTest.kt new file mode 100644 index 0000000000..b0a0e82f2a --- /dev/null +++ b/ui/components/src/test/kotlin/com/getcode/ui/components/text/NumberRollMotionTest.kt @@ -0,0 +1,112 @@ +package com.getcode.ui.components.text + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class NumberRollMotionTest { + @Test + fun `slots are keyed by role with integer digits counted from the point`() { + assertEquals( + mapOf("P0" to '$', "L2" to '1', "L1" to '7', "L0" to '0', "S0" to 'K'), + numberSlots("$170K"), + ) + } + + @Test + fun `fraction digits and the point get their own slots`() { + assertEquals( + mapOf("P0" to '$', "L1" to '2', "L0" to '7', "dot" to '.', "R0" to '0', "S0" to 'K'), + numberSlots("$27.0K"), + ) + } + + @Test + fun `a digit keeps its slot when the string gets shorter`() { + val before = numberSlots("$170K") + val after = numberSlots("$27.0K") + // The units digit stays in L0 and the leading digit drops out of L2. + assertEquals('0', before["L0"]) + assertEquals('7', after["L0"]) + assertTrue("L2" in before && "L2" !in after) + } + + @Test + fun `a suffix change reuses the suffix slot`() { + assertEquals('K', numberSlots("$999K")["S0"]) + assertEquals('M', numberSlots("$1.00M")["S0"]) + } + + @Test + fun `text without digits becomes prefix slots`() { + assertEquals(mapOf("P0" to '-', "P1" to '-'), numberSlots("--")) + assertEquals(emptyMap(), numberSlots("")) + } + + @Test + fun `numericValue applies the K M B T suffix scale`() { + assertEquals(22_700.0, numericValue("$22.7K"), 1e-6) + assertEquals(1_000_000.0, numericValue("$1M"), 1e-6) + assertEquals(2_500_000_000.0, numericValue("$2.5B"), 1e-3) + assertEquals(3e12, numericValue("$3T"), 1.0) + assertEquals(5.0, numericValue("$5"), 1e-9) + assertEquals(0.0, numericValue("--"), 0.0) + } + + @Test + fun `direction is rising when the readout grows across a suffix`() { + assertTrue(numericValue("$1.2M") >= numericValue("$999K")) + } + + @Test + fun `direction is falling when the readout shrinks across a suffix`() { + assertTrue(numericValue("$999K") < numericValue("$1.2M")) + assertTrue(numericValue("$27.0K") < numericValue("$170K")) + } + + @Test + fun `a settled glyph is sharp and opaque`() { + val m = glyphMotion(d = 0f, fade = incomingFade(1f), p = 1f, presence = 1f) + assertEquals(0f, m.travel, 1e-6f) + assertEquals(1f, m.alpha, 1e-6f) + assertEquals(0f, m.blur, 1e-6f) + } + + @Test + fun `travel is capped at 0_3 of the glyph height`() { + assertEquals(MaxTravel, glyphMotion(1f, 1f, 0.5f, 1f).travel, 1e-6f) + assertEquals(-MaxTravel, glyphMotion(-1f, 1f, 0.5f, 1f).travel, 1e-6f) + assertEquals(0.3f, MaxTravel, 0f) + } + + @Test + fun `outgoing glyph is gone by p 0_6 and incoming starts at p 0_4`() { + assertEquals(1f, outgoingFade(0f), 1e-6f) + assertEquals(0f, outgoingFade(0.6f), 1e-6f) + assertEquals(0f, outgoingFade(1f), 1e-6f) + assertEquals(0f, incomingFade(0f), 1e-6f) + assertEquals(0f, incomingFade(0.4f), 1e-6f) + assertEquals(1f, incomingFade(1f), 1e-6f) + } + + @Test + fun `presence scales alpha`() { + assertEquals(0.5f, glyphMotion(0f, 1f, 1f, 0.5f).alpha, 1e-6f) + } + + @Test + fun `a retargeted glyph that is still displaced is blurred even at p 0`() { + val m = glyphMotion(d = 0.8f, fade = 1f, p = 0f, presence = 1f) + assertEquals(0.8f, m.blur, 1e-6f) + } + + @Test + fun `blur peaks mid transition`() { + assertEquals(1f, glyphMotion(0f, 1f, 0.5f, 1f).blur, 1e-6f) + } + + @Test + fun `a negative readout is below a less negative one`() { + assertTrue(numericValue("-\$41.67") > numericValue("-\$44.02")) + } +} diff --git a/ui/theme/src/main/kotlin/com/getcode/theme/Type.kt b/ui/theme/src/main/kotlin/com/getcode/theme/Type.kt index 1540a3eff6..172b7db2ce 100644 --- a/ui/theme/src/main/kotlin/com/getcode/theme/Type.kt +++ b/ui/theme/src/main/kotlin/com/getcode/theme/Type.kt @@ -35,6 +35,7 @@ data class CodeTypography( val displayExtraLarge: TextStyle, val displayLarge: TextStyle, val displayMedium: TextStyle, + val displayCompact: TextStyle, val displaySmall: TextStyle, val displayExtraSmall: TextStyle, val keyboard: TextStyle, @@ -76,6 +77,12 @@ val codeTypography = CodeTypography( fontSize = 40.sp, fontWeight = FontWeight.Medium, ), + // Mirrors iOS appDisplayCompact (30 Bold). + displayCompact = TextStyle( + fontFamily = Avenir, + fontSize = 30.sp, + fontWeight = FontWeight.Bold, + ), displaySmall = TextStyle( fontFamily = Avenir, fontSize = 24.sp,