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
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {
Expand All @@ -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),
Expand All @@ -49,7 +51,7 @@ fun FlagWithFiat(
}

Text(
text = fiat.formatted(),
text = fiat.formatted(extraPrefix = extraPrefix),
style = textStyle,
color = textColor,
)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<CurrencyCode, Rate>): 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<CurrencyCode, Rate>): 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)
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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 },
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down
Loading