Uber Eats (iOS) — Jetpack Compose Implementation Guide
Companion to DESIGN.md — the framework-neutral spec. This file ports Uber Eats’ visual language to Android with Jetpack Compose (Material 3): color token objects (light + dark), 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 (Uber Eats’ bright photo-first white canvas, single green accent, sticky cart bar, live map tracking) while making everything idiomatic Android —
NavigationBarinstead of a UITabBar, Google Maps Compose for tracking,sp/dpinstead ofpt.
Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, Coil 2.6+ for images, and maps-compose4.3+ for the tracking map.
1. Color Tokens
// ui/theme/UEColors.kt
import androidx.compose.ui.graphics.Color
object UEColors {
// Light — default
val Canvas = Color(0xFFFFFFFF)
val Surface = Color(0xFFF3F3F3)
val Surface2 = Color(0xFFEEEEEE)
val Divider = Color(0xFFE8E8E8)
val TextPrimary = Color(0xFF000000)
val TextSecondary = Color(0xFF6B6B6B)
val TextTertiary = Color(0xFFA6A6A6)
// Dark
val DarkCanvas = Color(0xFF000000)
val DarkSurface = Color(0xFF1C1C1E)
val DarkSurface2 = Color(0xFF2C2C2E)
val DarkDivider = Color(0xFF2C2C2E)
val DarkTextPrimary = Color(0xFFFFFFFF)
val DarkTextSecondary = Color(0xFFA6A6A6)
// Brand — identical in light & dark
val Green = Color(0xFF06C167)
val GreenPressed = Color(0xFF05A658)
val GreenTint = Color(0xFFE7F8EF)
val ErrorRed = Color(0xFFE11900)
val BusyAmber = Color(0xFFFF8A00)
}
Provide both Material 3 schemes; the green is the primary in both (only neutrals invert).
// ui/theme/Theme.kt
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.*
private val UELight = lightColorScheme(
primary = UEColors.Green,
onPrimary = Color.White,
background = UEColors.Canvas,
onBackground = UEColors.TextPrimary,
surface = UEColors.Surface,
onSurface = UEColors.TextPrimary,
surfaceVariant = UEColors.Surface2,
outline = UEColors.Divider,
error = UEColors.ErrorRed,
)
private val UEDark = darkColorScheme(
primary = UEColors.Green, // unchanged in dark
onPrimary = Color.White,
background = UEColors.DarkCanvas,
onBackground = UEColors.DarkTextPrimary,
surface = UEColors.DarkSurface,
onSurface = UEColors.DarkTextPrimary,
surfaceVariant = UEColors.DarkSurface2,
outline = UEColors.DarkDivider,
error = UEColors.ErrorRed,
)
@Composable
fun UETheme(content: @Composable () -> Unit) =
MaterialTheme(
colorScheme = if (isSystemInDarkTheme()) UEDark else UELight,
typography = UETypography,
content = content,
)
2. Typography
Uber Move is a licensed brand typeface. Drop the TTFs in res/font/ (lowercase, snake_case) and build a FontFamily. Fall back to the system font (Roboto).
// ui/theme/UEType.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 UberMove = FontFamily(
Font(R.font.uber_move_regular, FontWeight.Normal), // 400
Font(R.font.uber_move_medium, FontWeight.Medium), // 500
Font(R.font.uber_move_bold, FontWeight.Bold), // 700
)
// Named ramp — mirrors DESIGN.md §3 exactly (pt → sp 1:1)
object UEText {
val RestaurantHeader = TextStyle(UberMove, fontWeight = FontWeight.Bold, fontSize = 30.sp, lineHeight = 33.sp, letterSpacing = (-0.4).sp)
val TitleLarge = TextStyle(UberMove, fontWeight = FontWeight.Bold, fontSize = 28.sp, lineHeight = 32.sp, letterSpacing = (-0.4).sp)
val SectionHeader = TextStyle(UberMove, fontWeight = FontWeight.Bold, fontSize = 22.sp, lineHeight = 26.sp, letterSpacing = (-0.3).sp)
val Subsection = TextStyle(UberMove, fontWeight = FontWeight.Bold, fontSize = 20.sp, lineHeight = 24.sp, letterSpacing = (-0.3).sp)
val RestaurantName = TextStyle(UberMove, fontWeight = FontWeight.Bold, fontSize = 17.sp, lineHeight = 21.sp, letterSpacing = (-0.1).sp)
val MenuItemTitle = TextStyle(UberMove, fontWeight = FontWeight.Medium, fontSize = 16.sp, lineHeight = 21.sp, letterSpacing = (-0.1).sp)
val Body = TextStyle(UberMove, fontWeight = FontWeight.Normal, fontSize = 15.sp, lineHeight = 22.sp)
val Price = TextStyle(UberMove, fontWeight = FontWeight.Bold, fontSize = 16.sp, lineHeight = 20.sp)
val Meta = TextStyle(UberMove, fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 18.sp)
val Pill = TextStyle(UberMove, fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 18.sp)
val Caption = TextStyle(UberMove, fontWeight = FontWeight.Normal, fontSize = 13.sp, lineHeight = 17.sp)
val LabelUpper = TextStyle(UberMove, fontWeight = FontWeight.Bold, fontSize = 11.sp, lineHeight = 13.sp, letterSpacing = 0.6.sp)
val Button = TextStyle(UberMove, fontWeight = FontWeight.Bold, fontSize = 16.sp, lineHeight = 20.sp, letterSpacing = 0.2.sp)
val Tab = TextStyle(UberMove, fontWeight = FontWeight.Medium, fontSize = 10.sp, lineHeight = 12.sp, letterSpacing = 0.2.sp)
val CartBadge = TextStyle(UberMove, fontWeight = FontWeight.Bold, fontSize = 12.sp, lineHeight = 14.sp)
}
// Map onto Material 3 slots so stock components inherit the brand
val UETypography = Typography(
headlineLarge = UEText.TitleLarge,
headlineSmall = UEText.SectionHeader,
titleMedium = UEText.RestaurantName,
bodyMedium = UEText.Body,
labelSmall = UEText.Tab,
)
3. Signature Components
Primary Green Button
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
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.*
import androidx.compose.ui.unit.dp
@Composable
fun UEPrimaryButton(
title: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
trailing: String? = null,
) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val scale by animateFloatAsState(
if (pressed) 0.98f else 1f,
spring(dampingRatio = 0.7f, stiffness = 600f), label = "ctaScale",
)
val haptics = LocalHapticFeedback.current
Row(
modifier = modifier
.fillMaxWidth()
.height(52.dp)
.scale(scale)
.clip(RoundedCornerShape(12.dp))
.background(if (pressed) UEColors.GreenPressed else UEColors.Green)
.clickable(interaction, indication = null) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress) // ~iOS light impact
onClick()
}
.padding(horizontal = if (trailing != null) 20.dp else 0.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = if (trailing != null) Arrangement.SpaceBetween
else Arrangement.Center,
) {
Text(title, style = UEText.Button, color = Color.White)
if (trailing != null) Text(trailing, style = UEText.Button, color = Color.White)
}
}
Photo-First Restaurant Card
@Composable
fun UERestaurantCard(
name: String,
rating: String,
eta: String,
fee: String,
freeDelivery: Boolean,
photoUrl: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val scheme = MaterialTheme.colorScheme
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.98f else 1f, label = "cardScale")
Column(modifier.clickable(interaction, indication = null, onClick = onClick)) {
Box(
Modifier
.fillMaxWidth()
.aspectRatio(16f / 9f)
.scale(scale)
.clip(RoundedCornerShape(12.dp)),
) {
AsyncImage(model = photoUrl, contentDescription = name,
modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
if (freeDelivery) {
Text(
"$0 Delivery Fee",
style = UEText.Caption.copy(fontWeight = FontWeight.Bold),
color = Color.White,
modifier = Modifier
.align(Alignment.TopStart).padding(10.dp)
.clip(RoundedCornerShape(50)).background(UEColors.Green)
.padding(horizontal = 8.dp, vertical = 4.dp),
)
}
Box(
Modifier
.align(Alignment.TopEnd).padding(10.dp)
.size(36.dp).clip(CircleShape).background(Color.White.copy(alpha = 0.9f)),
contentAlignment = Alignment.Center,
) { Icon(Icons.Outlined.FavoriteBorder, contentDescription = "Favorite",
tint = Color.Black, modifier = Modifier.size(18.dp)) }
}
Spacer(Modifier.height(8.dp))
Text(name, style = UEText.RestaurantName, color = scheme.onBackground)
Row(verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.padding(top = 4.dp)) {
Icon(Icons.Filled.Star, contentDescription = null,
tint = scheme.onBackground, modifier = Modifier.size(12.dp)) // monochrome, not yellow
Text(rating, style = UEText.Meta, color = scheme.onBackground)
Text("· $eta ·", style = UEText.Meta, color = UEColors.TextSecondary)
Text(fee, style = UEText.Meta, color = if (freeDelivery) UEColors.Green else UEColors.TextSecondary)
}
}
}
Sticky Cart Bar
@Composable
fun UEStickyCartBar(
count: Int,
total: String,
onView: () -> Unit,
modifier: Modifier = Modifier,
) {
AnimatedVisibility(
visible = count > 0,
enter = slideInVertically { it } + fadeIn(),
exit = slideOutVertically { it } + fadeOut(),
modifier = modifier,
) {
Row(
Modifier
.fillMaxWidth()
.padding(16.dp)
.shadow(12.dp, RoundedCornerShape(12.dp), spotColor = Color.Black.copy(alpha = 0.12f))
.clip(RoundedCornerShape(12.dp))
.background(UEColors.Green)
.clickable(onClick = onView)
.height(56.dp)
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(Modifier.size(28.dp).clip(CircleShape).background(Color.White),
contentAlignment = Alignment.Center) {
Text("$count", style = UEText.CartBadge, color = UEColors.Green)
}
Text("View cart", style = UEText.Button, color = Color.White,
modifier = Modifier.weight(1f), textAlign = TextAlign.Center)
Text(total, style = UEText.Button, color = Color.White)
}
}
}
Menu Item Row + Cart-Count Bump
@Composable
fun UEMenuItemRow(
name: String, desc: String, price: String, photoUrl: String,
onAdd: () -> Unit, modifier: Modifier = Modifier,
) {
val scheme = MaterialTheme.colorScheme
val haptics = LocalHapticFeedback.current
Row(
modifier
.fillMaxWidth()
.padding(vertical = 12.dp)
.drawBehind {
drawLine(UEColors.Divider, Offset(0f, size.height), Offset(size.width, size.height), 1.dp.toPx())
},
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Column(Modifier.weight(1f)) {
Text(name, style = UEText.MenuItemTitle, color = scheme.onBackground)
Text(desc, style = UEText.Meta, color = UEColors.TextSecondary, maxLines = 2,
overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 4.dp))
Text(price, style = UEText.Body, color = scheme.onBackground, modifier = Modifier.padding(top = 2.dp))
}
Box(Modifier.size(88.dp)) {
AsyncImage(model = photoUrl, contentDescription = name,
modifier = Modifier.size(88.dp).clip(RoundedCornerShape(12.dp)),
contentScale = ContentScale.Crop)
Box(
Modifier
.align(Alignment.BottomEnd)
.offset(x = 6.dp, y = 6.dp)
.size(28.dp).clip(CircleShape).background(UEColors.Green)
.clickable {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
onAdd()
},
contentAlignment = Alignment.Center,
) { Icon(Icons.Filled.Add, contentDescription = "Add", tint = Color.White,
modifier = Modifier.size(16.dp)) }
}
}
}
@Composable
fun UECartCountBadge(count: Int) {
val scale = remember { Animatable(1f) }
LaunchedEffect(count) {
scale.animateTo(1.25f, spring(dampingRatio = 0.5f)); scale.animateTo(1f, spring())
}
Box(
Modifier.scale(scale.value).defaultMinSize(18.dp, 18.dp)
.clip(CircleShape).background(UEColors.Green).padding(horizontal = 4.dp),
contentAlignment = Alignment.Center,
) { Text("$count", style = UEText.CartBadge, color = Color.White) }
}
4. Live Map Order Tracking
import com.google.android.gms.maps.model.LatLng
import com.google.maps.android.compose.*
@Composable
fun UEOrderTracking(route: List<LatLng>, etaText: String, step: Int) {
val scheme = MaterialTheme.colorScheme
var courier by remember { mutableStateOf(route.first()) }
LaunchedEffect(route) {
route.forEach { p ->
courier = p // Marker eases via MarkerState; or animate a custom LatLng tween
delay(3000)
}
}
Box(Modifier.fillMaxSize()) {
GoogleMap(
modifier = Modifier.fillMaxSize(),
cameraPositionState = rememberCameraPositionState {
position = com.google.maps.android.compose.CameraPositionState().position
},
) {
Polyline(points = route, color = Color.Black, width = 12f) // near-black route
Marker(state = MarkerState(position = courier)) // green pin via custom icon in production
}
Column(
Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.shadow(28.dp, RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
spotColor = Color.Black.copy(alpha = 0.16f))
.clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp))
.background(scheme.background)
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Box(Modifier.align(Alignment.CenterHorizontally)
.width(40.dp).height(5.dp).clip(RoundedCornerShape(3.dp)).background(UEColors.Divider))
Text(etaText, style = UEText.SectionHeader, color = scheme.onBackground)
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
repeat(4) { i ->
Box(
Modifier.weight(1f).height(4.dp).clip(RoundedCornerShape(2.dp))
.background(if (i <= step) UEColors.Green else UEColors.Surface2)
)
}
}
}
}
}
5. Bottom Navigation (Tab Bar)
Use Material 3 NavigationBar. Active tint is near-black/white, NOT green — green is reserved for action surfaces (cart bar, CTAs, badges).
@Composable
fun UEBottomBar(selected: Int, cartCount: Int, onSelect: (Int) -> Unit) {
val scheme = MaterialTheme.colorScheme
NavigationBar(containerColor = scheme.background, tonalElevation = 0.dp) {
val items = listOf(
"Home" to Icons.Filled.Home,
"Browse" to Icons.Filled.GridView,
"Search" to Icons.Filled.Search,
"Cart" to Icons.Filled.ShoppingCart,
"Account" to Icons.Filled.AccountCircle,
)
items.forEachIndexed { i, (label, icon) ->
NavigationBarItem(
selected = selected == i,
onClick = { onSelect(i) },
icon = {
if (i == 3 && cartCount > 0) {
BadgedBox(badge = {
Badge(containerColor = UEColors.Green, contentColor = Color.White) {
Text("$cartCount")
}
}) { Icon(icon, contentDescription = label, modifier = Modifier.size(24.dp)) }
} else {
Icon(icon, contentDescription = label, modifier = Modifier.size(24.dp))
}
},
label = { Text(label, style = UEText.Tab) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = scheme.onBackground, // near-black/white, NOT green
selectedTextColor = scheme.onBackground,
unselectedIconColor = UEColors.TextTertiary,
unselectedTextColor = UEColors.TextTertiary,
indicatorColor = Color.Transparent,
),
)
}
}
}
6. Motion
| Moment | Compose recipe |
|---|---|
| Cart-count bump | Animatable 1 → 1.25 → 1 (spring(dampingRatio = 0.5f)) keyed on count |
| Sticky cart bar appear | AnimatedVisibility + slideInVertically { it } + fadeIn() |
| Map courier marker | MarkerState position update; for smooth easing tween a LatLng via animateValueAsState (~1s) and feed it to the marker |
| Card press | animateFloatAsState 1 → 0.98, tween(150) ease-out |
| Pill select | animateColorAsState on background (tween(150)); scale 0.97 on press |
// Smoothly tween the courier between two LatLngs
@Composable
fun rememberEasedLatLng(target: LatLng): State<LatLng> {
val lat by animateFloatAsState(target.latitude.toFloat(),
tween(1000, easing = FastOutSlowInEasing), label = "lat")
val lng by animateFloatAsState(target.longitude.toFloat(),
tween(1000, easing = FastOutSlowInEasing), label = "lng")
return remember { derivedStateOf { LatLng(lat.toDouble(), lng.toDouble()) } }
}
Haptics: prefer LocalHapticFeedback. For richer control use LocalView.current.performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK) (API 30+) or a Vibrator VibrationEffect.createOneShot(8, ...) to approximate iOS’s .light impact.
7. Icons
Uber Eats ships custom glyphs; the closest first-party set is androidx.compose.material:material-icons-extended. For exact parity, export Uber Eats’ glyphs as vector drawables and load via ImageVector.vectorResource(R.drawable.…).
| Purpose | SF Symbol (iOS) | Material Icon (Compose) |
|---|---|---|
| Add | plus |
Icons.Filled.Add |
| Quantity − / + | minus / plus |
Icons.Filled.Remove / Icons.Filled.Add |
| Rating star | star.fill |
Icons.Filled.Star (monochrome) |
| Favorite | heart / heart.fill |
Icons.Outlined.FavoriteBorder / Icons.Filled.Favorite |
| Share | square.and.arrow.up |
Icons.Filled.IosShare |
| Search | magnifyingglass |
Icons.Filled.Search |
| Address chevron | chevron.down |
Icons.Filled.KeyboardArrowDown |
| Courier message | message.fill |
Icons.Filled.Chat |
| Courier call | phone.fill |
Icons.Filled.Call |
| Home (tab) | house.fill |
Icons.Filled.Home |
| Browse (tab) | square.grid.2x2.fill |
Icons.Filled.GridView |
| Search (tab) | magnifyingglass |
Icons.Filled.Search |
| Cart (tab) | cart.fill |
Icons.Filled.ShoppingCart |
| Account (tab) | person.crop.circle.fill |
Icons.Filled.AccountCircle |
8. Minimum SDK & Accessibility Notes
- minSdk 24 (Compose floor is 21; Maps Compose + modern motion comfortable at 24).
compileSdk 34+, Compose BOM2024.09+, Kotlin2.0+. Requires a Google Maps API key for the tracking screen. - Edge-to-edge: call
enableEdgeToEdge(); the white canvas wants dark-content status bar in light mode (and light-content in dark). ApplyScaffoldinsets so the sticky cart bar and map sheet clear gesture nav. - Light-first: default to the light scheme;
isSystemInDarkTheme()swaps neutrals butUEColors.Greenis theprimaryin both schemes. - Font scaling:
sphonors the user’s font scale automatically — keep it on restaurant names, menu items, descriptions, ETA. Pin layout-sensitive text (tab labels, cart badge, map ETA chip) viadp-derived sizes. - TalkBack: announce cards (“Sunrise Diner, 4.8 stars, 25 minute delivery, 99 cent delivery fee”); the cart bar as a single button (“View cart, 3 items, $28.40”); wrap the map ETA in
Modifier.semantics { liveRegion = LiveRegionMode.Polite }. - Touch targets: Material guidance is 48.dp minimum. The 52–56.dp CTA/cart bar clear it; pad the 28.dp
+add button to a 48.dp hit area. - Contrast:
#6B6B6Bon#FFFFFFpasses WCAG AA at 14sp+. White on#06C167passes AA at 16sp bold (the CTA size) — validate any smaller green text. In dark mode keep#06C167and re-verify green-on-#1C1C1Efor green text/icons. - Dynamic color: do not enable Material You
dynamicColorScheme()— Uber Eats’ brand requires the fixed white (or near-black) canvas and the single#06C167green regardless of wallpaper.