Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.flipcash.app.core.feed

import com.getcode.opencode.model.core.ID
import com.getcode.opencode.model.financial.Fiat
import com.getcode.opencode.model.financial.LocalFiat
import com.getcode.opencode.model.financial.Token
import com.getcode.solana.keys.PublicKey
Expand Down Expand Up @@ -109,18 +110,33 @@ sealed interface MessageMetadata {
val userId: ID? = null,
) : MessageMetadata

/**
* @param swapMetadata When the withdrawal was executed as a swap, the metadata for that swap;
* null for a plain withdrawal.
*/
@Serializable
data object WithdrewCrypto : MessageMetadata
data class WithdrewCrypto(
val swapMetadata: SwappedCryptoMetadata? = null,
) : MessageMetadata

@Serializable
data object DepositedCrypto : MessageMetadata

// Superseded by [SwappedCrypto]; retained so historical bought/sold messages still deserialize.
@Serializable
data object BoughtToken: MessageMetadata

@Serializable
data object SoldToken: MessageMetadata

/**
* A swap between two mints, modeled as a single event. Supersedes [BoughtToken]/[SoldToken].
*/
@Serializable
data class SwappedCrypto(
val swap: SwappedCryptoMetadata,
): MessageMetadata

@Serializable
data class PaidCrypto(
val poolId: ID,
Expand All @@ -136,4 +152,33 @@ sealed interface MessageMetadata {
}
}
}
}
}

/**
* The state of a swap as a whole. Persisted mirror of `com.flipcash.services.models.SwapState`.
*/
enum class SwapState {
UNKNOWN,
PENDING,
SUCCEEDED,
FAILED,
NONE,
}

/**
* Persisted mirror of `com.flipcash.services.models.SwappedCryptoMetadata`.
*
* @param from The amount the user gave up in the source mint.
* @param toMint The destination mint. Always known, even while the swap is pending.
* @param toAmount The amount received in the destination mint. Null until the swap has executed.
* @param fee The fee charged for the swap, known upfront regardless of swap state.
* @param swapState The state of the swap as a whole.
*/
@Serializable
data class SwappedCryptoMetadata(
val from: LocalFiat,
val toMint: PublicKey,
val toAmount: LocalFiat?,
val fee: Fiat,
val swapState: SwapState,
)
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
package com.flipcash.app.core.feed

