Grubhub (iOS) — Jetpack Compose Implementation Guide
Companion to DESIGN.md — the framework-neutral spec. This file ports Grubhub’s visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, paste-ready @Composables for the restaurant list and live order tracking, 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 (white canvas, wide food photography, Grubhub Red actions + Orange savings + Gold Perks + Blue tracking) while making everything idiomatic Android —
NavigationBarinstead of a UITabBar,Scaffold+BottomSheetScaffoldinstead of UIKit sheets,sp/dpinstead ofpt.
Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ for restaurant photos and avatars. Grubhub’s palette is fixed brand colors, so dynamic color extraction (Palette / Material You) is not used.
1. Color Tokens
// ui/theme/GrubhubColors.kt
import androidx.compose.ui.graphics.Color
object GrubhubColors {
// Brand (interactive)
val Red = Color(0xFFF63440)
val RedPressed = Color(0xFFD2202B)
val Orange = Color(0xFFFF8000)
val OrangePressed= Color(0xFFE06E00)
// Membership & status
val PerksGold = Color(0xFFFFB81C)
val TrackBlue = Color(0xFF00A0DF)
val RatingGreen = Color(0xFF18A957)
// Canvas & Surfaces (Light)
val Canvas = Color(0xFFFFFFFF)
val Surface1 = Color(0xFFF6F6F4)
val Surface2 = Color(0xFFECECEA)
val Divider = Color(0xFFE6E6E6)
// Canvas & Surfaces (Dark)
val DarkCanvas = Color(0xFF121212) // warm near-black — NOT pure black
val DarkSurface1 = Color(0xFF1C1C1E)
val DarkSurface2 = Color(0xFF262626)
val DarkDivider = Color(0xFF2C2C2E)
// Text
val TextPrimary = Color(0xFF1B1B1B)
val TextSecondary = Color(0xFF707070)
val TextTertiary = Color(0xFFA0A0A0)
val DarkTextPrimary = Color(0xFFF2F2F2)
// Semantic
val Error = Color(0xFFF0454F)
val Success = Color(0xFF18A957)
val PerksInk = Color(0xFF1A1206) // near-black on gold/orange
// Tracking banner gradient (dark)
val TrackBgFrom = Color(0xFF11363F)
val TrackBgTo = Color(0xFF0E2A33)
}
Wire it into both schemes. Grubhub is light-first (white, photo-forward); the dark scheme uses a warm #121212, never true black.
// ui/theme/Theme.kt
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.MaterialTheme
private val GrubhubLight = lightColorScheme(
primary = GrubhubColors.Red,
onPrimary = Color.White,
secondary = GrubhubColors.Orange,
background = GrubhubColors.Canvas,
onBackground = GrubhubColors.TextPrimary,
surface = GrubhubColors.Surface1,
onSurface = GrubhubColors.TextPrimary,
surfaceVariant = GrubhubColors.Surface2,
outline = GrubhubColors.Divider,
error = GrubhubColors.Error,
)
private val GrubhubDark = darkColorScheme(
primary = GrubhubColors.Red,
onPrimary = Color.White,
secondary = GrubhubColors.Orange,
background = GrubhubColors.DarkCanvas,
onBackground = GrubhubColors.DarkTextPrimary,
surface = GrubhubColors.DarkSurface1,
onSurface = GrubhubColors.DarkTextPrimary,
surfaceVariant = GrubhubColors.DarkSurface2,
outline = GrubhubColors.DarkDivider,
error = GrubhubColors.Error,
)
@Composable
fun GrubhubTheme(
dark: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) = MaterialTheme(
colorScheme = if (dark) GrubhubDark else GrubhubLight,
typography = GrubhubTypography,
content = content,
)
2. Typography (Material 3)
Grubhub’s brand face is Graphik (proprietary); use Inter (SIL OFL) — drop the TTFs in res/font/. Body 500, titles 700–900; numbers tabular.
// ui/theme/GrubhubType.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.text.style.TextMotion
import androidx.compose.ui.unit.sp
val Inter = FontFamily(
Font(R.font.inter_regular, FontWeight.Normal),
Font(R.font.inter_medium, FontWeight.Medium),
Font(R.font.inter_semibold, FontWeight.SemiBold),
Font(R.font.inter_bold, FontWeight.Bold),
Font(R.font.inter_extrabold, FontWeight.ExtraBold),
Font(R.font.inter_black, FontWeight.Black),
)
// Named ramp — mirrors DESIGN.md §3 exactly (pt → sp 1:1)
object GrubhubText {
val ScreenTitle = TextStyle(Inter, fontWeight = FontWeight.Black, fontSize = 32.sp, lineHeight = 37.sp, letterSpacing = (-0.5).sp)
val RestaurantHero = TextStyle(Inter, fontWeight = FontWeight.ExtraBold, fontSize = 26.sp, lineHeight = 31.sp, letterSpacing = (-0.3).sp)
val Section = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 22.sp, lineHeight = 28.sp, letterSpacing = (-0.2).sp)
val CardTitle = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 18.sp, lineHeight = 23.sp)
val RestaurantName = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 16.sp, lineHeight = 21.sp)
val Body = TextStyle(Inter, fontWeight = FontWeight.Medium, fontSize = 15.sp, lineHeight = 22.sp)
val Price = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 15.sp, lineHeight = 20.sp)
val Meta = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 20.sp)
val Deal = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 13.sp, lineHeight = 18.sp)
val Overline = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 12.sp, lineHeight = 12.sp, letterSpacing = 0.4.sp)
val Button = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 16.sp, lineHeight = 16.sp)
val Tab = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 10.sp, lineHeight = 10.sp, letterSpacing = 0.1.sp)
val Caption = TextStyle(Inter, fontWeight = FontWeight.Medium, fontSize = 12.sp, lineHeight = 16.sp)
}
// Map onto Material 3 slots so stock components inherit the brand
val GrubhubTypography = Typography(
headlineLarge = GrubhubText.ScreenTitle,
headlineMedium = GrubhubText.RestaurantHero,
titleLarge = GrubhubText.Section,
titleMedium = GrubhubText.CardTitle,
bodyMedium = GrubhubText.Body,
labelSmall = GrubhubText.Tab,
)
For tabular figures on prices/ETAs, build a TextStyle.copy(fontFeatureSettings = "tnum").
3. Signature Components
Restaurant Card
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.Icon
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.layout.ContentScale
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
@Composable
fun RestaurantCard(
name: String, cuisine: String, eta: String, fee: String,
rating: Double, deal: String?, perksBadge: String?, imageUrl: String,
modifier: Modifier = Modifier,
) {
var saved by remember { mutableStateOf(false) }
Column(modifier.fillMaxWidth().padding(bottom = 22.dp)) {
Box(
Modifier.fillMaxWidth().aspectRatio(16f / 9f).clip(RoundedCornerShape(14.dp))
) {
AsyncImage(model = imageUrl, contentDescription = name,
modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
if (perksBadge != null) {
Text(perksBadge, style = GrubhubText.Overline, color = GrubhubColors.PerksInk,
modifier = Modifier.padding(10.dp)
.clip(RoundedCornerShape(6.dp))
.background(GrubhubColors.PerksGold)
.padding(horizontal = 8.dp, vertical = 4.dp))
}
Icon(
if (saved) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder,
contentDescription = if (saved) "Saved" else "Save",
tint = if (saved) GrubhubColors.Red else Color.White,
modifier = Modifier.align(Alignment.TopEnd).padding(10.dp)
.size(30.dp).clip(RoundedCornerShape(50))
.background(Color.Black.copy(alpha = 0.45f))
.clickable { saved = !saved }.padding(7.dp),
)
}
Row(Modifier.fillMaxWidth().padding(top = 10.dp), verticalAlignment = Alignment.CenterVertically) {
Text(name, style = GrubhubText.RestaurantName, color = GrubhubColors.TextPrimary)
Spacer(Modifier.weight(1f))
Icon(Icons.Filled.Star, null, tint = GrubhubColors.RatingGreen, modifier = Modifier.size(12.dp))
Spacer(Modifier.width(4.dp))
Text(String.format("%.1f", rating), style = GrubhubText.Deal, color = GrubhubColors.TextPrimary)
}
Text("$cuisine · $eta · $fee", style = GrubhubText.Meta,
color = GrubhubColors.TextSecondary, modifier = Modifier.padding(top = 4.dp))
if (deal != null) {
Text(deal, style = GrubhubText.Deal, color = GrubhubColors.Orange,
modifier = Modifier.padding(top = 5.dp))
}
}
}
Cuisine Chip Rail
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
@Composable
fun CuisineChips(items: List<String>) {
var selected by remember { mutableIntStateOf(0) }
LazyRow(
horizontalArrangement = Arrangement.spacedBy(10.dp),
contentPadding = PaddingValues(horizontal = 16.dp),
) {
itemsIndexed(items) { i, label ->
val active = i == selected
Box(
Modifier
.height(34.dp)
.clip(RoundedCornerShape(50))
.background(if (active) GrubhubColors.Red else GrubhubColors.Surface1)
.border(0.5.dp, if (active) GrubhubColors.Red else GrubhubColors.Divider, RoundedCornerShape(50))
.clickable { selected = i }
.padding(horizontal = 14.dp),
contentAlignment = Alignment.Center,
) {
Text(label, style = GrubhubText.Deal,
color = if (active) Color.White else GrubhubColors.TextSecondary)
}
}
}
}
Quantity Stepper
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Remove
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
@Composable
fun QuantityStepper(quantity: Int, onChange: (Int) -> Unit) {
val haptics = LocalHapticFeedback.current
Row(
Modifier
.clip(RoundedCornerShape(50))
.background(GrubhubColors.Surface1)
.border(0.5.dp, GrubhubColors.Divider, RoundedCornerShape(50))
.padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
StepControl(Icons.Filled.Remove, enabled = quantity > 1) {
haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove); onChange(quantity - 1)
}
Text("$quantity", style = GrubhubText.Price.copy(fontFeatureSettings = "tnum"),
color = GrubhubColors.TextPrimary, modifier = Modifier.widthIn(min = 18.dp))
StepControl(Icons.Filled.Add, enabled = true) {
haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove); onChange(quantity + 1)
}
}
}
@Composable
private fun StepControl(icon: androidx.compose.ui.graphics.vector.ImageVector, enabled: Boolean, onClick: () -> Unit) {
Box(
Modifier.size(30.dp).clip(RoundedCornerShape(50))
.background(GrubhubColors.Red.copy(alpha = if (enabled) 1f else 0.35f))
.clickable(enabled = enabled) { onClick() },
contentAlignment = Alignment.Center,
) { Icon(icon, null, tint = Color.White, modifier = Modifier.size(16.dp)) }
}
Live Order Tracking Banner
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.ui.graphics.Brush
@Composable
fun OrderTrackingBanner(eta: String, restaurant: String, status: String, progress: Float) {
val infinite = rememberInfiniteTransition(label = "pulse")
val scale by infinite.animateFloat(
1f, 1.2f,
infiniteRepeatable(tween(1400), RepeatMode.Reverse), label = "dot",
)
Column(
Modifier.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(Brush.linearGradient(listOf(GrubhubColors.TrackBgFrom, GrubhubColors.TrackBgTo)))
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Box(Modifier.size(8.dp).graphicsLayer { scaleX = scale; scaleY = scale }
.clip(RoundedCornerShape(50)).background(GrubhubColors.TrackBlue))
Text(eta, style = GrubhubText.Deal, color = GrubhubColors.DarkTextPrimary)
Spacer(Modifier.weight(1f))
Text(restaurant, style = GrubhubText.Caption, color = GrubhubColors.TextSecondary)
}
Box(Modifier.fillMaxWidth().height(4.dp).clip(RoundedCornerShape(50))
.background(Color.White.copy(alpha = 0.12f))) {
Box(Modifier.fillMaxWidth(progress).height(4.dp)
.clip(RoundedCornerShape(50)).background(GrubhubColors.TrackBlue))
}
Text(status, style = GrubhubText.Caption.copy(fontWeight = FontWeight.SemiBold),
color = GrubhubColors.TrackBlue)
}
}
Order Tracking Timeline
enum class StepState { Done, Active, Pending }
data class TrackStep(val title: String, val sub: String, val state: StepState)
@Composable
fun TrackingTimeline(steps: List<TrackStep>) {
Column {
steps.forEachIndexed { i, step ->
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
val fill = when (step.state) {
StepState.Done -> GrubhubColors.TrackBlue
StepState.Active -> GrubhubColors.Red
StepState.Pending -> GrubhubColors.Canvas
}
val stroke = when (step.state) {
StepState.Done -> GrubhubColors.TrackBlue
StepState.Active -> GrubhubColors.Red
StepState.Pending -> GrubhubColors.Divider
}
Box(
Modifier.size(16.dp).clip(RoundedCornerShape(50))
.background(fill)
.border(3.dp, stroke, RoundedCornerShape(50))
)
if (i < steps.lastIndex) {
Box(Modifier.width(3.dp).heightIn(min = 26.dp).weight(1f, fill = false)
.background(if (step.state == StepState.Done) GrubhubColors.TrackBlue else GrubhubColors.Divider))
}
}
Column(Modifier.padding(bottom = 22.dp)) {
Text(step.title, style = GrubhubText.Meta.copy(fontWeight = FontWeight.Bold),
color = GrubhubColors.TextPrimary)
Text(step.sub, style = GrubhubText.Caption, color = GrubhubColors.TextSecondary)
}
}
}
}
}
Primary Button
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.interaction.MutableInteractionSource
@Composable
fun GHButton(title: String, onClick: () -> Unit, orange: Boolean = false) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val bg = when {
orange && pressed -> GrubhubColors.OrangePressed
orange -> GrubhubColors.Orange
pressed -> GrubhubColors.RedPressed
else -> GrubhubColors.Red
}
Box(
Modifier.fillMaxWidth().heightIn(min = 52.dp)
.scale(if (pressed) 0.98f else 1f)
.clip(RoundedCornerShape(50)).background(bg)
.clickable(interaction, indication = null) { onClick() },
contentAlignment = Alignment.Center,
) {
Text(title, style = GrubhubText.Button,
color = if (orange) GrubhubColors.PerksInk else Color.White)
}
}
// Checkout: GHButton("Go to checkout · $42.18", onClick = {})
4. Navigation
Grubhub has a 5-tab bottom strip and no Material tint pill — active is just Grubhub Red.
@Composable
fun GrubhubBottomBar(selected: Int, onSelect: (Int) -> Unit) {
NavigationBar(containerColor = GrubhubColors.Canvas, tonalElevation = 0.dp) {
val items = listOf(
"Delivery" to Icons.Filled.Home,
"Search" to Icons.Filled.Search,
"Orders" to Icons.Filled.ReceiptLong,
"Saved" to Icons.Filled.FavoriteBorder,
"Account" to Icons.Filled.AccountCircle,
)
items.forEachIndexed { i, (label, icon) ->
NavigationBarItem(
selected = selected == i,
onClick = { onSelect(i) },
icon = { Icon(icon, contentDescription = label, modifier = Modifier.size(22.dp)) },
label = { Text(label, style = GrubhubText.Tab) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = GrubhubColors.Red,
selectedTextColor = GrubhubColors.Red,
unselectedIconColor = Color(0xFF888888),
unselectedTextColor = Color(0xFF888888),
indicatorColor = Color.Transparent, // no Material pill — Grubhub has none
),
)
}
}
}
The address bar is a top Row (label + value + chevron, trailing bag Badge); sheets (bag, address, filters) are ModalBottomSheet with a 16.dp top radius and a sticky GHButton checkout pill. The courier map uses Google Maps Compose with a red destination marker.
5. Motion
Grubhub motion is functional — confirm an action, advance a stage, never decorative.
| Moment | Compose recipe |
|---|---|
| Add-to-bag badge bump | animateFloatAsState keyframes 1 → 1.25 → 1 spring(dampingRatio = 0.5f) + HapticFeedbackType.LongPress |
| Quantity change | Crossfade / AnimatedContent on the count + TextHandleMove haptic |
| Tracking progress fill | animateFloatAsState(progress, tween(600, easing = FastOutSlowInEasing)) driving fillMaxWidth(progress) |
| Tracking dot pulse | rememberInfiniteTransition animateFloat(1f, 1.2f, tween(1400), Reverse) |
| Step timeline advance | animateDpAsState node offset tween(250) + HapticFeedbackType.LongPress (medium analog) |
| Card → detail | shared element via SharedTransitionLayout (Compose 1.7+) tween(320) |
| Chip select | filled background animateColorAsState tween(150) |
| Sheet present | ModalBottomSheet default slide; scrim fades in parallel |
// Tracking progress — the canonical Grubhub motion
val width by animateFloatAsState(progress, tween(600, easing = FastOutSlowInEasing), label = "trackFill")
Box(Modifier.fillMaxWidth(width).height(4.dp).background(GrubhubColors.TrackBlue))
Haptics: HapticFeedbackType.TextHandleMove for the stepper tick; HapticFeedbackType.LongPress for add-to-bag and stage-complete. Order-placed success can use VibrationEffect.createPredefined(EFFECT_HEAVY_CLICK) via the system Vibrator.
6. Icons
The closest first-party set to Grubhub’s iconography is androidx.compose.material:material-icons-extended.
| Purpose | SF Symbol (iOS) | Material Icon (Compose) |
|---|---|---|
| Delivery (tab) | house / house.fill |
Icons.Filled.Home |
| Search (tab) | magnifyingglass |
Icons.Filled.Search |
| Orders (tab) | list.bullet.rectangle |
Icons.Filled.ReceiptLong |
| Saved (tab) | heart / heart.fill |
Icons.Filled.FavoriteBorder / Icons.Filled.Favorite |
| Account (tab) | person.crop.circle |
Icons.Filled.AccountCircle |
| Rating star | star.fill |
Icons.Filled.Star |
| Address chevron | chevron.down |
Icons.Filled.KeyboardArrowDown |
| Bag / cart | bag / bag.fill |
Icons.Filled.ShoppingBag |
| Stepper minus | minus |
Icons.Filled.Remove |
| Stepper plus | plus |
Icons.Filled.Add |
| Back | chevron.left |
Icons.Filled.ArrowBack |
| Share | square.and.arrow.up |
Icons.Filled.IosShare |
| Driver call | phone.fill |
Icons.Filled.Call |
| Driver message | message.fill |
Icons.Filled.Chat |
| Tracking pin | mappin.circle.fill |
Icons.Filled.LocationOn |
| Promo / deal | tag.fill |
Icons.Filled.LocalOffer |
| Filters | slider.horizontal.3 |
Icons.Filled.Tune |
7. Minimum SDK & Accessibility Notes
- minSdk 24 (Compose floor is 21; modern motion and
SharedTransitionLayoutare comfortable at 24).compileSdk 34+, Compose BOM2024.09+, Kotlin2.0+. - Edge-to-edge: call
enableEdgeToEdge(); the white canvas wants dark-content system bars (light-content in dark mode and over the tracking map). The address bar respects the camera cutout; the bottom tab + sticky checkout pill useModifier.navigationBarsPadding(). - Font scaling:
sphonors the user’s font scale — keep it on screen titles, restaurant names, body and prices. Pin layout-sensitive text (12sp Perks badges, 13sp deal lines, 10sp tab labels, chip text, ETA dot label) by deriving fromdporCompositionLocalProvider(LocalDensity provides Density(density, fontScale = 1f)). - Tabular numerals: apply
fontFeatureSettings = "tnum"to prices, fees, ETAs and quantity counters so figures don’t jitter. - TalkBack: a restaurant card is one focusable node — set a merged
contentDescription“{name}, {rating} stars, {cuisine}, {eta}, {fee}”. The saved heart is a toggle with state. The tracking timeline labels each step with its state (“Driver picking up, in progress”); post aliveRegionannouncement when a stage advances. - Touch targets: Material guidance is 48.dp. The 30.dp stepper controls and 30.dp heart get a 48.dp hit area via padding; chips are 34.dp tall but full-pill tappable; primary buttons ≥ 52.dp.
- Contrast:
#1B1B1Bon#FFFFFFpasses WCAG AAA; white on#F63440passes AA for button labels; orange deal text#FF8000is used at 13sp 600 (bold) where it passes AA — never as small regular body. - Reduce motion: when
Settings.Global.ANIMATOR_DURATION_SCALE == 0, disable the dot pulse and the timeline node slide (substitute aCrossfade); keep the progress fill but make it instant (it conveys state). - Dark mode: invert via the
Dark*palette —#121212, NOT true black;#1B1B1Btext becomes#F2F2F2. Brand Red/Orange/Gold/Blue/Green are constant. Shadows are nearly invisible on dark, so add a 0.5dpDarkDividerborder on floating commerce surfaces (bag sheet, sticky checkout, tracking banner). Do not enable Material YoudynamicColorScheme()— Grubhub’s red/orange identity must hold regardless of wallpaper.