From 71ce02ee7d767dee4f55738940fe34d9889c9181 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 1 Oct 2026 20:25:32 -0400 Subject: [PATCH 1/8] feat(currency-math): expose reserve and supply conversions on the discrete curve --- .../flipcash/libs/currency/math/Estimator.kt | 18 ++++++++++++++++++ .../internal/curves/DiscreteBondingCurve.kt | 5 +++++ 2 files changed, 23 insertions(+) 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") From fdb9a4569a5bc3707bef789c365e624ae1eb5b37 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 1 Oct 2026 20:25:32 -0400 Subject: [PATCH 2/8] feat(theme): add displayCompact type style 30sp Bold, mirroring iOS appDisplayCompact. Nothing sat between the 24sp and 40sp display styles. --- ui/theme/src/main/kotlin/com/getcode/theme/Type.kt | 7 +++++++ 1 file changed, 7 insertions(+) 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, From 4d4298fea81cfd128039b6cd26089f9a1310ed7f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 1 Oct 2026 20:25:32 -0400 Subject: [PATCH 3/8] feat(tokens): add How Market Cap Works explainer Opens from the Market Cap header on token info and from a new row under the range chips (spec section 10776:1757). A slider over reserve shows what the balance would be worth, springing back to Today on release. Readouts roll the way SwiftUI's numericText does. --- .../ui/navigation/AppScreenContent.kt | 4 + .../kotlin/com/flipcash/app/core/AppRoute.kt | 2 + .../main/res/drawable/ic_market_cap_curve.xml | 14 + .../core/src/main/res/values/strings.xml | 15 + .../app/tokens/MarketCapExplainerScreen.kt | 37 ++ .../components/info/CurrencyInfoContentV2.kt | 15 + .../components/info/MarketCapExplainerRow.kt | 81 +++++ .../components/info/MarketCapSection.kt | 32 +- .../internal/explainer/ExplainerColors.kt | 9 + .../internal/explainer/ExplainerNumberText.kt | 305 ++++++++++++++++ .../explainer/MarketCapExplainerChart.kt | 145 ++++++++ .../explainer/MarketCapExplainerContent.kt | 302 ++++++++++++++++ .../explainer/MarketCapExplainerSlider.kt | 227 ++++++++++++ .../flipcash/app/tokens/TokenCoordinator.kt | 4 + .../marketcap/MarketCapExplainerCalculator.kt | 334 ++++++++++++++++++ .../tokens/ui/MarketCapExplainerViewModel.kt | 109 ++++++ .../MarketCapExplainerCalculatorTest.kt | 329 +++++++++++++++++ 17 files changed, 1958 insertions(+), 6 deletions(-) create mode 100644 apps/flipcash/core/src/main/res/drawable/ic_market_cap_curve.xml create mode 100644 apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/MarketCapExplainerScreen.kt create mode 100644 apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/MarketCapExplainerRow.kt create mode 100644 apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/explainer/ExplainerColors.kt create mode 100644 apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/explainer/ExplainerNumberText.kt create mode 100644 apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/explainer/MarketCapExplainerChart.kt create mode 100644 apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/explainer/MarketCapExplainerContent.kt create mode 100644 apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/explainer/MarketCapExplainerSlider.kt create mode 100644 apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/marketcap/MarketCapExplainerCalculator.kt create mode 100644 apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/MarketCapExplainerViewModel.kt create mode 100644 apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/marketcap/MarketCapExplainerCalculatorTest.kt 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..beb8c83692 --- /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..b99952d29a --- /dev/null +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/MarketCapExplainerRow.kt @@ -0,0 +1,81 @@ +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.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.flipcash.app.tokens.internal.explainer.ExplainerRowIconBackground +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(ExplainerRowIconBackground, CircleShape), + contentAlignment = Alignment.Center, + ) { + Image( + modifier = Modifier.size(22.dp), + painter = painterResource(R.drawable.ic_market_cap_curve), + 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/ExplainerColors.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/explainer/ExplainerColors.kt new file mode 100644 index 0000000000..c021b6aac3 --- /dev/null +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/explainer/ExplainerColors.kt @@ -0,0 +1,9 @@ +package com.flipcash.app.tokens.internal.explainer + +import androidx.compose.ui.graphics.Color + +/** The one green on the explainer card: the slider fill (design node 10700:178) and the chart line and area. */ +internal val ExplainerGreen = Color(0xFF009F50) + +/** The row's icon disc (design node 10761:1879, filled-20); the theme has no token for this value. */ +internal val ExplainerRowIconBackground = Color(0xFF474748) diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/explainer/ExplainerNumberText.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/explainer/ExplainerNumberText.kt new file mode 100644 index 0000000000..6415b8a18c --- /dev/null +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/explainer/ExplainerNumberText.kt @@ -0,0 +1,305 @@ +package com.flipcash.app.tokens.internal.explainer + +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.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.width +import androidx.compose.material.LocalTextStyle +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.dp +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import kotlin.math.abs +import kotlin.math.roundToInt + +/** + * A readout whose digits roll like SwiftUI's `.contentTransition(.numericText())`. + * + * Characters are slots keyed by role (integer digit from the point, fraction digit, suffix), so + * a longer or shorter string keeps each digit in its own slot. Each digit slot is an odometer: its position is an [Animatable] that is retargeted (keeping + * its velocity) whenever the digit changes, and it draws the current and the next digit offset by + * the fractional position with a fade. During a continuous drag the digits therefore glide instead + * of restarting a transition on every value. Digit slots share the widest digit's width; symbols and + * suffixes crossfade only when they change; a slot appearing or disappearing animates its width. + * + * Local to the explainer so [com.getcode.ui.components.text.AnimatedNumberText] keeps its behaviour + * for the other screens that use it. + */ +@Composable +internal fun ExplainerNumberText( + value: String, + modifier: Modifier = Modifier, + style: TextStyle = LocalTextStyle.current, + color: Color = Color.Unspecified, +) { + val measurer = rememberTextMeasurer() + val digitWidth = remember(style) { + ('0'..'9').maxOf { measurer.measure(it.toString(), style).size.width } + } + + val holder = remember { DirectionHolder(value) } + if (holder.last != value) { + holder.rising = numericValue(value) >= numericValue(holder.last) + holder.last = value + } + val rising = holder.rising + + // Slots are keyed by role (prefix, integer digit from the point, decimal, fraction digit, suffix) + // rather than by position in the string, so $170K -> $27.0K keeps each digit in its own slot. 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(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { + for (slotKey in layout.keys()) { + key(slotKey) { + NumberSlot( + char = chars[slotKey], + rising = rising, + digitWidthPx = digitWidth, + style = style, + color = color, + ) + } + } + } +} + +/** Splits a readout into role-keyed characters: `P0..` prefix, `L0..` integer digits from the point, `dot`, `R0..` fraction, `S0..` suffix. */ +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 +} + +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) + } + } + } + + 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 (shownChar.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 { OdometerTrack(if (shownChar.isDigit()) shownChar.digitToInt() else -1, 0f) } + 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 + } + + 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 + } + + Box( + modifier = Modifier.layout { measurable, _ -> + val p = measurable.measure(Constraints()) + val w = if (isDigit) digitWidthPx else p.width + layout((w * presence.value).roundToInt(), p.height) { + p.place(((w * presence.value).roundToInt() - p.width) / 2, 0) + } + }.drawWithContent { + clipRect(left = 0f, top = 0f, right = size.width, bottom = size.height) { + this@drawWithContent.drawContent() + } + }, + contentAlignment = Alignment.Center, + ) { + if (isDigit) { + val glyphWidth = with(LocalDensity.current) { digitWidthPx.toDp() } + // Outgoing glyph. + Text( + modifier = Modifier + .width(glyphWidth) + .graphicsLayer { + val p = progress.value + val d = startOffset.floatValue + (-dir.floatValue - startOffset.floatValue) * p + applyGlyphMotion(d, 1f - (p / 0.6f).coerceIn(0f, 1f), p, presence.value) + }, + text = outDigit.intValue.toString(), style = style, color = color, + maxLines = 1, softWrap = false, textAlign = TextAlign.Center, + ) + // Incoming glyph. + Text( + modifier = Modifier + .width(glyphWidth) + .graphicsLayer { + val p = progress.value + val d = dir.floatValue * (1f - p) + applyGlyphMotion(d, ((p - 0.4f) / 0.6f).coerceIn(0f, 1f), p, presence.value) + }, + text = inDigit.intValue.toString(), style = style, color = color, + maxLines = 1, softWrap = false, textAlign = TextAlign.Center, + ) + } else { + Text( + modifier = Modifier.graphicsLayer { alpha = fade.value * presence.value }, + text = shownChar.toString(), style = style, color = color, + maxLines = 1, softWrap = false, + ) + } + } +} + +/** + * numericText look: the 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 and [p] the transition progress that drives the blur. Below API 31 + * `RenderEffect` is ignored, leaving the short travel and the alpha crossfade. + */ +private fun androidx.compose.ui.graphics.GraphicsLayerScope.applyGlyphMotion(d: Float, fade: Float, p: Float, presence: Float) { + translationY = d * MaxTravel * size.height + alpha = fade * presence + // 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 = kotlin.math.sin(Math.PI * p.coerceIn(0f, 1f)).toFloat() + val radius = 11.dp.toPx() * maxOf(arc, abs(d).coerceIn(0f, 1f)) + renderEffect = if (radius > 0.5f) { + androidx.compose.ui.graphics.BlurEffect(radius, radius, androidx.compose.ui.graphics.TileMode.Decal) + } else { + null + } +} + +private const val MaxTravel = 0.3f + +private class CharHolder(var value: Char) + +private class OdometerTrack(var digit: Int, var logical: Float, var symbol: Char? = null) + +private class DirectionHolder(var last: String, var rising: Boolean = true) + + +/** The number a formatted readout stands for, enough to tell whether it went up: `$22.7K` is 22,700. */ +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 + } + return number * scale +} 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..0d199d918f --- /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.marketcap.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 = ExplainerGreen + 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..ec1b98ce04 --- /dev/null +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/explainer/MarketCapExplainerContent.kt @@ -0,0 +1,302 @@ +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.theme.FlipcashThemeWrapper +import com.flipcash.app.tokens.marketcap.ExplainerChart +import com.flipcash.app.tokens.marketcap.ExplainerChartPoint +import com.flipcash.app.tokens.marketcap.ExplainerCurrency +import com.flipcash.app.tokens.marketcap.ExplainerFormat +import com.flipcash.app.tokens.marketcap.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 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 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 calculator = state.calculator + val token = state.token + if (calculator == 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 = calculator.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(calculator, position) { + derivedStateOf { calculator.snapshotAt(position.toDouble()) } + } + val chart by remember(calculator, snapshot) { derivedStateOf { calculator.chart(snapshot) } } + val ownership = remember(calculator) { calculator.ownership() } + + // The curve stays in USD; only what is drawn is converted to the preferred currency. + val currency = remember(state.rate) { ExplainerCurrency(state.rate) } + val reserveText = currency.reserve(snapshot.reserve) + val scrubLabel = if (snapshot.isToday) stringResource(R.string.label_marketCapScrubToday, reserveText) else reserveText + val appreciation = state.appreciation + val appreciationText = appreciation?.formattedAppreciation() ?: Dash + val appreciationTone = when { + appreciation == null || !appreciation.valueNonZero() -> Tone.Neutral + appreciation.toDouble() > 0 -> Tone.Positive + appreciation.toDouble() < 0 -> Tone.Negative + else -> Tone.Neutral + } + + val model = ExplainerUiModel( + tokenName = token.name, + position = position, + ticks = calculator.ticks, + tickLabels = calculator.ticks.associateWith { currency.reserve(it.reserve) }, + reserveText = reserveText, + worthText = snapshot.worth?.let { currency.convert(it).formatted() } ?: Dash, + chart = chart, + scrubLabel = scrubLabel, + ownership = listOf( + OwnershipRow( + R.string.label_underlyingTokensYouOwn, + ownership.tokensHeld + ?.let { java.text.NumberFormat.getIntegerInstance().format(it.setScale(0, java.math.RoundingMode.DOWN)) } + ?: Dash, + ), + OwnershipRow(R.string.label_currentPricePerToken, currency.price(ownership.price)), + OwnershipRow( + R.string.label_shareOfCirculatingSupply, + ownership.shareOfCirculating?.let(ExplainerFormat::percent) ?: Dash, + ), + OwnershipRow(R.string.label_shareOfMaxSupply, ownership.shareOfMax?.let(ExplainerFormat::percent) ?: Dash), + OwnershipRow(R.string.label_yourCurrentAppreciation, appreciationText, appreciationTone), + ), + ) + 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, + ) + ExplainerNumberText( + 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, + ) + ExplainerNumberText( + 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 -> ExplainerGreen + 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..212a77a88d --- /dev/null +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/explainer/MarketCapExplainerSlider.kt @@ -0,0 +1,227 @@ +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 com.flipcash.app.tokens.marketcap.ExplainerLabelBox +import com.flipcash.app.tokens.marketcap.layoutExplainerLabels +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.marketcap.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 = ExplainerGreen, + activeTrackColor = ExplainerGreen, + inactiveTrackColor = TrackColor, + ) + // 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, Color(0xFF5A5A5C).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, + ) + } + } + } +} + +// Slider spec colour (design node 10700:177 track); the fill is [ExplainerGreen]. +private val TrackColor = Color(0xFF474748) + +@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/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/marketcap/MarketCapExplainerCalculator.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/marketcap/MarketCapExplainerCalculator.kt new file mode 100644 index 0000000000..359a5547db --- /dev/null +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/marketcap/MarketCapExplainerCalculator.kt @@ -0,0 +1,334 @@ +package com.flipcash.app.tokens.marketcap + +import com.flipcash.libs.currency.math.Estimator +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 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 [MarketCapExplainerCalculator.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 MarketCapExplainerCalculator( + 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) + } +} + +/** + * Shows the explainer's dollar figures in the user's preferred currency. The curve math stays in USD; + * only display is converted, with the same [Rate] the token screen uses for its balance and appreciation. + */ +class ExplainerCurrency(private val rate: Rate) { + + /** [usd] converted to the preferred currency. */ + fun convert(usd: BigDecimal): Fiat = Fiat(fiat = usd.toDouble() * rate.fx, currencyCode = rate.currency) + + /** A USD [Fiat] converted to the preferred currency. */ + fun convert(usd: Fiat): Fiat = usd.convertingTo(rate) + + /** `$5K`, `$22.7K`, `$1M`: a USD reserve as a compact label in the preferred currency. */ + fun reserve(usd: BigDecimal): String { + var value = convert(usd).decimalValue + var unit = 0 + while (unit < UNITS.lastIndex && trim(value).abs() >= THOUSAND) { + value /= 1_000.0 + unit++ + } + return symbol() + trim(value).toPlainString() + UNITS[unit] + } + + /** A USD per-token price in the preferred currency, to four significant figures: `$0.02994`, `$8.78`. */ + fun price(usd: BigDecimal): 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())) + } + + private fun symbol() = rate.currency.singleCharacterCurrencySymbol.orEmpty() + + private fun trim(v: Double): BigDecimal = + BigDecimal(v).setScale(1, RoundingMode.HALF_UP).stripTrailingZeros() + + private companion object { + val UNITS = listOf("", "K", "M", "B", "T") + val THOUSAND: BigDecimal = BigDecimal(1_000) + } +} + +object ExplainerFormat { + private val MIN_SHOWN_PERCENT = BigDecimal("0.01") + + /** A share as a percentage to two decimals, or `<0.01%` for a positive share below that. */ + 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() + "%" + } +} + +/** A tick label measured in pixels: its [centre] on the track and its text [width]. */ +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. + */ +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/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..297d5ce20d --- /dev/null +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/MarketCapExplainerViewModel.kt @@ -0,0 +1,109 @@ +package com.flipcash.app.tokens.ui + +import androidx.lifecycle.viewModelScope +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.app.tokens.marketcap.MarketCapExplainerCalculator +import com.flipcash.libs.coroutines.DispatcherProvider +import com.getcode.opencode.exchange.Exchange +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 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 calculator: MarketCapExplainerCalculator? = 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, + ) + + sealed interface Event { + data class OnMintProvided(val mint: Mint) : Event + data class OnLoaded(val token: Token, val calculator: MarketCapExplainerCalculator) : 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, + calculator = MarketCapExplainerCalculator(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, calculator = event.calculator) + } + is Event.OnAppreciationUpdated -> { state -> state.copy(appreciation = event.appreciation) } + is Event.OnRateUpdated -> { state -> state.copy(rate = event.rate) } + } + } + } +} diff --git a/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/marketcap/MarketCapExplainerCalculatorTest.kt b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/marketcap/MarketCapExplainerCalculatorTest.kt new file mode 100644 index 0000000000..b510ce876f --- /dev/null +++ b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/marketcap/MarketCapExplainerCalculatorTest.kt @@ -0,0 +1,329 @@ +package com.flipcash.app.tokens.marketcap + +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 MarketCapExplainerCalculatorTest { + + 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 calculator(held: Long? = HELD_QUARKS, supply: Long = SUPPLY_QUARKS) = + MarketCapExplainerCalculator(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 = calculator() + 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") + assertEquals("$0.02994", ExplainerCurrency(Rate.oneToOne).price(today.price)) + assertEquals("$22.7K", ExplainerCurrency(Rate.oneToOne).reserve(calc.todayReserve)) + } + + @Test + fun `fixed tick vectors`() { + val calc = calculator() + // 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 = calculator(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 = calculator().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 (%)") + assertEquals("0.99%", ExplainerFormat.percent(ownership.shareOfCirculating!!)) + } + + @Test + fun `track spans five thousand to ten million and today sits on it`() { + val calc = calculator() + 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 = calculator(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 = calculator().snapshotAtReserve(BigDecimal(1_000_000)).supplyQuarks + val calc = calculator(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 = calculator() + 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 = calculator() + 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 = calculator(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 `display converts to the preferred currency while the curve stays in usd`() { + val euro = ExplainerCurrency(Rate(fx = 0.5, currency = CurrencyCode.EUR)) + // Reserve labels: 5,000 USD is 2,500 EUR; 10M USD is 5M EUR. + assertEquals("\u20ac2.5K", euro.reserve(BigDecimal(5_000))) + assertEquals("\u20ac50K", euro.reserve(BigDecimal(100_000))) + assertEquals("\u20ac500K", euro.reserve(BigDecimal(1_000_000))) + assertEquals("\u20ac5M", euro.reserve(BigDecimal(10_000_000))) + // Per-token price: 0.02994 USD is 0.01497 EUR, kept to four significant figures. + assertEquals("\u20ac0.01497", euro.price(BigDecimal("0.02994"))) + // Worth: 369.18 USD is 184.59 EUR. + assertEquals(CurrencyCode.EUR, euro.convert(Fiat(369.18)).currencyCode) + assertEquals("\u20ac184.59", euro.convert(Fiat(369.18)).formatted()) + + // The slider stops are USD reserves regardless of the rate. + val usd = MarketCapExplainerCalculator.FIXED_RESERVES + assertEquals(listOf(5_000, 100_000, 1_000_000, 10_000_000).map { BigDecimal(it) }, usd) + } + + @Test + fun `a zero-decimal currency keeps whole units and rolls over compact suffixes`() { + val yen = ExplainerCurrency(Rate(fx = 150.0, currency = CurrencyCode.JPY)) + assertEquals("\u00a5750K", yen.reserve(BigDecimal(5_000))) + assertEquals("\u00a51.5B", yen.reserve(BigDecimal(10_000_000))) + // 999,960 rounds to 1000K, which must read 1M. + assertEquals("\u00a51M", ExplainerCurrency(Rate.oneToOne.copy(currency = CurrencyCode.JPY)).reserve(BigDecimal(999_960))) + assertEquals("\u00a54.491", yen.price(BigDecimal("0.02994"))) + } + + @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 = calculator(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 = calculator().chart(calculator().today()) + assertEquals(1f, chart.xFraction(chart.xMax * 3)) + assertEquals(0f, chart.xFraction(-5.0)) + } + + @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), java.math.MathContext(20)) + assertEquals("<0.01%", ExplainerFormat.percent(share)) + assertEquals("<0.01%", ExplainerFormat.percent(BigDecimal("0.0099"))) + assertEquals("0.01%", ExplainerFormat.percent(BigDecimal("0.01"))) + assertEquals("0%", ExplainerFormat.percent(BigDecimal.ZERO)) + } + + @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 + } + + @Test + fun `marker x fraction rises with the slider position`() { + for (supply in listOf(30_000L, 1_250_000L, 12_000_000L)) { + val calc = calculator(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): MarketCapExplainerCalculator { + val quarks = calculator().snapshotAtReserve(BigDecimal(reserve)).supplyQuarks + return calculator(supply = quarks) + } + + private fun stopsOf(calc: MarketCapExplainerCalculator) = 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(calculator())) + } + + @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(), MarketCapExplainerCalculator.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) + } + } +} From d34f37d7c76348e05ef7e1005243fcb232d6419a Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 1 Oct 2026 20:27:46 -0400 Subject: [PATCH 4/8] test(tokens): cover the market cap explainer number roll and view model --- .../internal/explainer/ExplainerNumberText.kt | 36 +++- .../explainer/MarketCapExplainerContent.kt | 15 +- .../explainer/ExplainerNumberTextTest.kt | 118 ++++++++++++ .../ui/MarketCapExplainerViewModelTest.kt | 171 ++++++++++++++++++ 4 files changed, 325 insertions(+), 15 deletions(-) create mode 100644 apps/flipcash/features/tokens/src/test/kotlin/com/flipcash/app/tokens/internal/explainer/ExplainerNumberTextTest.kt create mode 100644 apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/MarketCapExplainerViewModelTest.kt diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/explainer/ExplainerNumberText.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/explainer/ExplainerNumberText.kt index 6415b8a18c..13a325db15 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/explainer/ExplainerNumberText.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/explainer/ExplainerNumberText.kt @@ -235,7 +235,7 @@ private fun NumberSlot( .graphicsLayer { val p = progress.value val d = startOffset.floatValue + (-dir.floatValue - startOffset.floatValue) * p - applyGlyphMotion(d, 1f - (p / 0.6f).coerceIn(0f, 1f), p, presence.value) + applyGlyphMotion(d, outgoingFade(p), p, presence.value) }, text = outDigit.intValue.toString(), style = style, color = color, maxLines = 1, softWrap = false, textAlign = TextAlign.Center, @@ -247,7 +247,7 @@ private fun NumberSlot( .graphicsLayer { val p = progress.value val d = dir.floatValue * (1f - p) - applyGlyphMotion(d, ((p - 0.4f) / 0.6f).coerceIn(0f, 1f), p, presence.value) + applyGlyphMotion(d, incomingFade(p), p, presence.value) }, text = inDigit.intValue.toString(), style = style, color = color, maxLines = 1, softWrap = false, textAlign = TextAlign.Center, @@ -269,12 +269,10 @@ private fun NumberSlot( * `RenderEffect` is ignored, leaving the short travel and the alpha crossfade. */ private fun androidx.compose.ui.graphics.GraphicsLayerScope.applyGlyphMotion(d: Float, fade: Float, p: Float, presence: Float) { - translationY = d * MaxTravel * size.height - alpha = fade * presence - // 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 = kotlin.math.sin(Math.PI * p.coerceIn(0f, 1f)).toFloat() - val radius = 11.dp.toPx() * maxOf(arc, abs(d).coerceIn(0f, 1f)) + val motion = glyphMotion(d, fade, p, presence) + translationY = motion.travel * size.height + alpha = motion.alpha + val radius = 11.dp.toPx() * motion.blur renderEffect = if (radius > 0.5f) { androidx.compose.ui.graphics.BlurEffect(radius, radius, androidx.compose.ui.graphics.TileMode.Decal) } else { @@ -282,7 +280,27 @@ private fun androidx.compose.ui.graphics.GraphicsLayerScope.applyGlyphMotion(d: } } -private const val MaxTravel = 0.3f +/** 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) + +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 = kotlin.math.sin(Math.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 = 0.6. */ +internal fun outgoingFade(p: Float): Float = 1f - (p / 0.6f).coerceIn(0f, 1f) + +/** 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 private class CharHolder(var value: Char) 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 index ec1b98ce04..4e4961451e 100644 --- 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 @@ -38,6 +38,7 @@ import com.flipcash.app.theme.FlipcashThemeWrapper import com.flipcash.app.tokens.marketcap.ExplainerChart import com.flipcash.app.tokens.marketcap.ExplainerChartPoint import com.flipcash.app.tokens.marketcap.ExplainerCurrency +import com.getcode.opencode.model.financial.Fiat import com.flipcash.app.tokens.marketcap.ExplainerFormat import com.flipcash.app.tokens.marketcap.ExplainerTick import com.flipcash.app.tokens.ui.MarketCapExplainerViewModel @@ -62,6 +63,13 @@ internal data class ExplainerUiModel( 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 = "–" @@ -94,12 +102,7 @@ internal fun MarketCapExplainerContent(state: MarketCapExplainerViewModel.State) val scrubLabel = if (snapshot.isToday) stringResource(R.string.label_marketCapScrubToday, reserveText) else reserveText val appreciation = state.appreciation val appreciationText = appreciation?.formattedAppreciation() ?: Dash - val appreciationTone = when { - appreciation == null || !appreciation.valueNonZero() -> Tone.Neutral - appreciation.toDouble() > 0 -> Tone.Positive - appreciation.toDouble() < 0 -> Tone.Negative - else -> Tone.Neutral - } + val appreciationTone = appreciationTone(appreciation) val model = ExplainerUiModel( tokenName = token.name, diff --git a/apps/flipcash/features/tokens/src/test/kotlin/com/flipcash/app/tokens/internal/explainer/ExplainerNumberTextTest.kt b/apps/flipcash/features/tokens/src/test/kotlin/com/flipcash/app/tokens/internal/explainer/ExplainerNumberTextTest.kt new file mode 100644 index 0000000000..235ea2c693 --- /dev/null +++ b/apps/flipcash/features/tokens/src/test/kotlin/com/flipcash/app/tokens/internal/explainer/ExplainerNumberTextTest.kt @@ -0,0 +1,118 @@ +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 +import kotlin.test.assertTrue + +class ExplainerNumberTextTest { + + @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 `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/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..4cf305f957 --- /dev/null +++ b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/MarketCapExplainerViewModelTest.kt @@ -0,0 +1,171 @@ +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 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 calculator 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 calculator = assertNotNull(state.calculator) + assertEquals(true, calculator.today().isToday) + } + + @Test + fun `a token that is not cached leaves the calculator null`() = + runTest(mainCoroutineRule.dispatcher) { + val vm = newVm(cache = emptyMap()) + vm.dispatchEvent(MarketCapExplainerViewModel.Event.OnMintProvided(mint)) + advanceUntilIdle() + + assertNull(vm.stateFlow.value.calculator) + } + + @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) + } +} From 4284dbda8b9cc1b597f19e417b7c6be0deb3d942 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 1 Oct 2026 20:53:05 -0400 Subject: [PATCH 5/8] refactor(tokens): use theme colors in the market cap explainer Replace ExplainerGreen with CodeTheme.colors.success (the token the existing price chart uses) and successText for positive appreciation. The icon disc and slider inactive track use surfaceVariant, and the slider's glass thumb tint uses toggleUncheckedTrackColor. The curve icon drawable is tinted from code. --- .../core/src/main/res/drawable/ic_market_cap_curve.xml | 4 ++-- .../internal/components/info/MarketCapExplainerRow.kt | 5 +++-- .../app/tokens/internal/explainer/ExplainerColors.kt | 9 --------- .../internal/explainer/MarketCapExplainerChart.kt | 2 +- .../internal/explainer/MarketCapExplainerContent.kt | 2 +- .../internal/explainer/MarketCapExplainerSlider.kt | 10 ++++------ 6 files changed, 11 insertions(+), 21 deletions(-) delete mode 100644 apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/explainer/ExplainerColors.kt 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 index beb8c83692..6e42426103 100644 --- 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 @@ -5,10 +5,10 @@ android:viewportHeight="22"> + android:fillColor="#FFFFFF" /> 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 index b99952d29a..f6746a58f1 100644 --- 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 @@ -19,11 +19,11 @@ 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.app.tokens.internal.explainer.ExplainerRowIconBackground import com.flipcash.core.R import com.getcode.theme.CodeTheme import com.getcode.theme.extraSmall @@ -46,12 +46,13 @@ internal fun MarketCapExplainerRow( verticalAlignment = Alignment.CenterVertically, ) { Box( - modifier = Modifier.size(40.dp).background(ExplainerRowIconBackground, CircleShape), + 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, ) } diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/explainer/ExplainerColors.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/explainer/ExplainerColors.kt deleted file mode 100644 index c021b6aac3..0000000000 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/explainer/ExplainerColors.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.flipcash.app.tokens.internal.explainer - -import androidx.compose.ui.graphics.Color - -/** The one green on the explainer card: the slider fill (design node 10700:178) and the chart line and area. */ -internal val ExplainerGreen = Color(0xFF009F50) - -/** The row's icon disc (design node 10761:1879, filled-20); the theme has no token for this value. */ -internal val ExplainerRowIconBackground = Color(0xFF474748) 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 index 0d199d918f..034f118b8f 100644 --- 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 @@ -34,7 +34,7 @@ internal fun MarketCapExplainerChart( description: String, modifier: Modifier = Modifier, ) { - val accent = ExplainerGreen + 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 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 index 4e4961451e..1ba8c0663f 100644 --- 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 @@ -232,7 +232,7 @@ internal fun MarketCapExplainerBody( text = value, style = CodeTheme.typography.textMedium, color = when (tone) { - Tone.Positive -> ExplainerGreen + Tone.Positive -> CodeTheme.colors.successText Tone.Negative -> CodeTheme.colors.errorText Tone.Neutral -> CodeTheme.colors.textMain }, 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 index 212a77a88d..657b5ae22e 100644 --- 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 @@ -88,9 +88,9 @@ internal fun MarketCapExplainerSlider( val density = LocalDensity.current val interactionSource = remember { MutableInteractionSource() } val colors = SliderDefaults.colors( - thumbColor = ExplainerGreen, - activeTrackColor = ExplainerGreen, - inactiveTrackColor = TrackColor, + 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. @@ -128,7 +128,7 @@ internal fun MarketCapExplainerSlider( } .shadow((4f * (1f - glass)).dp, CircleShape) .background( - lerp(Color.White, Color(0xFF5A5A5C).copy(alpha = 0.45f), glass), + lerp(Color.White, CodeTheme.colors.toggleUncheckedTrackColor.copy(alpha = 0.45f), glass), CircleShape, ) .border(1.dp, Color.White.copy(alpha = 0.28f * glass), CircleShape), @@ -203,8 +203,6 @@ internal fun MarketCapExplainerSlider( } } -// Slider spec colour (design node 10700:177 track); the fill is [ExplainerGreen]. -private val TrackColor = Color(0xFF474748) @Preview @Composable From aebef374ad71a146fd95853ddcd155b67ef19e3e Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 1 Oct 2026 21:13:20 -0400 Subject: [PATCH 6/8] refactor(ui): roll AnimatedNumberText digits like SwiftUI numericText AnimatedNumberText now uses the market cap explainer's roll, and the explainer calls it directly. ExplainerNumberText is removed. Slots are keyed by role (prefix, integer digit from the point, point, fraction digit, suffix), so a digit keeps its slot when the string grows or shrinks. Each digit is a spring that is retargeted mid-roll with its current offset and velocity, travels at most 0.3 of the glyph height, and blurs up to 11dp on API 31+. Below 31 it fades only. Digits take the widest digit's width; AutoSize sizes the string with every digit set to that width so fixed-width digits do not overflow. The roller still mounts only after the first change, seeded with the value on screen. The outgoing glyph leaves the tree once it has faded, and an emptied slot leaves it once its width reaches zero, so a settled number is one node per character. The pure motion helpers move to NumberRollMotion with their tests. --- .../internal/explainer/ExplainerNumberText.kt | 323 ------------- .../explainer/MarketCapExplainerContent.kt | 5 +- .../internal/explainer/ExplainerToneTest.kt | 16 + .../ui/components/text/AnimatedNumberText.kt | 424 ++++++++++++++---- .../ui/components/text/NumberRollMotion.kt | 77 ++++ .../components/text/AnimatedNumberTextTest.kt | 8 +- .../components/text/NumberRollMotionTest.kt | 14 +- 7 files changed, 431 insertions(+), 436 deletions(-) delete mode 100644 apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/explainer/ExplainerNumberText.kt create mode 100644 apps/flipcash/features/tokens/src/test/kotlin/com/flipcash/app/tokens/internal/explainer/ExplainerToneTest.kt create mode 100644 ui/components/src/main/kotlin/com/getcode/ui/components/text/NumberRollMotion.kt rename apps/flipcash/features/tokens/src/test/kotlin/com/flipcash/app/tokens/internal/explainer/ExplainerNumberTextTest.kt => ui/components/src/test/kotlin/com/getcode/ui/components/text/NumberRollMotionTest.kt (86%) diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/explainer/ExplainerNumberText.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/explainer/ExplainerNumberText.kt deleted file mode 100644 index 13a325db15..0000000000 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/explainer/ExplainerNumberText.kt +++ /dev/null @@ -1,323 +0,0 @@ -package com.flipcash.app.tokens.internal.explainer - -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.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.width -import androidx.compose.material.LocalTextStyle -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.key -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.layout.layout -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.rememberTextMeasurer -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.Constraints -import androidx.compose.ui.unit.dp -import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.graphics.drawscope.clipRect -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableIntStateOf -import kotlin.math.abs -import kotlin.math.roundToInt - -/** - * A readout whose digits roll like SwiftUI's `.contentTransition(.numericText())`. - * - * Characters are slots keyed by role (integer digit from the point, fraction digit, suffix), so - * a longer or shorter string keeps each digit in its own slot. Each digit slot is an odometer: its position is an [Animatable] that is retargeted (keeping - * its velocity) whenever the digit changes, and it draws the current and the next digit offset by - * the fractional position with a fade. During a continuous drag the digits therefore glide instead - * of restarting a transition on every value. Digit slots share the widest digit's width; symbols and - * suffixes crossfade only when they change; a slot appearing or disappearing animates its width. - * - * Local to the explainer so [com.getcode.ui.components.text.AnimatedNumberText] keeps its behaviour - * for the other screens that use it. - */ -@Composable -internal fun ExplainerNumberText( - value: String, - modifier: Modifier = Modifier, - style: TextStyle = LocalTextStyle.current, - color: Color = Color.Unspecified, -) { - val measurer = rememberTextMeasurer() - val digitWidth = remember(style) { - ('0'..'9').maxOf { measurer.measure(it.toString(), style).size.width } - } - - val holder = remember { DirectionHolder(value) } - if (holder.last != value) { - holder.rising = numericValue(value) >= numericValue(holder.last) - holder.last = value - } - val rising = holder.rising - - // Slots are keyed by role (prefix, integer digit from the point, decimal, fraction digit, suffix) - // rather than by position in the string, so $170K -> $27.0K keeps each digit in its own slot. 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(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { - for (slotKey in layout.keys()) { - key(slotKey) { - NumberSlot( - char = chars[slotKey], - rising = rising, - digitWidthPx = digitWidth, - style = style, - color = color, - ) - } - } - } -} - -/** Splits a readout into role-keyed characters: `P0..` prefix, `L0..` integer digits from the point, `dot`, `R0..` fraction, `S0..` suffix. */ -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 -} - -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) - } - } - } - - 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 (shownChar.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 { OdometerTrack(if (shownChar.isDigit()) shownChar.digitToInt() else -1, 0f) } - 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 - } - - 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 - } - - Box( - modifier = Modifier.layout { measurable, _ -> - val p = measurable.measure(Constraints()) - val w = if (isDigit) digitWidthPx else p.width - layout((w * presence.value).roundToInt(), p.height) { - p.place(((w * presence.value).roundToInt() - p.width) / 2, 0) - } - }.drawWithContent { - clipRect(left = 0f, top = 0f, right = size.width, bottom = size.height) { - this@drawWithContent.drawContent() - } - }, - contentAlignment = Alignment.Center, - ) { - if (isDigit) { - val glyphWidth = with(LocalDensity.current) { digitWidthPx.toDp() } - // Outgoing glyph. - Text( - modifier = Modifier - .width(glyphWidth) - .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, textAlign = TextAlign.Center, - ) - // Incoming glyph. - Text( - modifier = Modifier - .width(glyphWidth) - .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, textAlign = TextAlign.Center, - ) - } else { - Text( - modifier = Modifier.graphicsLayer { alpha = fade.value * presence.value }, - text = shownChar.toString(), style = style, color = color, - maxLines = 1, softWrap = false, - ) - } - } -} - -/** - * numericText look: the 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 and [p] the transition progress that drives the blur. Below API 31 - * `RenderEffect` is ignored, leaving the short travel and the alpha crossfade. - */ -private fun androidx.compose.ui.graphics.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 - val radius = 11.dp.toPx() * motion.blur - renderEffect = if (radius > 0.5f) { - androidx.compose.ui.graphics.BlurEffect(radius, radius, androidx.compose.ui.graphics.TileMode.Decal) - } else { - null - } -} - -/** 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) - -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 = kotlin.math.sin(Math.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 = 0.6. */ -internal fun outgoingFade(p: Float): Float = 1f - (p / 0.6f).coerceIn(0f, 1f) - -/** 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 - -private class CharHolder(var value: Char) - -private class OdometerTrack(var digit: Int, var logical: Float, var symbol: Char? = null) - -private class DirectionHolder(var last: String, var rising: Boolean = true) - - -/** The number a formatted readout stands for, enough to tell whether it went up: `$22.7K` is 22,700. */ -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 - } - return number * scale -} 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 index 1ba8c0663f..ce820378f6 100644 --- 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 @@ -46,6 +46,7 @@ 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. */ @@ -174,7 +175,7 @@ internal fun MarketCapExplainerBody( style = CodeTheme.typography.textSmall, color = CodeTheme.colors.textSecondary, ) - ExplainerNumberText( + AnimatedNumberText( modifier = Modifier.padding(top = grid.x1), value = model.reserveText, style = CodeTheme.typography.displayCompact, @@ -202,7 +203,7 @@ internal fun MarketCapExplainerBody( style = CodeTheme.typography.textSmall, color = CodeTheme.colors.textSecondary, ) - ExplainerNumberText( + AnimatedNumberText( modifier = Modifier.padding(top = grid.x1), value = model.worthText, style = CodeTheme.typography.displayMedium.bolded(), 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/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/apps/flipcash/features/tokens/src/test/kotlin/com/flipcash/app/tokens/internal/explainer/ExplainerNumberTextTest.kt b/ui/components/src/test/kotlin/com/getcode/ui/components/text/NumberRollMotionTest.kt similarity index 86% rename from apps/flipcash/features/tokens/src/test/kotlin/com/flipcash/app/tokens/internal/explainer/ExplainerNumberTextTest.kt rename to ui/components/src/test/kotlin/com/getcode/ui/components/text/NumberRollMotionTest.kt index 235ea2c693..b0a0e82f2a 100644 --- a/apps/flipcash/features/tokens/src/test/kotlin/com/flipcash/app/tokens/internal/explainer/ExplainerNumberTextTest.kt +++ b/ui/components/src/test/kotlin/com/getcode/ui/components/text/NumberRollMotionTest.kt @@ -1,13 +1,10 @@ -package com.flipcash.app.tokens.internal.explainer +package com.getcode.ui.components.text -import com.getcode.opencode.model.financial.CurrencyCode -import com.getcode.opencode.model.financial.Fiat import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue -class ExplainerNumberTextTest { - +class NumberRollMotionTest { @Test fun `slots are keyed by role with integer digits counted from the point`() { assertEquals( @@ -109,10 +106,7 @@ class ExplainerNumberTextTest { } @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)) + fun `a negative readout is below a less negative one`() { + assertTrue(numericValue("-\$41.67") > numericValue("-\$44.02")) } } From ef1713a6949877d343636d40642d2c30402b435b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 1 Oct 2026 21:15:30 -0400 Subject: [PATCH 7/8] refactor(tokens): rename the market cap explainer calculator to BondingCurveProjection MarketCapExplainerCalculator moves to com.flipcash.app.tokens.bondingcurve as BondingCurveProjection, and the view model's state field becomes projection. The view model stays in shared. ExplainerCurrency, ExplainerFormat, ExplainerLabelBox and layoutExplainerLabels are only used by the explainer UI, so they move to the feature's internal/explainer package as ExplainerFormatting. Their tests move with them to ExplainerFormattingTest; the formatted assertions that ran on calculator output now use the same literal values, and the projection test keeps the raw-number checks. --- .../internal/explainer/ExplainerFormatting.kt | 89 +++++++++++++++ .../explainer/MarketCapExplainerChart.kt | 2 +- .../explainer/MarketCapExplainerContent.kt | 26 ++--- .../explainer/MarketCapExplainerSlider.kt | 4 +- .../explainer/ExplainerFormattingTest.kt | 71 ++++++++++++ .../BondingCurveProjection.kt} | 88 +-------------- .../tokens/ui/MarketCapExplainerViewModel.kt | 10 +- .../BondingCurveProjectionTest.kt} | 104 +++++------------- .../ui/MarketCapExplainerViewModelTest.kt | 10 +- 9 files changed, 213 insertions(+), 191 deletions(-) create mode 100644 apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/explainer/ExplainerFormatting.kt create mode 100644 apps/flipcash/features/tokens/src/test/kotlin/com/flipcash/app/tokens/internal/explainer/ExplainerFormattingTest.kt rename apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/{marketcap/MarketCapExplainerCalculator.kt => bondingcurve/BondingCurveProjection.kt} (73%) rename apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/{marketcap/MarketCapExplainerCalculatorTest.kt => bondingcurve/BondingCurveProjectionTest.kt} (69%) diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/explainer/ExplainerFormatting.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/explainer/ExplainerFormatting.kt new file mode 100644 index 0000000000..1e484be064 --- /dev/null +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/explainer/ExplainerFormatting.kt @@ -0,0 +1,89 @@ +package com.flipcash.app.tokens.internal.explainer + +import com.getcode.opencode.internal.extensions.fractionDigits +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.Rate +import java.math.BigDecimal +import java.math.MathContext +import java.math.RoundingMode +import kotlin.math.max + +/** + * Shows the explainer's dollar figures in the user's preferred currency. The curve math stays in USD; + * only display is converted, with the same [Rate] the token screen uses for its balance and appreciation. + */ +internal class ExplainerCurrency(private val rate: Rate) { + + /** [usd] converted to the preferred currency. */ + fun convert(usd: BigDecimal): Fiat = Fiat(fiat = usd.toDouble() * rate.fx, currencyCode = rate.currency) + + /** A USD [Fiat] converted to the preferred currency. */ + fun convert(usd: Fiat): Fiat = usd.convertingTo(rate) + + /** `$5K`, `$22.7K`, `$1M`: a USD reserve as a compact label in the preferred currency. */ + fun reserve(usd: BigDecimal): String { + var value = convert(usd).decimalValue + var unit = 0 + while (unit < UNITS.lastIndex && trim(value).abs() >= THOUSAND) { + value /= 1_000.0 + unit++ + } + return symbol() + trim(value).toPlainString() + UNITS[unit] + } + + /** A USD per-token price in the preferred currency, to four significant figures: `$0.02994`, `$8.78`. */ + fun price(usd: BigDecimal): 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())) + } + + private fun symbol() = rate.currency.singleCharacterCurrencySymbol.orEmpty() + + private fun trim(v: Double): BigDecimal = + BigDecimal(v).setScale(1, RoundingMode.HALF_UP).stripTrailingZeros() + + private companion object { + val UNITS = listOf("", "K", "M", "B", "T") + val THOUSAND: BigDecimal = BigDecimal(1_000) + } +} + +internal object ExplainerFormat { + private val MIN_SHOWN_PERCENT = BigDecimal("0.01") + + /** A share as a percentage to two decimals, or `<0.01%` for a positive share below that. */ + 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() + "%" + } +} + +/** 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 index 034f118b8f..f5dbc3e1ef 100644 --- 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 @@ -19,7 +19,7 @@ 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.marketcap.ExplainerChart +import com.flipcash.app.tokens.bondingcurve.ExplainerChart import com.getcode.theme.CodeTheme /** 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 index ce820378f6..5dbbb35d0e 100644 --- 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 @@ -35,12 +35,10 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.flipcash.app.core.money.formattedAppreciation import com.flipcash.app.theme.FlipcashThemeWrapper -import com.flipcash.app.tokens.marketcap.ExplainerChart -import com.flipcash.app.tokens.marketcap.ExplainerChartPoint -import com.flipcash.app.tokens.marketcap.ExplainerCurrency +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.marketcap.ExplainerFormat -import com.flipcash.app.tokens.marketcap.ExplainerTick +import com.flipcash.app.tokens.bondingcurve.ExplainerTick import com.flipcash.app.tokens.ui.MarketCapExplainerViewModel import com.flipcash.core.R import com.getcode.theme.CodeTheme @@ -77,13 +75,13 @@ private const val Dash = "–" @Composable internal fun MarketCapExplainerContent(state: MarketCapExplainerViewModel.State) { - val calculator = state.calculator + val projection = state.projection val token = state.token - if (calculator == null || token == null) return + 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 = calculator.todayPosition.toFloat() + val today = projection.todayPosition.toFloat() val scope = rememberCoroutineScope() var dragged by remember { mutableStateOf(null) } val settle = remember(today) { Animatable(today) } @@ -91,11 +89,11 @@ internal fun MarketCapExplainerContent(state: MarketCapExplainerViewModel.State) // 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(calculator, position) { - derivedStateOf { calculator.snapshotAt(position.toDouble()) } + val snapshot by remember(projection, position) { + derivedStateOf { projection.snapshotAt(position.toDouble()) } } - val chart by remember(calculator, snapshot) { derivedStateOf { calculator.chart(snapshot) } } - val ownership = remember(calculator) { calculator.ownership() } + val chart by remember(projection, snapshot) { derivedStateOf { projection.chart(snapshot) } } + val ownership = remember(projection) { projection.ownership() } // The curve stays in USD; only what is drawn is converted to the preferred currency. val currency = remember(state.rate) { ExplainerCurrency(state.rate) } @@ -108,8 +106,8 @@ internal fun MarketCapExplainerContent(state: MarketCapExplainerViewModel.State) val model = ExplainerUiModel( tokenName = token.name, position = position, - ticks = calculator.ticks, - tickLabels = calculator.ticks.associateWith { currency.reserve(it.reserve) }, + ticks = projection.ticks, + tickLabels = projection.ticks.associateWith { currency.reserve(it.reserve) }, reserveText = reserveText, worthText = snapshot.worth?.let { currency.convert(it).formatted() } ?: Dash, chart = chart, 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 index 657b5ae22e..6385fcdc26 100644 --- 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 @@ -40,13 +40,11 @@ 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 com.flipcash.app.tokens.marketcap.ExplainerLabelBox -import com.flipcash.app.tokens.marketcap.layoutExplainerLabels 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.marketcap.ExplainerTick +import com.flipcash.app.tokens.bondingcurve.ExplainerTick import com.flipcash.app.theme.FlipcashPreview import com.flipcash.core.R import com.getcode.theme.CodeTheme diff --git a/apps/flipcash/features/tokens/src/test/kotlin/com/flipcash/app/tokens/internal/explainer/ExplainerFormattingTest.kt b/apps/flipcash/features/tokens/src/test/kotlin/com/flipcash/app/tokens/internal/explainer/ExplainerFormattingTest.kt new file mode 100644 index 0000000000..4a02832373 --- /dev/null +++ b/apps/flipcash/features/tokens/src/test/kotlin/com/flipcash/app/tokens/internal/explainer/ExplainerFormattingTest.kt @@ -0,0 +1,71 @@ +package com.flipcash.app.tokens.internal.explainer + +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.Rate +import java.math.BigDecimal +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class ExplainerFormattingTest { + + @Test + fun `today vector reads in usd`() { + assertEquals("$0.02994", ExplainerCurrency(Rate.oneToOne).price(BigDecimal("0.02994"))) + assertEquals("$22.7K", ExplainerCurrency(Rate.oneToOne).reserve(BigDecimal(22_700))) + assertEquals("0.99%", ExplainerFormat.percent(BigDecimal("0.992"))) + } + + @Test + fun `display converts to the preferred currency while the curve stays in usd`() { + val euro = ExplainerCurrency(Rate(fx = 0.5, currency = CurrencyCode.EUR)) + // Reserve labels: 5,000 USD is 2,500 EUR; 10M USD is 5M EUR. + assertEquals("\u20ac2.5K", euro.reserve(BigDecimal(5_000))) + assertEquals("\u20ac50K", euro.reserve(BigDecimal(100_000))) + assertEquals("\u20ac500K", euro.reserve(BigDecimal(1_000_000))) + assertEquals("\u20ac5M", euro.reserve(BigDecimal(10_000_000))) + // Per-token price: 0.02994 USD is 0.01497 EUR, kept to four significant figures. + assertEquals("\u20ac0.01497", euro.price(BigDecimal("0.02994"))) + // Worth: 369.18 USD is 184.59 EUR. + assertEquals(CurrencyCode.EUR, euro.convert(Fiat(369.18)).currencyCode) + assertEquals("\u20ac184.59", euro.convert(Fiat(369.18)).formatted()) + + } + + @Test + fun `a zero-decimal currency keeps whole units and rolls over compact suffixes`() { + val yen = ExplainerCurrency(Rate(fx = 150.0, currency = CurrencyCode.JPY)) + assertEquals("\u00a5750K", yen.reserve(BigDecimal(5_000))) + assertEquals("\u00a51.5B", yen.reserve(BigDecimal(10_000_000))) + // 999,960 rounds to 1000K, which must read 1M. + assertEquals("\u00a51M", ExplainerCurrency(Rate.oneToOne.copy(currency = CurrencyCode.JPY)).reserve(BigDecimal(999_960))) + assertEquals("\u00a54.491", yen.price(BigDecimal("0.02994"))) + } + + @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), java.math.MathContext(20)) + assertEquals("<0.01%", ExplainerFormat.percent(share)) + assertEquals("<0.01%", ExplainerFormat.percent(BigDecimal("0.0099"))) + assertEquals("0.01%", ExplainerFormat.percent(BigDecimal("0.01"))) + assertEquals("0%", ExplainerFormat.percent(BigDecimal.ZERO)) + } + + @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/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/marketcap/MarketCapExplainerCalculator.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/bondingcurve/BondingCurveProjection.kt similarity index 73% rename from apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/marketcap/MarketCapExplainerCalculator.kt rename to apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/bondingcurve/BondingCurveProjection.kt index 359a5547db..efca27d050 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/marketcap/MarketCapExplainerCalculator.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/bondingcurve/BondingCurveProjection.kt @@ -1,9 +1,7 @@ -package com.flipcash.app.tokens.marketcap +package com.flipcash.app.tokens.bondingcurve import com.flipcash.libs.currency.math.Estimator -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 java.math.BigDecimal import java.math.MathContext @@ -19,7 +17,7 @@ import kotlin.math.min * * @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 [MarketCapExplainerCalculator.HIDE_LABEL_WITHIN] + * @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( @@ -83,7 +81,7 @@ data class ExplainerOwnership( * @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 MarketCapExplainerCalculator( +class BondingCurveProjection( private val token: Token, private val todaySupplyQuarks: Long, private val heldQuarks: Long?, @@ -252,83 +250,3 @@ class MarketCapExplainerCalculator( private val MC = MathContext(20) } } - -/** - * Shows the explainer's dollar figures in the user's preferred currency. The curve math stays in USD; - * only display is converted, with the same [Rate] the token screen uses for its balance and appreciation. - */ -class ExplainerCurrency(private val rate: Rate) { - - /** [usd] converted to the preferred currency. */ - fun convert(usd: BigDecimal): Fiat = Fiat(fiat = usd.toDouble() * rate.fx, currencyCode = rate.currency) - - /** A USD [Fiat] converted to the preferred currency. */ - fun convert(usd: Fiat): Fiat = usd.convertingTo(rate) - - /** `$5K`, `$22.7K`, `$1M`: a USD reserve as a compact label in the preferred currency. */ - fun reserve(usd: BigDecimal): String { - var value = convert(usd).decimalValue - var unit = 0 - while (unit < UNITS.lastIndex && trim(value).abs() >= THOUSAND) { - value /= 1_000.0 - unit++ - } - return symbol() + trim(value).toPlainString() + UNITS[unit] - } - - /** A USD per-token price in the preferred currency, to four significant figures: `$0.02994`, `$8.78`. */ - fun price(usd: BigDecimal): 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())) - } - - private fun symbol() = rate.currency.singleCharacterCurrencySymbol.orEmpty() - - private fun trim(v: Double): BigDecimal = - BigDecimal(v).setScale(1, RoundingMode.HALF_UP).stripTrailingZeros() - - private companion object { - val UNITS = listOf("", "K", "M", "B", "T") - val THOUSAND: BigDecimal = BigDecimal(1_000) - } -} - -object ExplainerFormat { - private val MIN_SHOWN_PERCENT = BigDecimal("0.01") - - /** A share as a percentage to two decimals, or `<0.01%` for a positive share below that. */ - 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() + "%" - } -} - -/** A tick label measured in pixels: its [centre] on the track and its text [width]. */ -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. - */ -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/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 index 297d5ce20d..e6babce363 100644 --- 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 @@ -2,7 +2,7 @@ package com.flipcash.app.tokens.ui import androidx.lifecycle.viewModelScope import com.flipcash.app.tokens.TokenCoordinator -import com.flipcash.app.tokens.marketcap.MarketCapExplainerCalculator +import com.flipcash.app.tokens.bondingcurve.BondingCurveProjection import com.flipcash.libs.coroutines.DispatcherProvider import com.getcode.opencode.exchange.Exchange import com.getcode.opencode.model.financial.Fiat @@ -35,7 +35,7 @@ class MarketCapExplainerViewModel @Inject constructor( data class State( val token: Token? = null, /** Null until the token is cached and its supply is known. */ - val calculator: MarketCapExplainerCalculator? = null, + 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. */ @@ -44,7 +44,7 @@ class MarketCapExplainerViewModel @Inject constructor( sealed interface Event { data class OnMintProvided(val mint: Mint) : Event - data class OnLoaded(val token: Token, val calculator: MarketCapExplainerCalculator) : 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 } @@ -67,7 +67,7 @@ class MarketCapExplainerViewModel @Inject constructor( if (token == null || supply == null) return@combine null Event.OnLoaded( token = token, - calculator = MarketCapExplainerCalculator(token, supply, quarks), + projection = BondingCurveProjection(token, supply, quarks), ) } } @@ -99,7 +99,7 @@ class MarketCapExplainerViewModel @Inject constructor( when (event) { is Event.OnMintProvided -> { state -> state } is Event.OnLoaded -> { state -> - state.copy(token = event.token, calculator = event.calculator) + state.copy(token = event.token, projection = event.projection) } is Event.OnAppreciationUpdated -> { state -> state.copy(appreciation = event.appreciation) } is Event.OnRateUpdated -> { state -> state.copy(rate = event.rate) } diff --git a/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/marketcap/MarketCapExplainerCalculatorTest.kt b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/bondingcurve/BondingCurveProjectionTest.kt similarity index 69% rename from apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/marketcap/MarketCapExplainerCalculatorTest.kt rename to apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/bondingcurve/BondingCurveProjectionTest.kt index b510ce876f..841c0cc68a 100644 --- a/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/marketcap/MarketCapExplainerCalculatorTest.kt +++ b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/bondingcurve/BondingCurveProjectionTest.kt @@ -1,4 +1,4 @@ -package com.flipcash.app.tokens.marketcap +package com.flipcash.app.tokens.bondingcurve import com.flipcash.libs.currency.math.CurveTestInitializer import com.getcode.opencode.model.financial.Fiat @@ -23,7 +23,7 @@ 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 MarketCapExplainerCalculatorTest { +class BondingCurveProjectionTest { companion object { @JvmStatic @@ -65,8 +65,8 @@ class MarketCapExplainerCalculatorTest { holderMetrics = HolderMetrics.None, ) - private fun calculator(held: Long? = HELD_QUARKS, supply: Long = SUPPLY_QUARKS) = - MarketCapExplainerCalculator(token(supply), supply, held) + 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)") @@ -75,19 +75,17 @@ class MarketCapExplainerCalculatorTest { @Test fun `today vector`() { - val calc = calculator() + 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") - assertEquals("$0.02994", ExplainerCurrency(Rate.oneToOne).price(today.price)) - assertEquals("$22.7K", ExplainerCurrency(Rate.oneToOne).reserve(calc.todayReserve)) } @Test fun `fixed tick vectors`() { - val calc = calculator() + val calc = projection() // reserve, price, worth of 12,400 tokens, price/worth display tolerance val vectors = listOf( Triple(5_000, 0.0144, 177.0), @@ -105,7 +103,7 @@ class MarketCapExplainerCalculatorTest { @Test fun `unknown holdings leave worth and shares unknown`() { - val calc = calculator(held = null) + val calc = projection(held = null) assertNull(calc.today().worth) val ownership = calc.ownership() assertNull(ownership.tokensHeld) @@ -115,16 +113,15 @@ class MarketCapExplainerCalculatorTest { @Test fun `ownership shares`() { - val ownership = calculator().ownership() + 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 (%)") - assertEquals("0.99%", ExplainerFormat.percent(ownership.shareOfCirculating!!)) } @Test fun `track spans five thousand to ten million and today sits on it`() { - val calc = calculator() + 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) @@ -135,7 +132,7 @@ class MarketCapExplainerCalculatorTest { @Test fun `today beyond the base stops extends the track past it`() { // ~$100M reserve: past the $10M tick - val calc = calculator(supply = 12_000_000L * WHOLE_TOKEN) + 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) @@ -145,8 +142,8 @@ class MarketCapExplainerCalculatorTest { @Test fun `a fixed tick within ten percent of today loses its label`() { // Today at exactly the $1M reserve's supply - val supplyAt1M = calculator().snapshotAtReserve(BigDecimal(1_000_000)).supplyQuarks - val calc = calculator(supply = supplyAt1M) + 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 @@ -156,7 +153,7 @@ class MarketCapExplainerCalculatorTest { @Test fun `snapping near today returns today exactly`() { - val calc = calculator() + val calc = projection() val snap = calc.snapshotAt(calc.todayPosition + 0.005) assertTrue(snap.isToday) assertEquals(SUPPLY_QUARKS, snap.supplyQuarks) @@ -165,7 +162,7 @@ class MarketCapExplainerCalculatorTest { @Test fun `chart spans selected supply plus 750K tokens`() { - val calc = calculator() + val calc = projection() val snap = calc.snapshotAtReserve(BigDecimal(100_000)) val chart = calc.chart(snap) assertEquals( @@ -180,44 +177,21 @@ class MarketCapExplainerCalculatorTest { @Test fun `holding more than the supply at a low reserve is valued as the whole supply`() { - val calc = calculator(held = 2_000_000L * WHOLE_TOKEN) + 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 `display converts to the preferred currency while the curve stays in usd`() { - val euro = ExplainerCurrency(Rate(fx = 0.5, currency = CurrencyCode.EUR)) - // Reserve labels: 5,000 USD is 2,500 EUR; 10M USD is 5M EUR. - assertEquals("\u20ac2.5K", euro.reserve(BigDecimal(5_000))) - assertEquals("\u20ac50K", euro.reserve(BigDecimal(100_000))) - assertEquals("\u20ac500K", euro.reserve(BigDecimal(1_000_000))) - assertEquals("\u20ac5M", euro.reserve(BigDecimal(10_000_000))) - // Per-token price: 0.02994 USD is 0.01497 EUR, kept to four significant figures. - assertEquals("\u20ac0.01497", euro.price(BigDecimal("0.02994"))) - // Worth: 369.18 USD is 184.59 EUR. - assertEquals(CurrencyCode.EUR, euro.convert(Fiat(369.18)).currencyCode) - assertEquals("\u20ac184.59", euro.convert(Fiat(369.18)).formatted()) - - // The slider stops are USD reserves regardless of the rate. - val usd = MarketCapExplainerCalculator.FIXED_RESERVES + 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 `a zero-decimal currency keeps whole units and rolls over compact suffixes`() { - val yen = ExplainerCurrency(Rate(fx = 150.0, currency = CurrencyCode.JPY)) - assertEquals("\u00a5750K", yen.reserve(BigDecimal(5_000))) - assertEquals("\u00a51.5B", yen.reserve(BigDecimal(10_000_000))) - // 999,960 rounds to 1000K, which must read 1M. - assertEquals("\u00a51M", ExplainerCurrency(Rate.oneToOne.copy(currency = CurrencyCode.JPY)).reserve(BigDecimal(999_960))) - assertEquals("\u00a54.491", yen.price(BigDecimal("0.02994"))) - } - @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 = calculator(supply = supply * WHOLE_TOKEN) + val calc = projection(supply = supply * WHOLE_TOKEN) for (i in 0..200) { val position = i / 200.0 val chart = calc.chart(calc.snapshotAt(position)) @@ -236,52 +210,26 @@ class MarketCapExplainerCalculatorTest { @Test fun `x fraction clamps out of range values`() { - val chart = calculator().chart(calculator().today()) + val chart = projection().chart(projection().today()) assertEquals(1f, chart.xFraction(chart.xMax * 3)) assertEquals(0f, chart.xFraction(-5.0)) } - @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), java.math.MathContext(20)) - assertEquals("<0.01%", ExplainerFormat.percent(share)) - assertEquals("<0.01%", ExplainerFormat.percent(BigDecimal("0.0099"))) - assertEquals("0.01%", ExplainerFormat.percent(BigDecimal("0.01"))) - assertEquals("0%", ExplainerFormat.percent(BigDecimal.ZERO)) - } - - @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 - } - @Test fun `marker x fraction rises with the slider position`() { for (supply in listOf(30_000L, 1_250_000L, 12_000_000L)) { - val calc = calculator(supply = supply * WHOLE_TOKEN) + 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): MarketCapExplainerCalculator { - val quarks = calculator().snapshotAtReserve(BigDecimal(reserve)).supplyQuarks - return calculator(supply = quarks) + private fun calcAtReserve(reserve: Long): BondingCurveProjection { + val quarks = projection().snapshotAtReserve(BigDecimal(reserve)).supplyQuarks + return projection(supply = quarks) } - private fun stopsOf(calc: MarketCapExplainerCalculator) = calc.stops.map { it.toLong() } + private fun stopsOf(calc: BondingCurveProjection) = calc.stops.map { it.toLong() } private val baseStops = listOf(5_000L, 100_000, 1_000_000, 10_000_000) @@ -289,7 +237,7 @@ class MarketCapExplainerCalculatorTest { fun `small reserves keep the base stops`() { assertEquals(baseStops, stopsOf(calcAtReserve(22_700))) assertEquals(baseStops, stopsOf(calcAtReserve(171_000))) - assertEquals(baseStops, stopsOf(calculator())) + assertEquals(baseStops, stopsOf(projection())) } @Test @@ -313,7 +261,7 @@ class MarketCapExplainerCalculatorTest { 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(), MarketCapExplainerCalculator.reserveStops(BigDecimal(6_000_000), BigDecimal(50_000_000)) + assertEquals(emptyList(), BondingCurveProjection.reserveStops(BigDecimal(6_000_000), BigDecimal(50_000_000)) .filter { it > BigDecimal(50_000_000) }) } 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 index 4cf305f957..8746c4ba4a 100644 --- 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 @@ -96,7 +96,7 @@ class MarketCapExplainerViewModelTest { } @Test - fun `providing a cached mint loads the token and a calculator at Today`() = + 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)) @@ -104,18 +104,18 @@ class MarketCapExplainerViewModelTest { val state = vm.stateFlow.value assertEquals(token, state.token) - val calculator = assertNotNull(state.calculator) - assertEquals(true, calculator.today().isToday) + val projection = assertNotNull(state.projection) + assertEquals(true, projection.today().isToday) } @Test - fun `a token that is not cached leaves the calculator null`() = + 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.calculator) + assertNull(vm.stateFlow.value.projection) } @Test From f6868bd9bf564de67c86d8b191405cc8d918e640 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 1 Oct 2026 21:42:49 -0400 Subject: [PATCH 8/8] refactor(tokens): drop ExplainerCurrency from the market cap explainer ExplainerCurrency only held a Rate. Its convert duplicated Fiat.convertingTo, and its compact reserve label duplicated core's Fiat.abbreviated. The labels that do not move with the slider (tick labels, tokens held, price per token and both supply shares) are now formatted in MarketCapExplainerViewModel, and they are recomputed when the projection loads or the preferred rate changes. The reserve readout, scrub label and worth follow the finger, so Content derives them from the snapshot with convertingTo(rate) and abbreviated(). Reserve labels now use abbreviated()'s three significant digits instead of one decimal: $1.86M rather than $1.9M, and $1.23K rather than $1.2K. The ticks and Today read the same. ExplainerFormatting.kt only holds the label layout now, so it becomes ExplainerLabelLayout.kt. The price and percent tests move to MarketCapExplainerViewModelTest. --- .../internal/explainer/ExplainerFormatting.kt | 89 ------------------- .../explainer/ExplainerLabelLayout.kt | 28 ++++++ .../explainer/MarketCapExplainerContent.kt | 33 +++---- .../explainer/ExplainerFormattingTest.kt | 71 --------------- .../explainer/ExplainerLabelLayoutTest.kt | 25 ++++++ .../tokens/ui/MarketCapExplainerViewModel.kt | 60 ++++++++++++- .../ui/MarketCapExplainerViewModelTest.kt | 40 +++++++++ 7 files changed, 165 insertions(+), 181 deletions(-) delete mode 100644 apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/explainer/ExplainerFormatting.kt create mode 100644 apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/explainer/ExplainerLabelLayout.kt delete mode 100644 apps/flipcash/features/tokens/src/test/kotlin/com/flipcash/app/tokens/internal/explainer/ExplainerFormattingTest.kt create mode 100644 apps/flipcash/features/tokens/src/test/kotlin/com/flipcash/app/tokens/internal/explainer/ExplainerLabelLayoutTest.kt diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/explainer/ExplainerFormatting.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/explainer/ExplainerFormatting.kt deleted file mode 100644 index 1e484be064..0000000000 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/explainer/ExplainerFormatting.kt +++ /dev/null @@ -1,89 +0,0 @@ -package com.flipcash.app.tokens.internal.explainer - -import com.getcode.opencode.internal.extensions.fractionDigits -import com.getcode.opencode.model.financial.Fiat -import com.getcode.opencode.model.financial.Rate -import java.math.BigDecimal -import java.math.MathContext -import java.math.RoundingMode -import kotlin.math.max - -/** - * Shows the explainer's dollar figures in the user's preferred currency. The curve math stays in USD; - * only display is converted, with the same [Rate] the token screen uses for its balance and appreciation. - */ -internal class ExplainerCurrency(private val rate: Rate) { - - /** [usd] converted to the preferred currency. */ - fun convert(usd: BigDecimal): Fiat = Fiat(fiat = usd.toDouble() * rate.fx, currencyCode = rate.currency) - - /** A USD [Fiat] converted to the preferred currency. */ - fun convert(usd: Fiat): Fiat = usd.convertingTo(rate) - - /** `$5K`, `$22.7K`, `$1M`: a USD reserve as a compact label in the preferred currency. */ - fun reserve(usd: BigDecimal): String { - var value = convert(usd).decimalValue - var unit = 0 - while (unit < UNITS.lastIndex && trim(value).abs() >= THOUSAND) { - value /= 1_000.0 - unit++ - } - return symbol() + trim(value).toPlainString() + UNITS[unit] - } - - /** A USD per-token price in the preferred currency, to four significant figures: `$0.02994`, `$8.78`. */ - fun price(usd: BigDecimal): 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())) - } - - private fun symbol() = rate.currency.singleCharacterCurrencySymbol.orEmpty() - - private fun trim(v: Double): BigDecimal = - BigDecimal(v).setScale(1, RoundingMode.HALF_UP).stripTrailingZeros() - - private companion object { - val UNITS = listOf("", "K", "M", "B", "T") - val THOUSAND: BigDecimal = BigDecimal(1_000) - } -} - -internal object ExplainerFormat { - private val MIN_SHOWN_PERCENT = BigDecimal("0.01") - - /** A share as a percentage to two decimals, or `<0.01%` for a positive share below that. */ - 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() + "%" - } -} - -/** 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/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/MarketCapExplainerContent.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/explainer/MarketCapExplainerContent.kt index 5dbbb35d0e..e4f079baaf 100644 --- 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 @@ -34,6 +34,7 @@ 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 @@ -93,39 +94,33 @@ internal fun MarketCapExplainerContent(state: MarketCapExplainerViewModel.State) derivedStateOf { projection.snapshotAt(position.toDouble()) } } val chart by remember(projection, snapshot) { derivedStateOf { projection.chart(snapshot) } } - val ownership = remember(projection) { projection.ownership() } - // The curve stays in USD; only what is drawn is converted to the preferred currency. - val currency = remember(state.rate) { ExplainerCurrency(state.rate) } - val reserveText = currency.reserve(snapshot.reserve) + // 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 appreciationText = appreciation?.formattedAppreciation() ?: Dash - val appreciationTone = appreciationTone(appreciation) val model = ExplainerUiModel( tokenName = token.name, position = position, ticks = projection.ticks, - tickLabels = projection.ticks.associateWith { currency.reserve(it.reserve) }, + tickLabels = labels.ticks, reserveText = reserveText, - worthText = snapshot.worth?.let { currency.convert(it).formatted() } ?: Dash, + 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_underlyingTokensYouOwn, - ownership.tokensHeld - ?.let { java.text.NumberFormat.getIntegerInstance().format(it.setScale(0, java.math.RoundingMode.DOWN)) } - ?: Dash, + R.string.label_yourCurrentAppreciation, + appreciation?.formattedAppreciation() ?: Dash, + appreciationTone(appreciation), ), - OwnershipRow(R.string.label_currentPricePerToken, currency.price(ownership.price)), - OwnershipRow( - R.string.label_shareOfCirculatingSupply, - ownership.shareOfCirculating?.let(ExplainerFormat::percent) ?: Dash, - ), - OwnershipRow(R.string.label_shareOfMaxSupply, ownership.shareOfMax?.let(ExplainerFormat::percent) ?: Dash), - OwnershipRow(R.string.label_yourCurrentAppreciation, appreciationText, appreciationTone), ), ) MarketCapExplainerBody( diff --git a/apps/flipcash/features/tokens/src/test/kotlin/com/flipcash/app/tokens/internal/explainer/ExplainerFormattingTest.kt b/apps/flipcash/features/tokens/src/test/kotlin/com/flipcash/app/tokens/internal/explainer/ExplainerFormattingTest.kt deleted file mode 100644 index 4a02832373..0000000000 --- a/apps/flipcash/features/tokens/src/test/kotlin/com/flipcash/app/tokens/internal/explainer/ExplainerFormattingTest.kt +++ /dev/null @@ -1,71 +0,0 @@ -package com.flipcash.app.tokens.internal.explainer - -import com.getcode.opencode.model.financial.CurrencyCode -import com.getcode.opencode.model.financial.Fiat -import com.getcode.opencode.model.financial.Rate -import java.math.BigDecimal -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNull - -class ExplainerFormattingTest { - - @Test - fun `today vector reads in usd`() { - assertEquals("$0.02994", ExplainerCurrency(Rate.oneToOne).price(BigDecimal("0.02994"))) - assertEquals("$22.7K", ExplainerCurrency(Rate.oneToOne).reserve(BigDecimal(22_700))) - assertEquals("0.99%", ExplainerFormat.percent(BigDecimal("0.992"))) - } - - @Test - fun `display converts to the preferred currency while the curve stays in usd`() { - val euro = ExplainerCurrency(Rate(fx = 0.5, currency = CurrencyCode.EUR)) - // Reserve labels: 5,000 USD is 2,500 EUR; 10M USD is 5M EUR. - assertEquals("\u20ac2.5K", euro.reserve(BigDecimal(5_000))) - assertEquals("\u20ac50K", euro.reserve(BigDecimal(100_000))) - assertEquals("\u20ac500K", euro.reserve(BigDecimal(1_000_000))) - assertEquals("\u20ac5M", euro.reserve(BigDecimal(10_000_000))) - // Per-token price: 0.02994 USD is 0.01497 EUR, kept to four significant figures. - assertEquals("\u20ac0.01497", euro.price(BigDecimal("0.02994"))) - // Worth: 369.18 USD is 184.59 EUR. - assertEquals(CurrencyCode.EUR, euro.convert(Fiat(369.18)).currencyCode) - assertEquals("\u20ac184.59", euro.convert(Fiat(369.18)).formatted()) - - } - - @Test - fun `a zero-decimal currency keeps whole units and rolls over compact suffixes`() { - val yen = ExplainerCurrency(Rate(fx = 150.0, currency = CurrencyCode.JPY)) - assertEquals("\u00a5750K", yen.reserve(BigDecimal(5_000))) - assertEquals("\u00a51.5B", yen.reserve(BigDecimal(10_000_000))) - // 999,960 rounds to 1000K, which must read 1M. - assertEquals("\u00a51M", ExplainerCurrency(Rate.oneToOne.copy(currency = CurrencyCode.JPY)).reserve(BigDecimal(999_960))) - assertEquals("\u00a54.491", yen.price(BigDecimal("0.02994"))) - } - - @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), java.math.MathContext(20)) - assertEquals("<0.01%", ExplainerFormat.percent(share)) - assertEquals("<0.01%", ExplainerFormat.percent(BigDecimal("0.0099"))) - assertEquals("0.01%", ExplainerFormat.percent(BigDecimal("0.01"))) - assertEquals("0%", ExplainerFormat.percent(BigDecimal.ZERO)) - } - - @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/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/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 index e6babce363..2b2ca4ceb1 100644 --- 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 @@ -1,10 +1,13 @@ 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 @@ -20,6 +23,10 @@ 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 @@ -40,6 +47,17 @@ class MarketCapExplainerViewModel @Inject constructor( 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 { @@ -99,11 +117,49 @@ class MarketCapExplainerViewModel @Inject constructor( when (event) { is Event.OnMintProvided -> { state -> state } is Event.OnLoaded -> { state -> - state.copy(token = event.token, projection = event.projection) + 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) } + 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/ui/MarketCapExplainerViewModelTest.kt b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/MarketCapExplainerViewModelTest.kt index 8746c4ba4a..7217af26d9 100644 --- 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 @@ -26,6 +26,8 @@ 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 @@ -168,4 +170,42 @@ class MarketCapExplainerViewModelTest { 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)) + } }