import com.getcode.opencode.model.financial.Fiat
import com.getcode.opencode.model.financial.LocalFiat
import com.getcode.solana.keys.PublicKey
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
Expand Down Expand Up @@ -75,7 +80,7 @@ class ActivityFeedMessageTest {
fun metadataFromWithdrewCrypto() {
val json = """{"type":"com.flipcash.app.core.feed.MessageMetadata.WithdrewCrypto"}"""
val result = MessageMetadata.from(json)
assertEquals(MessageMetadata.WithdrewCrypto, result)
assertEquals(MessageMetadata.WithdrewCrypto(), result)
}

@Test
Expand All @@ -98,4 +103,41 @@ class ActivityFeedMessageTest {
val result = MessageMetadata.from(json)
assertEquals(MessageMetadata.SoldToken, result)
}

// --- Swap metadata ---

@Test
fun swappedCryptoRoundTrips() {
val original: MessageMetadata = MessageMetadata.SwappedCrypto(
swap = SwappedCryptoMetadata(
from = LocalFiat(usdf = Fiat(quarks = 5_000_000L), nativeAmount = Fiat(fiat = 5.0)),
toMint = PublicKey(ByteArray(32) { it.toByte() }.toList()),
toAmount = LocalFiat(usdf = Fiat(quarks = 4_900_000L), nativeAmount = Fiat(fiat = 4.9)),
fee = Fiat(fiat = 0.1),
swapState = SwapState.SUCCEEDED,
)
)
assertEquals(original, MessageMetadata.from(Json.encodeToString(original)))
}

@Test
fun withdrewCryptoWithSwapMetadataRoundTrips() {
val original: MessageMetadata = MessageMetadata.WithdrewCrypto(
swapMetadata = SwappedCryptoMetadata(
from = LocalFiat(usdf = Fiat(quarks = 5_000_000L), nativeAmount = Fiat(fiat = 5.0)),
toMint = PublicKey(ByteArray(32) { it.toByte() }.toList()),
toAmount = null,
fee = Fiat(fiat = 0.1),
swapState = SwapState.PENDING,
)
)
assertEquals(original, MessageMetadata.from(Json.encodeToString(original)))
}

// Legacy withdrew-crypto JSON (no swapMetadata) still decodes, with a null swap.
@Test
fun legacyWithdrewCryptoDecodesWithNullSwap() {
val json = """{"type":"com.flipcash.app.core.feed.MessageMetadata.WithdrewCrypto"}"""
assertEquals(MessageMetadata.WithdrewCrypto(swapMetadata = null), MessageMetadata.from(json))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,11 @@ internal fun TokenLeaderboard(
),
rank = index + 1,
token = entry.token,
rankingSystem = if (isNewUi) {
RankingSystem.MarketCap
} else {
RankingSystem.Holders
},
) {
dispatch(TokenDiscoveryViewModel.Event.OpenTokenInfo(entry.token.address))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,15 @@ import com.flipcash.app.core.ui.rememberShimmerAlpha
import com.flipcash.app.core.ui.shimmer
import com.flipcash.app.core.util.abbreviated
import com.flipcash.features.discovery.R
import com.getcode.opencode.model.financial.CurrencyCode
import com.getcode.opencode.model.financial.Token
import com.getcode.opencode.model.ui.WindowedRange
import com.getcode.theme.CodeTheme
import com.getcode.ui.components.charts.LineTrend
import com.getcode.ui.core.addIf
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.padding
import kotlin.math.abs

sealed interface RankingSystem {
object Holders : RankingSystem
Expand Down Expand Up @@ -114,7 +116,53 @@ internal fun TokenMetricsRow(
)
}
RankingSystem.MarketCap -> {
// TODO: once we have market cap metrics cross window
val currencySymbol = CurrencyCode.USD.singleCharacterCurrencySymbol.orEmpty()

val metricsDelta =
remember(token.marketCapMetrics) { token.marketCapMetrics.deltaForWindow(window) }
val change = if (metricsDelta >= 0) LineTrend.Up else LineTrend.Down
val deltaForWindow = buildString {
append(if (change == LineTrend.Up) "+" else "-")
append(currencySymbol)
append(abs(metricsDelta).abbreviated())
append(" ")
append(
when (window) {
WindowedRange.AllTime -> stringResource(R.string.label_marketCapAllTime)
WindowedRange.LastDay -> stringResource(R.string.label_marketCapDay)
WindowedRange.LastWeek -> stringResource(R.string.label_marketCapWeek)
WindowedRange.LastMonth -> stringResource(R.string.label_marketCapMonth)
WindowedRange.LastYear -> stringResource(R.string.label_marketCapYear)
}
)
}

// Prefer the backend-provided market cap; fall back to the bonding-curve estimate
// for tokens that don't yet have market-cap metrics populated.
val currentCap = remember(token) {
token.marketCapMetrics.currentMarketCap.takeIf { it > 0.0 }
?: token.marketCap()?.decimalValue
}
val value = currentCap?.let { "$currencySymbol${it.abbreviated()}" }.orEmpty()

val subtitle = pluralStringResource(
R.plurals.subtitle_personCount,
token.holderMetrics.currentHolders.toInt(),
token.holderMetrics.currentHolders.abbreviated()
)

TokenMetricsRow(
modifier = modifier,
token = token,
subtitle = subtitle,
value = value,
valueChange = deltaForWindow,
valueChangeColor = when (change) {
LineTrend.Down -> CodeTheme.colors.textSecondary
LineTrend.Up -> change.color
},
onClick = onClick,
)
}
}
}
Expand Down
Loading
Loading