Robinhood (iOS) — Jetpack Compose Implementation Guide
Companion to DESIGN.md — the framework-neutral spec. This file ports Robinhood’s visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, paste-ready @Composables, Modifiers, motion, and haptics.
Why a Compose guide for an iOS-referenced app? The DESIGN.md tokens are platform-neutral. This file keeps the visual identity (the stark white canvas with true-black text, direction-bound green/orange, the full-bleed portfolio chart with its draggable scrubber, tabular numerals everywhere) while making everything idiomatic Android — a
Canvas-drawn line graph withpointerInputscrubbing instead of SwiftCharts,NavigationBarinstead of a UITabBar,sp/dpinstead ofpt.
Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ for fetched company/ticker logos.
1. Color Tokens
// ui/theme/RobinhoodColors.kt
import androidx.compose.ui.graphics.Color
object RobinhoodColors {
// Canvas & Surfaces (Light)
val CanvasLight = Color(0xFFFFFFFF)
val SurfaceGray = Color(0xFFF7F7F7)
val SurfaceGray2 = Color(0xFFEFEFEF)
val DividerLight = Color(0xFFE6E6E6)
// Text (Light) — true black, no warm grays
val TextPrimary = Color(0xFF000000)
val TextSecondary = Color(0xFF5C6166)
val TextTertiary = Color(0xFF9B9EA3)
val TextMuted = Color(0xFFC2C5CA)
// Brand — color is bound to direction
val Green = Color(0xFF00C805) // up
val GreenPressed = Color(0xFF00A904)
val GreenDim = Color(0xFF21CE99) // heritage
val GreenBg = Color(0xFFE6F9E0)
val Red = Color(0xFFFF5000) // down — a saturated ORANGE, not red
val RedBg = Color(0xFFFFEDE5)
// Semantic
val ErrorTrue = Color(0xFFE62232) // a TRUE red — hard errors only, distinct from loss orange
val Warning = Color(0xFFFFB800)
val Info = Color(0xFF1D6FF2)
// Crypto accents
val Bitcoin = Color(0xFFF7931A)
val Ethereum = Color(0xFF627EEA)
// Dark mode — true black; the green line glows electric
val DarkCanvas = Color(0xFF000000)
val DarkSurface1 = Color(0xFF181B1F)
val DarkSurface2 = Color(0xFF23272D)
val DarkDivider = Color(0xFF2D3138)
val DarkTextPrimary = Color(0xFFFFFFFF)
val DarkTextSecondary = Color(0xFFA4A8AD)
}
Robinhood’s default is a stark light canvas — clean tech-product, deliberate counterpoint to legacy brokerages. Wire a Material 3 lightColorScheme/darkColorScheme. Do not map green/orange onto primary/error — they are direction signals, not theme roles. Keep primary as black (the universal Trade CTA color) and the brand greens/orange as explicit tokens so no stock component can accidentally use them.
// ui/theme/Theme.kt
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.foundation.isSystemInDarkTheme
private val RobinhoodLight = lightColorScheme(
primary = RobinhoodColors.TextPrimary, // black — the universal Trade CTA
onPrimary = Color.White,
background = RobinhoodColors.CanvasLight,
onBackground = RobinhoodColors.TextPrimary,
surface = RobinhoodColors.CanvasLight,
onSurface = RobinhoodColors.TextPrimary,
surfaceVariant = RobinhoodColors.SurfaceGray,
outline = RobinhoodColors.DividerLight,
error = RobinhoodColors.ErrorTrue, // the TRUE red, not the loss orange
)
private val RobinhoodDark = darkColorScheme(
primary = RobinhoodColors.DarkTextPrimary, // white — Trade CTA on black
onPrimary = RobinhoodColors.DarkCanvas,
background = RobinhoodColors.DarkCanvas,
onBackground = RobinhoodColors.DarkTextPrimary,
surface = RobinhoodColors.DarkSurface1,
onSurface = RobinhoodColors.DarkTextPrimary,
surfaceVariant = RobinhoodColors.DarkSurface2,
outline = RobinhoodColors.DarkDivider,
error = RobinhoodColors.ErrorTrue,
)
@Composable
fun RobinhoodTheme(
dark: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) = MaterialTheme(
colorScheme = if (dark) RobinhoodDark else RobinhoodLight,
typography = RobinhoodTypography,
content = content,
)
2. Typography
Capsule Sans (drawn by XYZ Type for Robinhood) is proprietary. Drop the TTFs in res/font/ and build two families — CapsuleSansText for body, CapsuleSansDisplay for hero values. Fall back to the system font; on the fallback, always apply FontFeatureSettings("tnum") (tabular numerals) — this is the single most important typography rule on a brokerage app: columns of prices must align as the user scrolls.
// ui/theme/RobinhoodType.kt
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val CapsuleText = FontFamily(
Font(R.font.capsule_sans_text_regular, FontWeight.Normal), // 400
Font(R.font.capsule_sans_text_medium, FontWeight.Medium), // 500
Font(R.font.capsule_sans_text_semibold, FontWeight.SemiBold), // 600
Font(R.font.capsule_sans_text_bold, FontWeight.Bold), // 700
)
val CapsuleDisplay = FontFamily(
Font(R.font.capsule_sans_display_bold, FontWeight.Bold), // hero values 40sp+
)
// Tabular numerals — non-negotiable on every price / percent / share count
private const val TNUM = "tnum"
// Named ramp — mirrors DESIGN.md §3 exactly (pt → sp 1:1)
object RobinhoodText {
val PortfolioHero = TextStyle(CapsuleDisplay, fontWeight = FontWeight.Bold, fontSize = 40.sp, lineHeight = 40.sp, letterSpacing = (-0.5).sp, fontFeatureSettings = TNUM)
val DayChange = TextStyle(CapsuleText, fontWeight = FontWeight.Medium, fontSize = 16.sp, lineHeight = 19.sp, fontFeatureSettings = TNUM)
val ScreenTitle = TextStyle(CapsuleText, fontWeight = FontWeight.Bold, fontSize = 22.sp, lineHeight = 25.sp, letterSpacing = (-0.2).sp)
val SectionHeader = TextStyle(CapsuleText, fontWeight = FontWeight.SemiBold, fontSize = 18.sp, lineHeight = 22.sp, letterSpacing = (-0.1).sp)
val PositionTitle = TextStyle(CapsuleText, fontWeight = FontWeight.SemiBold, fontSize = 16.sp, lineHeight = 21.sp)
val Ticker = TextStyle(CapsuleText, fontWeight = FontWeight.Medium, fontSize = 13.sp, lineHeight = 17.sp, letterSpacing = 0.3.sp)
val PositionValue = TextStyle(CapsuleText, fontWeight = FontWeight.Medium, fontSize = 17.sp, lineHeight = 20.sp, fontFeatureSettings = TNUM)
val PositionChange = TextStyle(CapsuleText, fontWeight = FontWeight.Medium, fontSize = 13.sp, lineHeight = 17.sp, fontFeatureSettings = TNUM)
val ChartAxis = TextStyle(CapsuleText, fontWeight = FontWeight.Medium, fontSize = 11.sp, lineHeight = 11.sp, letterSpacing = 0.2.sp, fontFeatureSettings = TNUM)
val Body = TextStyle(CapsuleText, fontWeight = FontWeight.Normal, fontSize = 15.sp, lineHeight = 21.sp)
val BodySmall = TextStyle(CapsuleText, fontWeight = FontWeight.Normal, fontSize = 13.sp, lineHeight = 18.sp)
val ButtonPrimary = TextStyle(CapsuleText, fontWeight = FontWeight.SemiBold, fontSize = 16.sp, lineHeight = 16.sp)
val RangeChip = TextStyle(CapsuleText, fontWeight = FontWeight.SemiBold, fontSize = 13.sp, lineHeight = 13.sp, letterSpacing = 0.3.sp)
val TabLabel = TextStyle(CapsuleText, fontWeight = FontWeight.SemiBold, fontSize = 10.sp, lineHeight = 10.sp, letterSpacing = 0.2.sp)
val AllCaps = TextStyle(CapsuleText, fontWeight = FontWeight.Bold, fontSize = 11.sp, lineHeight = 13.sp, letterSpacing = 0.6.sp)
}
val RobinhoodTypography = Typography(
displayMedium = RobinhoodText.PortfolioHero,
headlineSmall = RobinhoodText.ScreenTitle,
titleMedium = RobinhoodText.PositionTitle,
bodyMedium = RobinhoodText.Body,
labelSmall = RobinhoodText.TabLabel,
)
3. Signature Components
Portfolio Hero (top of Investing tab)
BUYING POWER all-caps label → the giant tabular $ value in Capsule Display → the day-change line in green (up) or orange (down) — no pill chrome, just colored text. The value morphs in <100ms during chart scrubbing.
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import java.text.NumberFormat
import java.util.Locale
private val usd = NumberFormat.getCurrencyInstance(Locale.US)
@Composable
fun PortfolioHero(
portfolioValue: Double,
dayChange: Double,
dayChangePct: Double,
modifier: Modifier = Modifier,
) {
val up = dayChange >= 0
val changeColor = if (up) RobinhoodColors.Green else RobinhoodColors.Red
val sign = if (up) "+" else "−"
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("BUYING POWER", style = RobinhoodText.AllCaps, color = RobinhoodColors.TextSecondary)
// Value morphs during scrub
AnimatedContent(
targetState = portfolioValue,
transitionSpec = { (fadeIn(tween(90)) togetherWith fadeOut(tween(90))) },
label = "portfolioValue",
) { v ->
Text(usd.format(v), style = RobinhoodText.PortfolioHero, color = RobinhoodColors.TextPrimary)
}
Text(
"$sign${usd.format(kotlin.math.abs(dayChange))} ($sign${"%.2f".format(dayChangePct * 100)}%) Today",
style = RobinhoodText.DayChange,
color = changeColor,
)
}
}
Portfolio Chart with Scrubber (THE Robinhood interaction)
Swift Charts has no Compose equivalent that supports the live scrubber idiomatically — draw it with Canvas and capture the drag with pointerInput. A 2.dp stroke in the day’s color, no fill below the line, full-bleed. Dragging snaps the bubble to the nearest data point and drives the hero value (hoist scrubValue to the screen and feed it back into PortfolioHero).
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.runtime.*
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
@Composable
fun PortfolioChart(
points: List<Float>, // y-values, evenly spaced in x
isUp: Boolean,
onScrub: (index: Int?) -> Unit, // null on release → hero snaps back to current
modifier: Modifier = Modifier,
) {
val lineColor = if (isUp) RobinhoodColors.Green else RobinhoodColors.Red
var activeIndex by remember { mutableStateOf<Int?>(null) }
val haptics = LocalHapticFeedback.current
val strokePx = with(androidx.compose.ui.platform.LocalDensity.current) { 2.dp.toPx() }
Canvas(
modifier = modifier
.fillMaxWidth()
.height(220.dp) // 180 on small phones, 240 on Max — derive from windowSizeClass
.pointerInput(points) {
detectDragGestures(
onDragStart = { off ->
val idx = ((off.x / size.width) * (points.size - 1)).toInt().coerceIn(points.indices)
activeIndex = idx; onScrub(idx)
haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
},
onDrag = { change, _ ->
val idx = ((change.position.x / size.width) * (points.size - 1)).toInt().coerceIn(points.indices)
if (idx != activeIndex) {
activeIndex = idx; onScrub(idx) // instant — no smoothing
}
},
onDragEnd = { activeIndex = null; onScrub(null) },
onDragCancel = { activeIndex = null; onScrub(null) },
)
},
) {
if (points.size < 2) return@Canvas
val maxV = points.max(); val minV = points.min()
val range = (maxV - minV).takeIf { it != 0f } ?: 1f
fun px(i: Int) = size.width * i / (points.size - 1)
fun py(v: Float) = size.height * (1f - (v - minV) / range)
val path = Path().apply {
moveTo(px(0), py(points[0]))
for (i in 1 until points.size) lineTo(px(i), py(points[i]))
}
drawPath(path, color = lineColor, style = Stroke(width = strokePx)) // no fill below
activeIndex?.let { i ->
// thin vertical rule + solid black point bubble
drawLine(RobinhoodColors.TextPrimary, Offset(px(i), 0f), Offset(px(i), size.height), strokeWidth = 1f)
drawCircle(RobinhoodColors.TextPrimary, radius = 8f, center = Offset(px(i), py(points[i])))
}
}
}
Chart Range Chips
Text-only, no container. Active flips to the chart-line color with a 2.dp underline.
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
@Composable
fun ChartRangeChips(
selected: String,
isUp: Boolean,
onSelect: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val active = if (isUp) RobinhoodColors.Green else RobinhoodColors.Red
val ranges = listOf("1D", "1W", "1M", "3M", "YTD", "1Y", "5Y", "ALL")
val haptics = LocalHapticFeedback.current
Row(
modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
ranges.forEach { r ->
val on = r == selected
Column(
horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally,
modifier = Modifier
.weight(1f)
.heightIn(min = 44.dp) // 44dp min touch even though visual is smaller
.clickableNoRipple {
onSelect(r)
haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
},
) {
Text(
r,
style = RobinhoodText.RangeChip,
color = if (on) active else RobinhoodColors.TextSecondary,
modifier = Modifier.padding(vertical = 6.dp),
)
Box(
Modifier
.fillMaxWidth()
.height(2.dp)
.background(if (on) active else Color.Transparent)
)
}
}
}
}
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.ui.composed
fun Modifier.clickableNoRipple(onClick: () -> Unit): Modifier = composed {
clickable(remember { MutableInteractionSource() }, indication = null, onClick = onClick)
}
Position Row
56.dp tall. 32.dp rounded-square logo, company name above tracked ticker + share count, market value + day-change (tabular) right-aligned, 0.5.dp bottom divider.
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.Alignment
import coil.compose.AsyncImage
@Composable
fun PositionRow(
ticker: String,
company: String,
shares: Double,
marketValue: Double,
dayChange: Double,
dayChangePct: Double,
logoUrl: String?,
modifier: Modifier = Modifier,
) {
val up = dayChange >= 0
val changeColor = if (up) RobinhoodColors.Green else RobinhoodColors.Red
val sign = if (up) "+" else "−"
Row(
modifier = modifier
.fillMaxWidth()
.height(56.dp)
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
if (logoUrl != null) {
AsyncImage(
model = logoUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.size(32.dp).clip(RoundedCornerShape(8.dp)),
)
} else {
Box(
Modifier.size(32.dp).clip(RoundedCornerShape(8.dp)).background(RobinhoodColors.SurfaceGray),
contentAlignment = Alignment.Center,
) { Text(ticker.take(1), style = RobinhoodText.PositionTitle, color = RobinhoodColors.TextPrimary) }
}
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(company, style = RobinhoodText.PositionTitle, color = RobinhoodColors.TextPrimary, maxLines = 1)
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Text(ticker, style = RobinhoodText.Ticker, color = RobinhoodColors.TextSecondary)
Text("· ${shares.formatShares()} shares", style = RobinhoodText.BodySmall, color = RobinhoodColors.TextSecondary)
}
}
Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(usd.format(marketValue), style = RobinhoodText.PositionValue, color = RobinhoodColors.TextPrimary)
Text(
"$sign${usd.format(kotlin.math.abs(dayChange))} ($sign${"%.2f".format(dayChangePct * 100)}%)",
style = RobinhoodText.PositionChange,
color = changeColor,
)
}
}
Spacer(Modifier.fillMaxWidth().height(0.5.dp).background(RobinhoodColors.DividerLight))
}
private fun Double.formatShares(): String =
if (this % 1.0 == 0.0) toInt().toString() else "%.4f".format(this).trimEnd('0').trimEnd('.')
Trade Button (sticky footer) & Buy/Sell Pair
The universal Trade CTA is black — green/orange are reserved for Buy/Sell inside the trade flow.
@Composable
fun TradeButton(label: String, onClick: () -> Unit, modifier: Modifier = Modifier) {
val haptics = LocalHapticFeedback.current
Box(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.height(48.dp)
.clip(RoundedCornerShape(8.dp))
.background(RobinhoodColors.TextPrimary) // black, not green
.clickableNoRipple {
haptics.performHapticFeedback(HapticFeedbackType.LongPress) // ~impact medium
onClick()
},
contentAlignment = Alignment.Center,
) { Text(label, style = RobinhoodText.ButtonPrimary, color = Color.White) }
}
@Composable
fun BuySellButtons(onBuy: () -> Unit, onSell: () -> Unit, modifier: Modifier = Modifier) {
Row(modifier.fillMaxWidth().padding(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Box(
Modifier.weight(1f).height(48.dp).clip(RoundedCornerShape(8.dp))
.background(RobinhoodColors.Red).clickableNoRipple(onSell),
contentAlignment = Alignment.Center,
) { Text("Sell", style = RobinhoodText.ButtonPrimary, color = Color.White) }
Box(
Modifier.weight(1f).height(48.dp).clip(RoundedCornerShape(8.dp))
.background(RobinhoodColors.Green).clickableNoRipple(onBuy),
contentAlignment = Alignment.Center,
) { Text("Buy", style = RobinhoodText.ButtonPrimary, color = Color.White) }
}
}
4. The Portfolio Chart Scrubber — wiring the live hero update
§3 draws the chart; this is the data flow that makes the scrubber Robinhood’s signature. The screen owns the scrub index; while dragging, the hero shows the scrubbed value and on release snaps back to current. The numeric morph runs in <100ms via AnimatedContent.
@Composable
fun InvestingScreen(
series: List<Pair<Long, Float>>, // (epochMillis, portfolioValue)
currentValue: Double,
dayChange: Double,
dayChangePct: Double,
) {
var scrubIndex by remember { mutableStateOf<Int?>(null) }
val isUp = dayChange >= 0
val shownValue = scrubIndex?.let { series[it].second.toDouble() } ?: currentValue
Column {
PortfolioHero(
portfolioValue = shownValue, // updates live during scrub
dayChange = dayChange,
dayChangePct = dayChangePct,
)
PortfolioChart(
points = series.map { it.second },
isUp = isUp,
onScrub = { scrubIndex = it }, // null on release → snaps back
)
ChartRangeChips(selected = "1D", isUp = isUp, onSelect = { /* reload series */ })
}
}
Reduce-motion: skip the AnimatedContent morph during scrubbing — swap the value instantly (drag is already 60fps with no smoothing).
5. Bottom Navigation (Tab Bar)
Five labeled destinations in a Material 3 NavigationBar: Investing, Search, Crypto, Cash, Account. Active tint is black (not green — green is the direction signal). Solid surface, 0.5.dp top divider, no blur. Below it, a single sticky black Trade footer on the stock-detail screen.
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
@Composable
fun RobinhoodBottomBar(selected: Int, onSelect: (Int) -> Unit) {
val haptics = LocalHapticFeedback.current
NavigationBar(
containerColor = RobinhoodColors.CanvasLight,
tonalElevation = 0.dp,
) {
val tabs = listOf(
"Investing" to Icons.Filled.ShowChart,
"Search" to Icons.Filled.Search,
"Crypto" to Icons.Filled.CurrencyBitcoin,
"Cash" to Icons.Filled.AccountBalanceWallet,
"Account" to Icons.Filled.AccountCircle,
)
tabs.forEachIndexed { i, (label, icon) ->
NavigationBarItem(
selected = selected == i,
onClick = {
onSelect(i)
haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove) // selection
},
icon = { Icon(icon, label, modifier = Modifier.size(24.dp)) },
label = { Text(label, style = RobinhoodText.TabLabel) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = RobinhoodColors.TextPrimary, // black, not green
selectedTextColor = RobinhoodColors.TextPrimary,
unselectedIconColor = RobinhoodColors.TextTertiary,
unselectedTextColor = RobinhoodColors.TextTertiary,
indicatorColor = Color.Transparent,
),
)
}
}
}
6. Motion
| Moment | Compose recipe |
|---|---|
| Chart scrubber drag | pointerInput + detectDragGestures, snap to nearest index, no smoothing (instant) |
| Portfolio value update | AnimatedContent cross-fade tween(90) — <100ms numeric morph |
| Buy/Sell confirm | animateFloatAsState 1 → 0.97 → 1 spring(dampingRatio = 0.7f) + HapticFeedbackType.LongPress (impact medium) |
| Order placed | green particle burst (Canvas + Animatable over ~800ms) + HapticFeedbackType.Confirm (success) |
| Tab switch | instant icon/label color change + HapticFeedbackType.TextHandleMove (selection) |
| Range chip switch | animateColorAsState text + underline cross-fade spring(stiffness = 700f) |
| Position row tap | animateColorAsState bg white → SurfaceGray over 150ms before navigating |
// Buy/Sell press scale
@Composable
fun rememberPressScale(interaction: MutableInteractionSource): Float {
val pressed by interaction.collectIsPressedAsState()
val scale by animateFloatAsState(
if (pressed) 0.97f else 1f,
spring(dampingRatio = 0.7f, stiffness = 500f),
label = "pressScale",
)
return scale
}
Haptics: prefer LocalHapticFeedback. The scrub tick and tab switch use HapticFeedbackType.TextHandleMove (selection-like); Buy/Sell confirm uses LongPress (impact medium); order success uses HapticFeedbackType.Confirm. For finer control, Vibrator VibrationEffect.createOneShot(10, ...) approximates iOS’s medium impact.
7. Icons
Robinhood uses custom glyphs; the closest first-party set is androidx.compose.material:material-icons-extended. The portfolio chart is hand-drawn (§3, not an icon). Custom marks (success confetti, order states) ship as vector drawables via ImageVector.vectorResource(...).
| Purpose | SF Symbol (iOS) | Material Icon (Compose) |
|---|---|---|
| Investing (tab) | chart.line.uptrend.xyaxis |
Icons.Filled.ShowChart |
| Search (tab) | magnifyingglass |
Icons.Filled.Search |
| Crypto (tab) | bitcoinsign.circle |
Icons.Filled.CurrencyBitcoin |
| Cash (tab) | wallet.pass |
Icons.Filled.AccountBalanceWallet |
| Account (tab) | person.crop.circle |
Icons.Filled.AccountCircle |
| Search (in field) | magnifyingglass |
Icons.Filled.Search |
| Set alert | bell / bell.fill |
Icons.Filled.NotificationsNone / Icons.Filled.Notifications |
| Add to list | plus.circle |
Icons.Filled.AddCircleOutline |
| Back | chevron.left |
Icons.AutoMirrored.Filled.ArrowBack |
| Share | square.and.arrow.up |
Icons.Filled.IosShare |
| Order placed (success) | checkmark.circle.fill |
Icons.Filled.CheckCircle |
| Order failed | xmark.circle.fill |
Icons.Filled.Cancel |
8. Minimum SDK & Accessibility Notes
- minSdk 24.
compileSdk 34+, Compose BOM2024.09+, Kotlin2.0+. The chart is a hand-drawnCanvas(no extra dependency) so it works at minSdk with no Swift-Charts equivalent needed. - Edge-to-edge: call
enableEdgeToEdge(). The light canvas wants dark-content system bars (white wants light-content on dark mode). The chart goes edge-to-edge; applyScaffoldinsets only to the hero, lists, and the sticky Trade footer (clears gesture nav). - Tabular numerals are critical: every
Textrendering a price, percent, or share count must usefontFeatureSettings = "tnum"(baked into theRobinhoodTextstyles). On the fallback font this guarantees columns align as the user scrolls a watchlist — the brand-defining rule. - Font scaling:
sphonors user scale on body, secondary text, list rows. The portfolio hero (40sp) scales but clamp at 56sp to preserve layout; chart axis labels (11sp), range chips, and tab labels are fixed (layout-sensitive) — wrap in a fixed-fontScaleLocalDensity. Position values honor scaling but clamp at 22sp. - TalkBack: the hero should announce “Buying power $24,847.93, up $847.93, up 3.52 percent today” via one merged
semantics. Position rows: “Apple Inc., A A P L, 4 shares, market value $1,247.92, up $24.13, up 1.97 percent today”. The chart needs acontentDescriptionsummarizing the trend plus an accessible “scrub to time” affordance (the drag gesture alone is not TalkBack-reachable). - Color-blind: green/orange is the primary direction signal — always pair with the
+/−sign prefix (already in the components) so it is never color-only. WhenisHighTextContrastEnabled/differentiate-without-color is set, prefix an up/down arrow on the change text. - Touch targets: range chips are visually small but must keep a 44.dp min hit (
heightIn(min = 44.dp), already applied); Trade button is 48.dp; tab items 56.dp. - Contrast:
#000000on#FFFFFFis maximum;#5C6166on white passes AA at 14sp+;#9B9EA3(inactive tab labels) passes AA only at 18sp+ — keep the 10sp tab label at#000000once selected. Dark mode: green#00C805and orange#FF5000stay identical (they read electric on#000000). - Dynamic color: do not enable Material You
dynamicColorScheme()— the stark white/true-black canvas and the direction-bound green/orange are the brand. A wallpaper-tinted primary would break the “color = direction” contract and the clean tech-product aesthetic.