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
Expand Up @@ -153,8 +153,13 @@ fun TokenCardStack(
// Always the fanned height, so the list's scroll range is stable while cards collapse.
val height = if (placeables.isEmpty()) 0 else cardPx + fannedPx * (placeables.size - 1)
// Scroll distance at which every card has finished collapsing (the last card pins last).
val collapseComplete =
((placeables.size - 1) * (fannedPx - collapsedPx) - pinInsetPx).coerceAtLeast(0)
// Deliberately NOT clamped at 0: it is a cap on `past`, and at that cap the last card sits at
// exactly its fanned slot, so the deck never leaves the measured height. When the fanned slack
// is smaller than the pin inset — a single card has none at all — the cap is negative and no
// card ever pins, which is correct: there is nothing to collapse. Clamping it to 0 would let
// the deck pin `pinInset` px below its own top, pushing the front card past the bottom of the
// item and under the following row (the wallet's "Recent" section overlapping a lone card).
val collapseComplete = (placeables.size - 1) * (fannedPx - collapsedPx) - pinInsetPx
layout(constraints.maxWidth, height) {
// Read scroll offset HERE (placement) — not in the measure scope — so scrolling only
// re-places the cards; reading it while measuring would re-run each card's SubcomposeLayout.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package com.flipcash.app.core.ui

import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.width
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.hasClickAction
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import coil3.ColorImage
import coil3.ImageLoader
import coil3.SingletonImageLoader
import coil3.decode.DataSource
import coil3.intercept.Interceptor
import coil3.request.ImageResult
import coil3.request.SuccessResult
import com.flipcash.app.theme.FlipcashPreview
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.Token
import com.getcode.opencode.model.financial.TokenWithLocalizedBalance
import com.getcode.opencode.model.financial.usdc
import com.getcode.opencode.model.financial.usdf
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import kotlin.test.assertTrue

/**
* Placement invariant for [TokenCardStack]: however far the list scrolls, no card may be placed below
* the stack's own measured height. The stack reports the *fanned* height, so a card pushed past it
* draws over whatever the enclosing list puts next (the wallet's "Recent" section) — which is exactly
* what happened when the collapse cap was clamped at 0 and the pin inset had no fanned slack to eat,
* as with a single card.
*/
@RunWith(RobolectricTestRunner::class)
// Tall viewport so the stack (up to 480dp fanned) is never clipped by the root, which would
// shrink its reported bounds and fake a violation.
@Config(sdk = [34], qualifiers = "w411dp-h891dp")
class TokenCardStackPlacementTest {

@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()

@Before
fun stubImageLoader() {
SingletonImageLoader.setSafe { context ->
ImageLoader.Builder(context)
.components {
add(
Interceptor { chain ->
SuccessResult(
image = ColorImage(color = 0x330D3B22),
request = chain.request,
dataSource = DataSource.MEMORY,
) as ImageResult
},
)
}
.build()
}
}

@Test
fun `lone card never leaves the stack bounds`() = assertStaysInBounds(cards = 1)

/** Two cards fan by 64dp — less slack than the 88dp inset, so they must not pin either. */
@Test
fun `short stack never leaves the stack bounds`() = assertStaysInBounds(cards = 2)

@Test
fun `tall stack never leaves the stack bounds`() = assertStaysInBounds(cards = 5)

/**
* Renders [cards] tokens and scrolls the stack far past the point where every card has collapsed,
* asserting each card is still inside the stack's reported bounds.
*/
private fun assertStaysInBounds(cards: Int) {
val tokens = List(cards) { index ->
TokenWithLocalizedBalance(
token = if (index == 0) Token.usdf else Token.usdc,
balance = LocalFiat(
usdf = Fiat(quarks = 1_000_000L),
nativeAmount = Fiat(fiat = 1.0, currencyCode = CurrencyCode.USD),
),
displayName = "Token $index",
)
}

composeRule.mainClock.autoAdvance = false
composeRule.setContent {
FlipcashPreview {
Box(modifier = Modifier.width(360.dp)) {
TokenCardStack(
tokens = tokens,
modifier = Modifier.testTag(StackTag),
// Status bar + a grid unit, as the wallet screen passes.
pinInset = 88.dp,
// Well past `collapseComplete` for any of these stacks: the deck has finished
// collapsing and is scrolling off with the list.
scrolledPast = { 5_000f },
)
}
}
}
// Pump frames rather than waiting for idle — the card's async icon keeps scheduling work.
repeat(10) { composeRule.mainClock.advanceTimeByFrame() }

val stackNode = composeRule.onNodeWithTag(StackTag).fetchSemanticsNode()
val stackTop = stackNode.positionInRoot.y.toDp()
val stackBottom = stackTop + stackNode.size.height.toDp()
val cardNodes = composeRule.onAllNodes(hasClickAction()).fetchSemanticsNodes()
assertTrue(cardNodes.size == cards, "expected $cards cards, found ${cardNodes.size}")

cardNodes.forEachIndexed { index, node ->
val top = node.positionInRoot.y.toDp()
val bottom = top + node.size.height.toDp()
assertTrue(
top >= stackTop - Tolerance,
"card $index top ($top) is above the stack ($stackTop)",
)
assertTrue(
bottom <= stackBottom + Tolerance,
"card $index bottom ($bottom) is below the stack ($stackBottom)",
)
}
}

private fun Float.toDp(): Dp = with(composeRule.density) { this@toDp.toDp() }
private fun Int.toDp(): Dp = with(composeRule.density) { this@toDp.toDp() }

private companion object {
const val StackTag = "tokenCardStack"
val Tolerance = 1.dp
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ import kotlin.time.Instant
/**
* Leading avatar for a transaction row.
* - [Profile] — a resolved counterparty (tip / user-to-user send-receive), keyed by user id.
* - [TokenIcon] — no counterparty (deposit / buy / sell / withdraw): the token's icon.
* - [TokenIcon] — no counterparty to draw: deposit / buy / sell / withdraw, a cash link (sent to
* whoever opens it), or a give or grab the server left unidentified (a bill hand-off never
* exchanges identities). The token's icon.
* - [SwapTokens] — a convert: both sides' icons, source behind destination.
* - [Generic] — unresolved / unknown counterparty.
* - [Generic] — a counterparty that is named but not yet resolved, or unknown metadata.
*/
sealed interface TransactionAvatar {
data class Profile(val profile: UserProfile) : TransactionAvatar
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ internal class TransactionItemMapper @Inject constructor(
counterparty != null -> TransactionAvatar.Profile(counterparty)
// A convert always draws both sides, even before both tokens have resolved.
convert != null -> TransactionAvatar.SwapTokens(from = token, to = source.toToken)
hasNoCounterparty(meta) && token != null -> TransactionAvatar.TokenIcon(token)
(hasNoCounterparty(meta) || isUnidentifiedBill(meta)) && token != null ->
TransactionAvatar.TokenIcon(token)
else -> TransactionAvatar.Generic
}

Expand Down Expand Up @@ -150,11 +151,31 @@ private fun userIdOf(meta: MessageMetadata?): ID? = when (meta) {
else -> null
}

/**
* Whether this is a bill hand-off — a give or a grab — that names nobody.
*
* The two devices never exchange identities during one: the grabber's `RequestToGrabBill` carries a
* destination token account and nothing else, so when the server also leaves the notification's
* identifier unset there is no counterparty to resolve, now or later. The row would otherwise keep
* the generic silhouette forever; the token's own icon at least says what moved.
*
* Deliberately narrow: a peer payment whose profile simply hasn't landed yet *does* carry an
* identifier, so it stays generic and swaps in the real avatar when the profile arrives.
*/
private fun isUnidentifiedBill(meta: MessageMetadata?): Boolean = when (meta) {
is MessageMetadata.DirectlySentCrypto -> meta.userId == null && meta.phoneNumber == null
is MessageMetadata.ReceivedCrypto -> meta.userId == null && meta.phoneNumber == null
else -> false
}

private fun hasNoCounterparty(meta: MessageMetadata?): Boolean = when (meta) {
MessageMetadata.DepositedCrypto,
is MessageMetadata.WithdrewCrypto,
MessageMetadata.BoughtToken,
is MessageMetadata.SwappedCrypto,
// A cash link is sent to whoever opens it, so it carries a gift-card vault instead of a
// recipient — there is never a profile to draw, only the token that moved.
is MessageMetadata.IndirectlySentCrypto,
MessageMetadata.SoldToken -> true
else -> false
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,68 @@ class TransactionItemMapperTest {
assertEquals(TransactionAvatar.TokenIcon(token), item.avatar)
}

@Test
fun `give with no identifier uses the token icon`() {
val token = usdfToken()
val msg = feedMessage(metadata = MessageMetadata.DirectlySentCrypto())
.copy(text = "Gave", textSubstitutions = emptyList())
val item = mapper.map(ActivityFeedMessageWithToken(msg, token) to cached)

assertEquals(TransactionAvatar.TokenIcon(token), item.avatar)
assertEquals("-", item.signedAmountPrefix)
assertEquals("Gave", item.title)
}

@Test
fun `grab with no identifier uses the token icon`() {
val token = usdfToken()
val msg = feedMessage(metadata = MessageMetadata.ReceivedCrypto())
.copy(text = "Received", textSubstitutions = emptyList())
val item = mapper.map(ActivityFeedMessageWithToken(msg, token) to cached)

assertEquals(TransactionAvatar.TokenIcon(token), item.avatar)
assertEquals("+", item.signedAmountPrefix)
}

/** A named counterparty is still coming, so the row waits for it rather than showing the token. */
@Test
fun `send to a named but unresolved user keeps the generic avatar`() {
val token = usdfToken()
val msg = feedMessage(metadata = MessageMetadata.DirectlySentCrypto(userId = knownUserId))
val item = mapper.map(ActivityFeedMessageWithToken(msg, token) to emptyMap())

assertEquals(TransactionAvatar.Generic, item.avatar)
}

@Test
fun `send to a phone-only recipient keeps the generic avatar`() {
val token = usdfToken()
val msg = feedMessage(metadata = MessageMetadata.DirectlySentCrypto(phoneNumber = "+15555550123"))
val item = mapper.map(ActivityFeedMessageWithToken(msg, token) to emptyMap())

assertEquals(TransactionAvatar.Generic, item.avatar)
}

/** No token has resolved from the mint cache yet, so there is no icon to draw. */
@Test
fun `give with no identifier and no token stays generic`() {
val msg = feedMessage(metadata = MessageMetadata.DirectlySentCrypto())
val item = mapper.map(ActivityFeedMessageWithToken(msg, token = null) to cached)

assertEquals(TransactionAvatar.Generic, item.avatar)
}

@Test
fun `cash link uses the token icon`() {
val token = usdfToken()
val creator = PublicKey(ByteArray(32).toList())
val msg = feedMessage(metadata = MessageMetadata.IndirectlySentCrypto(creator, canCancel = true))
val item = mapper.map(ActivityFeedMessageWithToken(msg, token) to cached)

assertEquals(TransactionAvatar.TokenIcon(token), item.avatar)
assertEquals("-", item.signedAmountPrefix)
}

@Test
fun `null metadata yields null prefix and a generic avatar`() {
val msg = feedMessage(metadata = null)
Expand Down
Loading