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 @@ -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
Expand Down Expand Up @@ -84,12 +91,50 @@ 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()
// 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 = "$",
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 - watermarkInset.toPx(),
y = -watermarkInset.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),
Expand Down Expand Up @@ -143,6 +188,12 @@ 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). 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

/** 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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<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 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)")
}
}
Loading