From 7c0f629ef6a68282317cb03ebde725a6d72b2ac0 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 19 Aug 2026 08:51:24 -0400 Subject: [PATCH 1/2] feat(ui): add USDF "$" watermark to the Dollars token card Draw an oversized bold "$" glyph bleeding off the top-right of the gold USDF/Dollars card, using mix-blend "overlay" at 30% so it reads as an emboss on the gradient rather than a solid mark (Figma 9120:15335). Gated to USDF (token.address == Mint.usdf); other tokens are unchanged. The glyph is pre-measured with a TextMeasurer and its font size is expressed in dp (via toSp) so it scales with the card height and ignores the user font scale. It's drawn in a drawWithContent layer that sits above the gradient, is clipped to the card's rounded shape, and ignores the content padding. Adds a render-only Robolectric harness (TokenCardWatermarkScreenshotTest) that captures the card to build/screenshots/ for eyeballing the mark without an emulator. --- .../com/flipcash/app/core/ui/TokenCard.kt | 49 ++++++++ .../ui/TokenCardWatermarkScreenshotTest.kt | 110 ++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 apps/flipcash/core-ui/src/test/kotlin/com/flipcash/app/core/ui/TokenCardWatermarkScreenshotTest.kt diff --git a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/TokenCard.kt b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/TokenCard.kt index 91af39bad6..495d11b072 100644 --- a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/TokenCard.kt +++ b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/TokenCard.kt @@ -16,9 +16,16 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.drawText import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.flipcash.app.core.money.formattedAppreciation @@ -84,12 +91,47 @@ fun TokenCard( if (isUsdf) UsdfBrush else billCardBrush(token.billCustomizations) } + // USDF's oversized "$" watermark (Figma 9120:15335): a bold white glyph bleeding off the + // top-right, drawn with mix-blend "overlay" at 30% so it reads as an emboss on the gold, not a + // solid mark. Pre-measured here; positioned per-frame in the draw layer once the card size is + // known. Font size is expressed in dp (via toSp) so the watermark ignores the user font scale. + val density = LocalDensity.current + val textMeasurer = rememberTextMeasurer() + val watermark = remember(isUsdf, height, density) { + if (!isUsdf) null else textMeasurer.measure( + text = "$", + style = TextStyle( + color = Color.White, + fontWeight = FontWeight.Bold, + fontSize = with(density) { (height * WatermarkFontScale).toSp() }, + ), + ) + } + Box( modifier = modifier .fillMaxWidth() .height(height) .clip(shape) .background(brush) + // Sits above the gradient but below the header; clipped to the card's rounded shape + // (applied before this in the chain) and ignores the content padding (applied after). + .addIf(watermark != null) { + Modifier.drawWithContent { + watermark?.let { glyph -> + drawText( + textLayoutResult = glyph, + topLeft = Offset( + x = size.width - glyph.size.width - WatermarkRightInset.toPx(), + y = -WatermarkTopBleed.toPx(), + ), + alpha = WatermarkAlpha, + blendMode = BlendMode.Overlay, + ) + } + drawContent() + } + } .border(CodeTheme.dimens.border, CodeTheme.colors.surfaceVariant, shape) .addIf(onClick != null) { Modifier.clickable { onClick?.invoke() } } .padding(CodeTheme.dimens.inset), @@ -143,6 +185,13 @@ fun TokenCard( /** USDF's fixed gold branding (Figma) — used instead of a user-chosen bill color. */ private val UsdfBrush = Brush.horizontalGradient(listOf(Color(0xFFC4980B), Color(0xFFB06B00))) +// USDF "$" watermark tuning (Figma 9120:15335: ~213px glyph on a ~214px-tall card, top -17 / right +// +17, 30% opacity). Sizes are proportional to the card height so it scales with `height`. +private const val WatermarkFontScale = 0.95f +private const val WatermarkAlpha = 0.30f +private val WatermarkTopBleed = 16.dp +private val WatermarkRightInset = 16.dp + /** Horizontal gradient brush from a token's bill-customization colors (matches the Figma cards). */ private fun billCardBrush(customizations: TokenBillCustomizations?): Brush { val fallback = Color(0xFF06450F) // matches CashBill's no-customization fallback diff --git a/apps/flipcash/core-ui/src/test/kotlin/com/flipcash/app/core/ui/TokenCardWatermarkScreenshotTest.kt b/apps/flipcash/core-ui/src/test/kotlin/com/flipcash/app/core/ui/TokenCardWatermarkScreenshotTest.kt new file mode 100644 index 0000000000..f83583d4fa --- /dev/null +++ b/apps/flipcash/core-ui/src/test/kotlin/com/flipcash/app/core/ui/TokenCardWatermarkScreenshotTest.kt @@ -0,0 +1,110 @@ +package com.flipcash.app.core.ui + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.view.View +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.junit4.createAndroidComposeRule +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.Token +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 org.robolectric.annotation.GraphicsMode +import java.io.File + +/** + * Renders [TokenCard] to a PNG so the USDF "$" watermark can be eyeballed without an emulator. + * Not an assertion test — it captures the composable to `build/screenshots/`. + * + * The screen keeps scheduling frames (Coil's async icon, the balance's AnimatedNumberText, the + * SharedTransitionLayout in the preview wrapper), so `captureToImage()`'s implicit `waitForIdle` + * would time out. Instead we pause the clock, pump a fixed number of frames to let layout settle, + * and draw the Android view directly — a path that can't hang. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [34]) +class TokenCardWatermarkScreenshotTest { + + @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 rendersUsdfWatermark() { + composeRule.mainClock.autoAdvance = false + composeRule.setContent { + FlipcashPreview(showBackground = true) { + Column( + modifier = Modifier + .width(360.dp) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // USDF (Dollars) — gold with the "$" watermark (Figma 9120:15335). + TokenCard( + token = Token.usdf, + balanceText = "$30.00", + displayName = "Dollars", + appreciationText = "+$0.00", + ) + // A non-USDF token for contrast — no watermark. + TokenCard( + token = Token.usdc, + balanceText = "$12.34", + ) + } + } + } + // Pump frames so composition/measure/layout/draw run, without waiting for idle. + repeat(10) { composeRule.mainClock.advanceTimeByFrame() } + + val root: View = composeRule.activity.findViewById(android.R.id.content) + val width = root.width.takeIf { it > 0 } ?: 1080 + val height = root.height.takeIf { it > 0 } ?: 1920 + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + root.draw(Canvas(bitmap)) + + val outDir = File("build/screenshots").apply { mkdirs() } + val file = File(outDir, "token_card_watermark.png") + file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } + println("SCREENSHOT_WRITTEN: ${file.absolutePath} (${width}x$height)") + } +} From 25d251cc833f243b48f03dd0f64e9eba6f891cb5 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 19 Aug 2026 08:58:35 -0400 Subject: [PATCH 2/2] refactor(ui): use dimens.inset for the USDF watermark bleed Replace the hardcoded 16.dp top-bleed / right-inset constants with CodeTheme.dimens.inset so the watermark tracks the card's content inset and flips to 16dp automatically on the 4pt-grid switch. --- .../kotlin/com/flipcash/app/core/ui/TokenCard.kt | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/TokenCard.kt b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/TokenCard.kt index 495d11b072..5bc2bae22c 100644 --- a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/TokenCard.kt +++ b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/TokenCard.kt @@ -97,6 +97,9 @@ fun TokenCard( // known. Font size is expressed in dp (via toSp) so the watermark ignores the user font scale. val density = LocalDensity.current val textMeasurer = rememberTextMeasurer() + // Same inset the card content uses, so the "$" hangs off the top-right by one grid unit. Read in + // composition (it's theme-scoped) and captured for the draw layer; flips to 16dp on the 4pt grid. + val watermarkInset = CodeTheme.dimens.inset val watermark = remember(isUsdf, height, density) { if (!isUsdf) null else textMeasurer.measure( text = "$", @@ -122,8 +125,8 @@ fun TokenCard( drawText( textLayoutResult = glyph, topLeft = Offset( - x = size.width - glyph.size.width - WatermarkRightInset.toPx(), - y = -WatermarkTopBleed.toPx(), + x = size.width - glyph.size.width - watermarkInset.toPx(), + y = -watermarkInset.toPx(), ), alpha = WatermarkAlpha, blendMode = BlendMode.Overlay, @@ -186,11 +189,10 @@ fun TokenCard( private val UsdfBrush = Brush.horizontalGradient(listOf(Color(0xFFC4980B), Color(0xFFB06B00))) // USDF "$" watermark tuning (Figma 9120:15335: ~213px glyph on a ~214px-tall card, top -17 / right -// +17, 30% opacity). Sizes are proportional to the card height so it scales with `height`. +// +17, 30% opacity). The glyph size is proportional to the card height so it scales with `height`; +// the top/right bleed uses CodeTheme.dimens.inset (read in composition, see above). private const val WatermarkFontScale = 0.95f private const val WatermarkAlpha = 0.30f -private val WatermarkTopBleed = 16.dp -private val WatermarkRightInset = 16.dp /** Horizontal gradient brush from a token's bill-customization colors (matches the Figma cards). */ private fun billCardBrush(customizations: TokenBillCustomizations?): Brush {