From d3b10da0cbe27e246d99eaf2c9abbcd6859b67d5 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 28 Aug 2026 17:42:27 -0400 Subject: [PATCH 1/2] fix(wallet): keep a short token stack inside its own bounds `collapseComplete` caps how far the deck pins as the list scrolls. At that cap the last card lands exactly on its fanned slot, which is what keeps every card inside the stack's measured (fanned) height. Clamping the cap at 0 broke this whenever the fanned slack is smaller than `pinInset`: a single card has no slack at all, so the cap became 0 rather than -88dp and the front card was placed 88dp (status bar plus 8dp) below the item's own top, drawing over the "Recent" section that follows it in the list. Drop the clamp. A negative cap means no card ever pins, which is correct when there is nothing to collapse. The test renders 1, 2 and 5 cards scrolled well past full collapse and asserts no card leaves the stack's bounds. The 1- and 2-card cases fail without this change. --- .../flipcash/app/core/ui/TokenCardStack.kt | 9 +- .../core/ui/TokenCardStackPlacementTest.kt | 143 ++++++++++++++++++ 2 files changed, 150 insertions(+), 2 deletions(-) create mode 100644 apps/flipcash/core-ui/src/test/kotlin/com/flipcash/app/core/ui/TokenCardStackPlacementTest.kt diff --git a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/TokenCardStack.kt b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/TokenCardStack.kt index 62ca4aa2b..031f57a00 100644 --- a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/TokenCardStack.kt +++ b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/TokenCardStack.kt @@ -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. diff --git a/apps/flipcash/core-ui/src/test/kotlin/com/flipcash/app/core/ui/TokenCardStackPlacementTest.kt b/apps/flipcash/core-ui/src/test/kotlin/com/flipcash/app/core/ui/TokenCardStackPlacementTest.kt new file mode 100644 index 000000000..4450ac5f4 --- /dev/null +++ b/apps/flipcash/core-ui/src/test/kotlin/com/flipcash/app/core/ui/TokenCardStackPlacementTest.kt @@ -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() + + @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 + } +} From ff33ff24f6306b375f043f5d5d2d1edc4768139e Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 28 Aug 2026 17:42:34 -0400 Subject: [PATCH 2/2] fix(activity): show the token icon when a row has no counterparty A give or grab hands a bill between two devices that never exchange identities: `RequestToGrabBill` carries a destination token account and nothing else. When the server also leaves the notification's `destination_identifier` unset, no profile is ever coming and the row keeps the generic silhouette permanently. Seen on a USDF give-bill, which arrived as `directly_sent_crypto` with the identifier oneof unset, while a token give-bill 22 seconds earlier carried the grabber's user id. Fall back to the token's icon for those rows, and for cash links, which carry a gift-card vault rather than a recipient. The fallback stays narrow. A payment that does name a counterparty keeps the silhouette until the profile lands, so a slow profile fetch never settles on a token icon. --- .../transactionhistory/TransactionListItem.kt | 6 +- .../internal/TransactionItemMapper.kt | 23 ++++++- .../TransactionItemMapperTest.kt | 62 +++++++++++++++++++ 3 files changed, 88 insertions(+), 3 deletions(-) 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 0a960de3f..6131b10ff 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 @@ -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 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 c39b5ea47..1370de371 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 @@ -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 } @@ -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 } diff --git a/apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/TransactionItemMapperTest.kt b/apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/TransactionItemMapperTest.kt index 115555e9e..54c7bfe02 100644 --- a/apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/TransactionItemMapperTest.kt +++ b/apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/TransactionItemMapperTest.kt @@ -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)