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 + } +} 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)