Hopper (iOS) — Jetpack Compose Implementation Guide
Companion to DESIGN.md — the framework-neutral spec. This file ports Hopper’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 (Hopper’s clean white canvas, friendly red accent, the green/red price-prediction heatmap) while making everything idiomatic Android —
NavigationBarinstead of a UITabBar,Brushgradient legend instead of a CAGradientLayer,sp/dpinstead ofpt.
Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ for images.
1. Color Tokens
// ui/theme/HopperColors.kt
import androidx.compose.ui.graphics.Color
object HopperColors {
// Canvas & Surfaces
val Canvas = Color(0xFFFFFFFF)
val Surface = Color(0xFFF5F5F7)
val Divider = Color(0xFFE5E5EA)
val SurfacePressed = Color(0xFFEBEBF0)
// Text
val TextPrimary = Color(0xFF1D1D1F)
val TextSecondary = Color(0xFF6E6E73)
val TextTertiary = Color(0xFFA1A1A6)
// Brand
val Red = Color(0xFFFA4747)
val RedPressed = Color(0xFFE03A3A)
val BuyGreen = Color(0xFF34C759)
// Heatmap (prediction scale: cheapest → most expensive)
val BuyBest = Color(0xFF34C759)
val BuyGood = Color(0xFFA8E6B8)
val Neutral = Color(0xFFF5F5F7)
val WaitHigh = Color(0xFFFFC7C7)
val WaitWorst = Color(0xFFFA4747)
// Semantic
val PriceDrop = Color(0xFF34C759)
val ErrorRed = Color(0xFFD70015)
}
Wire it into a Material 3 lightColorScheme so ripples, dividers, and default component colors inherit the brand. Hopper is light-first.
// ui/theme/Theme.kt
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.MaterialTheme
private val HopperScheme = lightColorScheme(
primary = HopperColors.Red,
onPrimary = Color.White,
background = HopperColors.Canvas,
onBackground = HopperColors.TextPrimary,
surface = HopperColors.Canvas,
onSurface = HopperColors.TextPrimary,
surfaceVariant = HopperColors.Surface,
outline = HopperColors.Divider,
error = HopperColors.ErrorRed,
)
@Composable
fun HopperTheme(content: @Composable () -> Unit) =
MaterialTheme(colorScheme = HopperScheme, typography = HopperTypography, content = content)
2. Typography
Hopper’s geometric face is proprietary. Drop substitute Inter TTFs in res/font/ (lowercase, snake_case) and build a FontFamily. Fall back to the system font (Roboto).
// ui/theme/HopperType.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 HopperSans = FontFamily(
Font(R.font.inter_regular, FontWeight.Normal), // 400
Font(R.font.inter_semibold, FontWeight.SemiBold), // 600
Font(R.font.inter_bold, FontWeight.Bold), // 700
)
// Named ramp — mirrors DESIGN.md §3 exactly (pt → sp 1:1)
object HopperText {
val Price = TextStyle(HopperSans, fontWeight = FontWeight.Bold, fontSize = 28.sp, lineHeight = 31.sp, letterSpacing = (-0.5).sp)
val TitleLarge = TextStyle(HopperSans, fontWeight = FontWeight.Bold, fontSize = 28.sp, lineHeight = 32.sp, letterSpacing = (-0.4).sp)
val Section = TextStyle(HopperSans, fontWeight = FontWeight.Bold, fontSize = 22.sp, lineHeight = 26.sp, letterSpacing = (-0.3).sp)
val Route = TextStyle(HopperSans, fontWeight = FontWeight.Bold, fontSize = 17.sp, lineHeight = 21.sp, letterSpacing = (-0.1).sp)
val CardTitle = TextStyle(HopperSans, fontWeight = FontWeight.SemiBold, fontSize = 16.sp, lineHeight = 21.sp, letterSpacing = (-0.1).sp)
val Body = TextStyle(HopperSans, fontWeight = FontWeight.Normal, fontSize = 15.sp, lineHeight = 22.sp)
val Button = TextStyle(HopperSans, fontWeight = FontWeight.Bold, fontSize = 16.sp, lineHeight = 20.sp)
val Verdict = TextStyle(HopperSans, fontWeight = FontWeight.Bold, fontSize = 13.sp, lineHeight = 16.sp, letterSpacing = 0.2.sp)
val Meta = TextStyle(HopperSans, fontWeight = FontWeight.Normal, fontSize = 13.sp, lineHeight = 17.sp)
val CalDay = TextStyle(HopperSans, fontWeight = FontWeight.SemiBold, fontSize = 14.sp, lineHeight = 16.sp)
val CalPrice = TextStyle(HopperSans, fontWeight = FontWeight.SemiBold, fontSize = 10.sp, lineHeight = 12.sp)
val Tab = TextStyle(HopperSans, fontWeight = FontWeight.SemiBold, fontSize = 11.sp, lineHeight = 13.sp, letterSpacing = 0.2.sp)
}
// Map onto Material 3 slots so stock components inherit the brand
val HopperTypography = Typography(
headlineLarge = HopperText.TitleLarge,
headlineSmall = HopperText.Section,
titleMedium = HopperText.Route,
bodyMedium = HopperText.Body,
labelSmall = HopperText.Tab,
)
3. Signature Components
Price-Prediction Calendar Heatmap
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.dp
enum class Prediction { BuyBest, BuyGood, Neutral, WaitHigh, WaitWorst }
private fun Prediction.fill() = when (this) {
Prediction.BuyBest -> HopperColors.BuyBest
Prediction.BuyGood -> HopperColors.BuyGood
Prediction.Neutral -> HopperColors.Neutral
Prediction.WaitHigh -> HopperColors.WaitHigh
Prediction.WaitWorst -> HopperColors.WaitWorst
}
private fun Prediction.fg() =
if (this == Prediction.BuyBest || this == Prediction.WaitWorst) Color.White else HopperColors.TextPrimary
@Composable
fun DayCell(
day: Int,
price: String,
prediction: Prediction,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val haptics = LocalHapticFeedback.current
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 1.05f else 1f, spring(dampingRatio = 0.6f), label = "cell")
// Heatmap "breathes" when the prediction changes
val bg by animateColorAsState(prediction.fill(), tween(300), label = "fill")
Column(
modifier
.scale(scale)
.clip(RoundedCornerShape(8.dp))
.background(bg)
.then(
if (selected) Modifier.border(2.dp, HopperColors.TextPrimary, RoundedCornerShape(8.dp))
else Modifier
)
.height(44.dp)
.clickable(interaction, indication = null) {
haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove) // ~iOS light impact
onClick()
},
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text("$day", style = HopperText.CalDay, color = prediction.fg())
Text(price, style = HopperText.CalPrice, color = prediction.fg().copy(alpha = 0.85f))
}
}
@Composable
fun HeatmapLegend() {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Box(
Modifier
.fillMaxWidth()
.height(6.dp)
.clip(RoundedCornerShape(3.dp))
.background(
Brush.horizontalGradient(
listOf(HopperColors.BuyBest, HopperColors.BuyGood,
HopperColors.Neutral, HopperColors.WaitHigh, HopperColors.WaitWorst)
)
)
)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text("Buy", style = HopperText.Meta, color = HopperColors.BuyGreen)
Text("Wait", style = HopperText.Meta, color = HopperColors.Red)
}
}
}
Fare Verdict Pill
import androidx.compose.foundation.shape.CircleShape
enum class Verdict { Buy, Wait }
@Composable
fun VerdictPill(verdict: Verdict, detail: String? = null, modifier: Modifier = Modifier) {
Row(
modifier
.clip(CircleShape)
.background(if (verdict == Verdict.Buy) HopperColors.BuyGreen else HopperColors.Red)
.padding(horizontal = 12.dp, vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(if (verdict == Verdict.Buy) "BUY" else "WAIT", style = HopperText.Verdict, color = Color.White)
if (detail != null) Text("· $detail", style = HopperText.Meta, color = Color.White.copy(alpha = 0.9f))
}
}
Fare Card
import androidx.compose.foundation.border
import androidx.compose.ui.draw.shadow
@Composable
fun FareCard(
route: String,
airline: String,
duration: String,
price: String,
verdict: Verdict,
confidence: Int,
) {
val filled = ((confidence / 100f) * 4).toInt()
val accent = if (verdict == Verdict.Buy) HopperColors.BuyGreen else HopperColors.Red
Column(
Modifier
.fillMaxWidth()
.shadow(16.dp, RoundedCornerShape(16.dp), spotColor = Color.Black.copy(alpha = 0.06f))
.clip(RoundedCornerShape(16.dp))
.background(HopperColors.Canvas)
.border(1.dp, HopperColors.Divider, RoundedCornerShape(16.dp))
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(route, style = HopperText.Route, color = HopperColors.TextPrimary)
Text("$airline · $duration", style = HopperText.Meta, color = HopperColors.TextSecondary)
}
Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Text(price, style = HopperText.Price, color = HopperColors.TextPrimary)
VerdictPill(verdict, if (verdict == Verdict.Buy) "won't drop" else "cheaper soon")
}
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
repeat(4) { i ->
Box(
Modifier
.weight(1f).height(6.dp).clip(CircleShape)
.background(if (i < filled) accent else HopperColors.Divider)
)
}
}
Text("Hopper is $confidence% confident", style = HopperText.Meta, color = HopperColors.TextSecondary)
}
HopButton("Watch this trip") {}
}
}
Watch-Price Toggle
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material3.Icon
@Composable
fun WatchToggle(on: Boolean, onToggle: () -> Unit) {
val haptics = LocalHapticFeedback.current
val offset by animateDpAsState(if (on) 22.dp else 0.dp, spring(dampingRatio = 0.7f), label = "knob")
Box(
Modifier
.size(width = 52.dp, height = 32.dp)
.clip(CircleShape)
.background(if (on) HopperColors.Red else HopperColors.Divider)
.clickable(remember { MutableInteractionSource() }, indication = null) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
onToggle()
}
.padding(2.dp),
contentAlignment = Alignment.CenterStart,
) {
Box(
Modifier
.offset(x = offset)
.size(28.dp)
.shadow(4.dp, CircleShape, spotColor = Color.Black.copy(alpha = 0.12f))
.clip(CircleShape)
.background(Color.White),
contentAlignment = Alignment.Center,
) {
if (on) Icon(Icons.Filled.Notifications, contentDescription = null,
tint = HopperColors.Red, modifier = Modifier.size(12.dp))
}
}
}
Primary Button
@Composable
fun HopButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier) {
val haptics = LocalHapticFeedback.current
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.98f else 1f, label = "btn")
Box(
modifier
.fillMaxWidth()
.scale(scale)
.clip(RoundedCornerShape(14.dp))
.background(if (pressed) HopperColors.RedPressed else HopperColors.Red)
.clickable(interaction, indication = null) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
onClick()
}
.padding(vertical = 16.dp),
contentAlignment = Alignment.Center,
) { Text(text, style = HopperText.Button, color = Color.White) }
}
4. Bottom Navigation (Tab Bar)
Use Material 3 NavigationBar. Hopper’s iOS tab bar is .regularMaterial blur; Android has no first-class live blur, so use a 96%-opaque white surface. Active tint is Hopper Red — red is the active indicator.
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
@Composable
fun HopperBottomBar(selected: Int, onSelect: (Int) -> Unit) {
NavigationBar(containerColor = HopperColors.Canvas.copy(alpha = 0.96f), tonalElevation = 0.dp) {
val items = listOf(
"Home" to Icons.Filled.Home,
"Watches" to Icons.Filled.Notifications,
"Trips" to Icons.Filled.Work,
"Profile" to Icons.Filled.Person,
)
items.forEachIndexed { i, (label, icon) ->
NavigationBarItem(
selected = selected == i,
onClick = { onSelect(i) },
icon = { Icon(icon, contentDescription = label, modifier = Modifier.size(24.dp)) },
label = { Text(label, style = HopperText.Tab) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = HopperColors.Red,
selectedTextColor = HopperColors.Red,
unselectedIconColor = HopperColors.TextSecondary,
unselectedTextColor = HopperColors.TextSecondary,
indicatorColor = Color.Transparent, // no Material pill — Hopper has none
),
)
}
}
}
5. Navigation
The top bar carries the screen title (left, HopperText.TitleLarge) and a profile avatar (right). Below it sits the trip-builder search card — a white RoundedCornerShape(16.dp) block, 1.dp HopperColors.Divider border, with stacked “From / To / Dates / Travelers” rows separated by hairline Dividers and a circular red swap-route button on the From/To boundary. On scroll, collapse it to a compact bar; Android has no live blur, so use the 96%-opaque white surface.
6. Motion
| Moment | Compose recipe |
|---|---|
| Calendar date select | animateFloatAsState 1 → 1.05 spring(dampingRatio = 0.6f); HapticFeedbackType.TextHandleMove |
| Heatmap color shift | animateColorAsState(prediction.fill(), tween(300)) per cell |
| Watch toggle arm | animateDpAsState knob spring(dampingRatio = 0.7f), track color crossfade; HapticFeedbackType.LongPress |
| Price-drop celebration | bottom sheet slideInVertically + an animated counter (animateIntAsState) ticking the drop |
| Verdict pill entrance | animateFloatAsState 0.94 → 1 spring(dampingRatio = 0.6f) |
// Price-drop counter
val shown by animateIntAsState(targetValue = droppedAmount, animationSpec = tween(700), label = "drop")
Text("↓ $$shown", style = HopperText.Price, color = HopperColors.PriceDrop)
Haptics: prefer LocalHapticFeedback. For richer control use LocalView.current.performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK) (API 30+) or a Vibrator VibrationEffect.createOneShot(10, ...) to approximate iOS’s .medium impact.
7. Icons
The closest first-party set is androidx.compose.material:material-icons-extended. For exact parity with Hopper’s custom glyphs (including the bunny), export them as vector drawables and load via ImageVector.vectorResource(R.drawable.…).
| Purpose | SF Symbol (iOS) | Material Icon (Compose) |
|---|---|---|
| Watch / bell | bell / bell.fill |
Icons.Outlined.Notifications / Icons.Filled.Notifications |
| Search | magnifyingglass |
Icons.Filled.Search |
| Swap route | arrow.up.arrow.down |
Icons.Filled.SwapVert |
| Calendar | calendar |
Icons.Filled.CalendarMonth |
| Plane | airplane |
Icons.Filled.Flight |
| Price drop | arrow.down |
Icons.Filled.ArrowDownward |
| Prediction info | info.circle |
Icons.Outlined.Info |
| Back | chevron.left |
Icons.AutoMirrored.Filled.ArrowBack |
| Home (tab) | house.fill |
Icons.Filled.Home |
| Watches (tab) | bell.fill |
Icons.Filled.Notifications |
| Trips (tab) | suitcase.fill |
Icons.Filled.Work |
| Profile (tab) | person.fill |
Icons.Filled.Person |
8. Minimum SDK & Accessibility Notes
- minSdk 24 (Compose floor is 21; modern motion is comfortable at 24).
compileSdk 34+, Compose BOM2024.09+, Kotlin2.0+. - Edge-to-edge: call
enableEdgeToEdge()in theActivity; the white canvas wantsWindowCompatdark-content system bars. ApplyModifier.windowInsetsPadding(WindowInsets.systemBars)orScaffoldinsets so the calendar and tab bar clear the gesture nav. - Font scaling:
sphonors the user’s font scale automatically — keep it on the predicted price, route titles, body. Pin layout-sensitive units (calendar day/price labels, verdict pill, tab labels) by deriving size fromdpor wrapping inCompositionLocalProvider(LocalDensity provides Density(density, fontScale = 1f)). - TalkBack: never rely on color alone — give each calendar cell a
contentDescriptionlike"12, predicted $189, good price to buy"; give the verdict block one combined description; mark the watch toggle withRole.Switchand astateDescription. - Color-blind safety: the per-cell price text and the literal “BUY”/“WAIT” pill label ensure the red/green heatmap is never the only channel of meaning. Consider a subtle icon (down-arrow for buy, hourglass for wait) on the strongest cells.
- Touch targets: Material guidance is 48.dp minimum. The CTA clears it; ensure each 44.dp calendar cell and the 52×32.dp toggle have ≥48.dp effective touch via padding.
- Contrast:
#6E6E73on#FFFFFFpasses WCAG AA at 13sp+. Light heatmap tints keep dark text; strong fills use white — validate both. - Dynamic color: do not enable Material You
dynamicLightColorScheme()— Hopper’s brand requires the fixed white canvas and the friendly-red accent regardless of wallpaper.