diff --git a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/ActivityAmount.kt b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/ActivityAmount.kt new file mode 100644 index 0000000000..442cde67d7 --- /dev/null +++ b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/ActivityAmount.kt @@ -0,0 +1,91 @@ +package com.flipcash.app.core.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import com.getcode.opencode.compose.LocalExchange +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.LocalFiat +import com.getcode.theme.CodeTheme + +/** + * The amount column of an activity/transaction row: the entry in the viewer's own currency, and — + * only when the payment was denominated in someone else's — what actually moved, flagged, underneath. + * A tip of 7,500 pesos reads "-$5.00" to a viewer in dollars, with "-$7,500.00" under an Argentine + * flag below it. + * + * Both lines carry [signPrefix], so a debit reads as one whichever line you look at — see [sign] + * for the amount that signs itself. + * + * @param showViewerFlag whether the top line is flagged too. The per-token transaction history flags + * every amount it shows, so it opts in; the wallet's activity rows leave the viewer's own currency + * unflagged and let the flag mark the foreign line alone. + */ +@Composable +fun ActivityAmount( + amount: LocalFiat, + modifier: Modifier = Modifier, + signPrefix: String? = null, + showViewerFlag: Boolean = false, +) { + val exchange = LocalExchange.current + // Observed rather than read once: the viewer can change their currency from a screen stacked + // over the list, and these rows are re-mapped only when a profile or token cache lands, so + // nothing else would bring the new currency back down here. Cross-rates for a non-USDF entry + // are a snapshot taken alongside it — see `forViewer`, which only needs them as a fallback. + val preferredRate by exchange.observePreferredRate().collectAsState(initial = exchange.preferredRate) + val amounts = amount.forViewer(preferredRate, exchange.rates()) + + Column( + modifier = modifier, + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), + ) { + if (showViewerFlag) { + FlagWithFiat( + fiat = amounts.viewer, + extraPrefix = amounts.viewer.sign(signPrefix), + spacing = FLAG_SPACING, + ) + } else { + Text( + text = amounts.viewer.formatted(extraPrefix = amounts.viewer.sign(signPrefix)), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + maxLines = 1, + ) + } + + amounts.transferred?.let { transferred -> + FlagWithFiat( + fiat = transferred, + extraPrefix = transferred.sign(signPrefix), + iconSize = CodeTheme.dimens.staticGrid.x2, + spacing = FLAG_SPACING, + textStyle = CodeTheme.typography.textSmall, + textColor = CodeTheme.colors.textSecondary, + ) + } + } +} + +/** + * The sign to render ahead of this amount, or null when the value already carries its own. Activity + * amounts arrive as magnitudes with their direction alongside them, which is why the row supplies a + * sign at all; a value that is genuinely negative formats its own "-", and prefixing that as well + * would read "--$5.00". + */ +internal fun Fiat.sign(prefix: String?): String? = prefix?.takeUnless { isNegative } + +/** + * How far the flag sits from the figure it belongs to. Tighter than [FlagWithFiat]'s default, which + * is set for a lone amount rather than for two of them stacked. + */ +private val FLAG_SPACING: Dp + @Composable get() = CodeTheme.dimens.grid.x1 diff --git a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/FlagWithFiat.kt b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/FlagWithFiat.kt index a66340e2e5..83d0712e1b 100644 --- a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/FlagWithFiat.kt +++ b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/FlagWithFiat.kt @@ -24,8 +24,10 @@ import com.getcode.theme.CodeTheme fun FlagWithFiat( fiat: Fiat, modifier: Modifier = Modifier, + extraPrefix: String? = null, suffix: @Composable (CurrencyCode) -> String? = { null }, iconSize: Dp = CodeTheme.dimens.staticGrid.x4, + spacing: Dp = CodeTheme.dimens.grid.x2, textStyle: TextStyle = CodeTheme.typography.textMedium, textColor: Color = CodeTheme.colors.textMain, ) { @@ -39,7 +41,7 @@ fun FlagWithFiat( flag?.let { Image( modifier = Modifier - .padding(end = CodeTheme.dimens.grid.x2) + .padding(end = spacing) .height(iconSize) .width(iconSize) .clip(CircleShape), @@ -49,7 +51,7 @@ fun FlagWithFiat( } Text( - text = fiat.formatted(), + text = fiat.formatted(extraPrefix = extraPrefix), style = textStyle, color = textColor, ) diff --git a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/ViewerAmount.kt b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/ViewerAmount.kt new file mode 100644 index 0000000000..3e5471c484 --- /dev/null +++ b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/ViewerAmount.kt @@ -0,0 +1,47 @@ +package com.flipcash.app.core.ui + +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.LocalFiat +import com.getcode.opencode.model.financial.Rate +import com.getcode.solana.keys.Mint + +/** + * What an activity row shows for its amount. + * + * @param viewer the entry in the currency the viewer reads money in — always the row's headline. + * @param transferred what actually moved, set only when the payment was denominated in a currency + * that isn't the viewer's; null when the two are the same and a second line would just repeat the + * first. + */ +data class ViewerAmount( + val viewer: Fiat, + val transferred: Fiat?, +) + +/** + * Restates this amount in the viewer's own currency ([preferredRate]'s), keeping what was actually + * transferred alongside it when the two differ — a 7,500 ARS tip reads as its $5 to a viewer in + * dollars, with the pesos underneath. + * + * A USDF payment carries its own USD value ([LocalFiat.underlyingTokenAmount], fixed at the moment + * it settled), so it converts from that: $5 of USDF stays $5 however far the peso has moved since. + * Any other mint has no such anchor — `underlyingTokenAmount` holds that mint's quarks, not + * dollars — so it crosses through today's [rates] instead, and falls back to the transferred amount + * alone when the source currency has no rate to cross with. + */ +fun LocalFiat.forViewer(preferredRate: Rate, rates: Map): ViewerAmount { + val transferred = nativeAmount + if (transferred.currencyCode == preferredRate.currency) return ViewerAmount(transferred, null) + + val usd = usdValue(rates) ?: return ViewerAmount(transferred, null) + return ViewerAmount(viewer = usd.convertingTo(preferredRate), transferred = transferred) +} + +/** The entry's value in USD, or null when it can't be established. See [forViewer]. */ +private fun LocalFiat.usdValue(rates: Map): Fiat? { + if (mint == Mint.usdf) return underlyingTokenAmount + + val rate = rates[nativeAmount.currencyCode]?.takeIf { it.fx > 0.0 } ?: return null + return Fiat(fiat = nativeAmount.decimalValue / rate.fx, currencyCode = CurrencyCode.USD) +} diff --git a/apps/flipcash/core-ui/src/test/kotlin/com/flipcash/app/core/ui/ViewerAmountTest.kt b/apps/flipcash/core-ui/src/test/kotlin/com/flipcash/app/core/ui/ViewerAmountTest.kt new file mode 100644 index 0000000000..40fd94a5f4 --- /dev/null +++ b/apps/flipcash/core-ui/src/test/kotlin/com/flipcash/app/core/ui/ViewerAmountTest.kt @@ -0,0 +1,97 @@ +package com.flipcash.app.core.ui + +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.LocalFiat +import com.getcode.opencode.model.financial.Rate +import com.getcode.solana.keys.Mint +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * An activity row leads with the viewer's own currency, so a tip denominated in someone else's has + * to be restated — and the restated figure has to hold still, which is why a USDF payment converts + * from the USD value it settled at rather than from today's peso. + */ +class ViewerAmountTest { + + private val jeffy = Mint("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v") + + private fun usd(amount: Double) = Fiat(amount, CurrencyCode.USD) + private fun ars(amount: Double) = Fiat(amount, CurrencyCode.ARS) + private fun eur(amount: Double) = Fiat(amount, CurrencyCode.EUR) + + private val usdRate = Rate(fx = 1.0, currency = CurrencyCode.USD) + private val eurRate = Rate(fx = 0.9, currency = CurrencyCode.EUR) + + @Test + fun `an amount already in the viewer's currency shows one line`() { + val amount = LocalFiat(usdf = usd(5.0), nativeAmount = usd(5.0)) + + val shown = amount.forViewer(usdRate, rates = emptyMap()) + + assertEquals(usd(5.0), shown.viewer) + assertNull(shown.transferred) + } + + @Test + fun `a USDF tip in pesos leads with the dollars it settled at`() { + val amount = LocalFiat(usdf = usd(5.0), nativeAmount = ars(7_500.0)) + + val shown = amount.forViewer(usdRate, rates = mapOf(CurrencyCode.ARS to Rate(3_000.0, CurrencyCode.ARS))) + + // The peso has halved against the dollar since — the row still reads $5, not $2.50. + assertEquals(usd(5.0), shown.viewer) + assertEquals(ars(7_500.0), shown.transferred) + } + + @Test + fun `a USDF tip crosses its settled dollars into whatever currency the viewer reads`() { + val amount = LocalFiat(usdf = usd(5.0), nativeAmount = ars(7_500.0)) + + val shown = amount.forViewer(eurRate, rates = emptyMap()) + + assertEquals(eur(4.5), shown.viewer) + assertEquals(ars(7_500.0), shown.transferred) + } + + @Test + fun `a non-USDF tip has no settled dollars, so it crosses through today's rates`() { + val amount = LocalFiat(usdf = Fiat(1_234_567L), nativeAmount = ars(7_500.0), mint = jeffy) + + val shown = amount.forViewer(usdRate, rates = mapOf(CurrencyCode.ARS to Rate(1_500.0, CurrencyCode.ARS))) + + // `underlyingTokenAmount` holds the mint's own quarks here, not dollars, so it is ignored. + assertEquals(usd(5.0), shown.viewer) + assertEquals(ars(7_500.0), shown.transferred) + } + + @Test + fun `a non-USDF tip with no rate to cross falls back to what was transferred`() { + val amount = LocalFiat(usdf = Fiat(1_234_567L), nativeAmount = ars(7_500.0), mint = jeffy) + + val shown = amount.forViewer(usdRate, rates = emptyMap()) + + assertEquals(ars(7_500.0), shown.viewer) + assertNull(shown.transferred) + } + + @Test + fun `an amount that signs itself is not signed twice`() { + // The row supplies the sign because feed amounts are magnitudes; one that isn't would + // otherwise format as "--$5.00". + assertEquals("-$5.00", usd(-5.0).formatted(extraPrefix = usd(-5.0).sign("-"))) + assertEquals("-$5.00", usd(5.0).formatted(extraPrefix = usd(5.0).sign("-"))) + } + + @Test + fun `an unusable rate is treated as no rate at all`() { + val amount = LocalFiat(usdf = Fiat(1_234_567L), nativeAmount = ars(7_500.0), mint = jeffy) + + val shown = amount.forViewer(usdRate, rates = mapOf(CurrencyCode.ARS to Rate(0.0, CurrencyCode.ARS))) + + assertEquals(ars(7_500.0), shown.viewer) + assertNull(shown.transferred) + } +} diff --git a/apps/flipcash/features/transactions/src/main/kotlin/com/flipcash/app/transactions/internal/components/FeedItemSummary.kt b/apps/flipcash/features/transactions/src/main/kotlin/com/flipcash/app/transactions/internal/components/FeedItemSummary.kt index 468c23830e..f209d9ed6c 100644 --- a/apps/flipcash/features/transactions/src/main/kotlin/com/flipcash/app/transactions/internal/components/FeedItemSummary.kt +++ b/apps/flipcash/features/transactions/src/main/kotlin/com/flipcash/app/transactions/internal/components/FeedItemSummary.kt @@ -9,11 +9,10 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.material.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import com.flipcash.app.core.feed.ActivityFeedMessage -import com.flipcash.app.core.ui.FlagWithFiat +import com.flipcash.app.core.ui.ActivityAmount import com.getcode.theme.CodeTheme import com.getcode.ui.core.addIf import com.getcode.ui.core.noRippleClickable @@ -66,11 +65,9 @@ internal fun FeedItemSummary( } message.amount?.let { amount -> - Column( - horizontalAlignment = Alignment.End - ) { - FlagWithFiat(fiat = amount.nativeAmount) - } + // Flagged on both lines here: this screen has always flagged the one amount it shows, + // so the viewer's own currency keeps its flag rather than losing it to the new line. + ActivityAmount(amount = amount, showViewerFlag = true) } } } diff --git a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/ActivityFeedRow.kt b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/ActivityFeedRow.kt index 14f1949089..311a859b16 100644 --- a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/ActivityFeedRow.kt +++ b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/ActivityFeedRow.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow +import com.flipcash.app.core.ui.ActivityAmount import com.flipcash.app.core.ui.TokenIcon import com.getcode.opencode.model.financial.Token import com.flipcash.shared.common.ui.ContactAvatar @@ -76,7 +77,7 @@ fun ActivityFeedRow( verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), ) { Text( - text = amount.formatted(), + text = amount.nativeAmount.formatted(), style = CodeTheme.typography.textMedium, color = CodeTheme.colors.textMain, maxLines = 1, @@ -90,10 +91,9 @@ fun ActivityFeedRow( } } else { amount?.let { fiat -> - Text( - text = fiat.formatted(extraPrefix = item.signedAmountPrefix?.ifEmpty { null }), - style = CodeTheme.typography.textMedium, - color = CodeTheme.colors.textMain, + ActivityAmount( + amount = fiat, + signPrefix = item.signedAmountPrefix?.ifEmpty { null }, ) } } diff --git a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/TransactionListItem.kt b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/TransactionListItem.kt index f646982147..6d7df683c0 100644 --- a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/TransactionListItem.kt +++ b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/TransactionListItem.kt @@ -2,6 +2,7 @@ package com.flipcash.shared.transactionhistory import com.flipcash.services.models.UserProfile import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.LocalFiat import com.getcode.opencode.model.financial.Token import kotlin.time.Instant @@ -49,7 +50,9 @@ data class TransactionListItem( val timestamp: Instant, val avatar: TransactionAvatar, val signedAmountPrefix: String?, // "-", "+", or null (for metadata == null) - val amount: Fiat?, // message.amount.nativeAmount, or null if non-financial + // The whole exchanged amount, not just its native side: the row restates it in the viewer's + // own currency, which needs the entry's USD value and mint as well (see `forViewer`). + val amount: LocalFiat?, // message.amount, or null if non-financial val fee: Fiat?, // converts only: what the swap cost, shown under the amount val canCancel: Boolean, ) diff --git a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/internal/TransactionItemMapper.kt b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/internal/TransactionItemMapper.kt index dbac7b0ef8..2113e7989a 100644 --- a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/internal/TransactionItemMapper.kt +++ b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/internal/TransactionItemMapper.kt @@ -60,7 +60,7 @@ internal class TransactionItemMapper @Inject constructor( timestamp = msg.timestamp, avatar = avatar, signedAmountPrefix = prefix, - amount = msg.amount?.nativeAmount, + amount = msg.amount, fee = convert?.fee, canCancel = (meta as? MessageMetadata.IndirectlySentCrypto)?.canCancel == true, )