Delta (iOS) — Jetpack Compose Implementation Guide
Companion to DESIGN.md — the framework-neutral spec. This file ports Delta’s visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, paste-ready @Composables for the boarding pass + flight-status timeline, navigation, 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 (Delta Blue navy, the navy→red boarding pass, the status-first trip header, calm functional motion) while making everything idiomatic Android —
NavigationBarinstead of a UITabBar,sp/dpinstead ofpt.
Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+. No color extraction — Delta’s palette is a fixed brand system. Delta is light-mode-first; a full navy-charcoal dark scheme is provided.
1. Color Tokens (Compose + M3)
// ui/theme/DeltaColors.kt
import androidx.compose.ui.graphics.Color
object DeltaColors {
// Brand
val Blue = Color(0xFF003268)
val BlueBright = Color(0xFF0F4C9A)
val Red = Color(0xFFC8102E)
val RedPressed = Color(0xFFA30D26)
val WidgetMaroon = Color(0xFF862633)
val SkyMilesGold = Color(0xFFC99700)
// Canvas & Surfaces (Light)
val Canvas = Color(0xFFFFFFFF)
val SurfaceGray = Color(0xFFF4F6F9)
val SurfacePressed = Color(0xFFE8ECF3)
val DividerLight = Color(0xFFD6DCE7)
// Canvas & Surfaces (Dark) — deep navy-charcoal, NOT pure black
val DarkCanvas = Color(0xFF0B1320)
val DarkSurface1 = Color(0xFF131C2B)
val DarkSurface2 = Color(0xFF1C2738)
val DarkDivider = Color(0xFF25314A)
// Text
val TextPrimary = Color(0xFF10243F)
val TextSecondary = Color(0xFF5A6A82)
val TextTertiary = Color(0xFF8A98AE)
val DarkTextPrimary = Color(0xFFE8ECF3)
val DarkTextSecondary = Color(0xFF9AA6BC)
val DarkTextTertiary = Color(0xFF5F6E88)
// Flight status (functional, constant)
val OnTime = Color(0xFF1E8E5A)
val OnTimeDark = Color(0xFF4FD18C)
val Boarding = Color(0xFF0F4C9A)
val BoardingDark = Color(0xFF6FB0EA)
val Delayed = Color(0xFFC99700)
val DelayedDark = Color(0xFFE0B743)
val Canceled = Color(0xFFC8102E)
val CanceledDark = Color(0xFFE0414E)
}
Wire it into both schemes. Delta is light-first; the dark scheme uses the signature #0B1320 navy-charcoal, never true black, so the brand navy survives.
// 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 DeltaLight = lightColorScheme(
primary = DeltaColors.Red, // primary CTA — the single high-energy accent
onPrimary = Color.White,
secondary = DeltaColors.Blue, // brand/navigation anchor
onSecondary = Color.White,
background = DeltaColors.Canvas,
onBackground = DeltaColors.TextPrimary,
surface = DeltaColors.SurfaceGray,
onSurface = DeltaColors.TextPrimary,
surfaceVariant = DeltaColors.SurfacePressed,
outline = DeltaColors.DividerLight,
error = DeltaColors.Canceled,
)
private val DeltaDark = darkColorScheme(
primary = DeltaColors.Red,
onPrimary = Color.White,
secondary = DeltaColors.BlueBright,
onSecondary = Color.White,
background = DeltaColors.DarkCanvas,
onBackground = DeltaColors.DarkTextPrimary,
surface = DeltaColors.DarkSurface1,
onSurface = DeltaColors.DarkTextPrimary,
surfaceVariant = DeltaColors.DarkSurface2,
outline = DeltaColors.DarkDivider,
error = DeltaColors.CanceledDark,
)
@Composable
fun DeltaTheme(
dark: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) = MaterialTheme(
colorScheme = if (dark) DeltaDark else DeltaLight,
typography = DeltaTypography,
content = content,
)
2. Typography (M3)
Delta uses a Whitney-class humanist sans; Open Sans (Apache 2.0) is the closest free analog. Drop the TTFs in res/font/. Data is bold and big; document the weight ladder 400/600/700/800.
// ui/theme/DeltaType.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 OpenSans = FontFamily(
Font(R.font.opensans_regular, FontWeight.Normal),
Font(R.font.opensans_medium, FontWeight.Medium),
Font(R.font.opensans_semibold, FontWeight.SemiBold),
Font(R.font.opensans_bold, FontWeight.Bold),
Font(R.font.opensans_extrabold, FontWeight.ExtraBold),
)
object DeltaText {
val RouteIATA = TextStyle(OpenSans, fontWeight = FontWeight.ExtraBold, fontSize = 34.sp, lineHeight = 36.sp, letterSpacing = (-0.5).sp)
val ScreenTitle = TextStyle(OpenSans, fontWeight = FontWeight.ExtraBold, fontSize = 26.sp, lineHeight = 30.sp, letterSpacing = (-0.3).sp)
val Section = TextStyle(OpenSans, fontWeight = FontWeight.Bold, fontSize = 22.sp, lineHeight = 26.sp, letterSpacing = (-0.2).sp)
val PaxName = TextStyle(OpenSans, fontWeight = FontWeight.Bold, fontSize = 18.sp, lineHeight = 22.sp)
val DataLarge = TextStyle(OpenSans, fontWeight = FontWeight.ExtraBold, fontSize = 22.sp, lineHeight = 24.sp)
val Body = TextStyle(OpenSans, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 24.sp)
val DataInline = TextStyle(OpenSans, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, lineHeight = 20.sp)
val Meta = TextStyle(OpenSans, fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 20.sp)
val Eyebrow = TextStyle(OpenSans, fontWeight = FontWeight.Bold, fontSize = 12.sp, lineHeight = 12.sp, letterSpacing = 0.6.sp)
val CellLabel = TextStyle(OpenSans, fontWeight = FontWeight.Bold, fontSize = 10.sp, lineHeight = 10.sp, letterSpacing = 0.6.sp)
val Button = TextStyle(OpenSans, fontWeight = FontWeight.Bold, fontSize = 16.sp, lineHeight = 16.sp, letterSpacing = 0.2.sp)
val Tab = TextStyle(OpenSans, fontWeight = FontWeight.SemiBold, fontSize = 10.sp, lineHeight = 10.sp, letterSpacing = 0.1.sp)
val StatusPill = TextStyle(OpenSans, fontWeight = FontWeight.Bold, fontSize = 12.sp, lineHeight = 12.sp, letterSpacing = 0.2.sp)
}
val DeltaTypography = Typography(
headlineLarge = DeltaText.ScreenTitle,
headlineMedium = DeltaText.Section,
titleMedium = DeltaText.PaxName,
bodyMedium = DeltaText.Body,
labelSmall = DeltaText.Tab,
)
3. Signature Components (@Composable)
Digital Boarding Pass
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
@Composable
fun BoardingPass(
paxName: String, gate: String, seat: String, group: String,
boardsAt: String, zone: String, dark: Boolean = true,
) {
Column(
Modifier
.clip(RoundedCornerShape(18.dp))
.background(DeltaColors.DarkSurface1)
.border(1.dp, DeltaColors.DarkDivider, RoundedCornerShape(18.dp)),
) {
Box(
Modifier
.fillMaxWidth().height(6.dp)
.background(Brush.horizontalGradient(listOf(DeltaColors.Blue, DeltaColors.Red))),
)
Column(Modifier.padding(18.dp)) {
Text("PASSENGER", style = DeltaText.CellLabel, color = DeltaColors.DarkTextSecondary)
Text(paxName.uppercase(), style = DeltaText.PaxName, color = DeltaColors.DarkTextPrimary,
modifier = Modifier.padding(top = 2.dp))
Row(Modifier.padding(top = 16.dp), horizontalArrangement = Arrangement.spacedBy(18.dp)) {
PassCell("GATE", gate, DeltaColors.DarkTextPrimary, Modifier.weight(1f))
PassCell("SEAT", seat, DeltaColors.DarkTextPrimary, Modifier.weight(1f))
PassCell("GROUP", group, DeltaColors.SkyMilesGold, Modifier.weight(1f))
}
Row(
Modifier
.padding(top = 16.dp).fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(DeltaColors.DarkSurface2)
.padding(14.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text("Boards $boardsAt · Zone", style = DeltaText.DataInline.copy(fontWeight = FontWeight.SemiBold),
color = DeltaColors.DarkTextSecondary)
Text(zone, style = DeltaText.DataInline.copy(fontWeight = FontWeight.Bold),
color = DeltaColors.DarkTextPrimary)
}
BarcodeStripe(dark = dark, modifier = Modifier.padding(top = 16.dp).fillMaxWidth().height(56.dp))
}
}
}
@Composable
private fun PassCell(label: String, value: String, valueColor: Color, modifier: Modifier = Modifier) {
Column(modifier) {
Text(label, style = DeltaText.CellLabel, color = DeltaColors.DarkTextTertiary)
Text(value, style = DeltaText.DataLarge, color = valueColor, modifier = Modifier.padding(top = 3.dp))
}
}
/** High-contrast bars; inverts to light bars in dark mode so a gate scanner reads a dimmed screen. */
@Composable
fun BarcodeStripe(dark: Boolean, modifier: Modifier = Modifier) {
val bar = if (dark) DeltaColors.DarkTextPrimary else DeltaColors.TextPrimary
Row(modifier.clip(RoundedCornerShape(8.dp))) {
repeat(64) { i ->
Box(Modifier.width(if (i % 3 == 0) 3.dp else 1.dp).fillMaxHeight().background(bar))
Spacer(Modifier.width(2.dp))
}
}
}
Flight Status Timeline
import androidx.compose.foundation.shape.CircleShape
data class FlightStep(val title: String, val meta: String, val done: Boolean)
@Composable
fun FlightStatusTimeline(steps: List<FlightStep>, modifier: Modifier = Modifier) {
Column(
modifier
.clip(RoundedCornerShape(16.dp))
.background(DeltaColors.DarkSurface1)
.border(1.dp, DeltaColors.DarkDivider, RoundedCornerShape(16.dp))
.padding(18.dp),
) {
Text("Flight Status", style = DeltaText.DataInline.copy(fontWeight = FontWeight.Bold),
color = DeltaColors.DarkTextPrimary, modifier = Modifier.padding(bottom = 16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
steps.forEachIndexed { i, s ->
Box(
Modifier.size(12.dp).clip(CircleShape)
.background(if (s.done) DeltaColors.OnTimeDark else DeltaColors.DarkTextSecondary)
.border(2.dp, if (s.done) DeltaColors.OnTimeDark else DeltaColors.DarkTextSecondary, CircleShape),
)
if (i < steps.lastIndex) {
Box(Modifier.width(2.dp).height(28.dp)
.background(DeltaColors.DarkDivider).padding(vertical = 2.dp))
}
}
}
Column(verticalArrangement = Arrangement.spacedBy(18.dp)) {
steps.forEach { s ->
Column {
Text(s.title, style = DeltaText.DataInline.copy(fontWeight = FontWeight.Bold),
color = DeltaColors.DarkTextPrimary)
Text(s.meta, style = DeltaText.Meta, color = DeltaColors.DarkTextSecondary,
modifier = Modifier.padding(top = 2.dp))
}
}
}
}
}
}
Trip Header + Buttons + Status Chip
@Composable
fun TripHeader(flightNo: String, origin: String, dest: String, status: String) {
Column(
Modifier
.fillMaxWidth()
.background(Brush.verticalGradient(listOf(DeltaColors.Blue, Color(0xFF06203F))))
.padding(horizontal = 18.dp).padding(top = 8.dp, bottom = 16.dp),
) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically) {
Text("TODAY · $flightNo", style = DeltaText.Eyebrow.copy(letterSpacing = 1.sp),
color = Color(0xFF8FB4DD))
StatusChip(StatusKind.OnTime, status)
}
Row(Modifier.padding(top = 12.dp), verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(14.dp)) {
Text(origin, style = DeltaText.RouteIATA, color = Color.White)
Row(Modifier.weight(1f).padding(bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Box(Modifier.weight(1f).height(2.dp).background(Color.White.copy(alpha = 0.35f)))
Icon(Icons.Filled.Flight, null, tint = Color.White, modifier = Modifier.size(18.dp))
Box(Modifier.weight(1f).height(2.dp).background(Color.White.copy(alpha = 0.35f)))
}
Text(dest, style = DeltaText.RouteIATA, color = Color.White)
}
}
}
enum class StatusKind { OnTime, Boarding, Delayed, Canceled }
@Composable
fun StatusChip(kind: StatusKind, label: String) {
val (fg, bg) = when (kind) {
StatusKind.OnTime -> DeltaColors.OnTimeDark to DeltaColors.OnTime.copy(alpha = 0.18f)
StatusKind.Boarding -> DeltaColors.BoardingDark to DeltaColors.Boarding.copy(alpha = 0.24f)
StatusKind.Delayed -> DeltaColors.DelayedDark to DeltaColors.Delayed.copy(alpha = 0.18f)
StatusKind.Canceled -> DeltaColors.CanceledDark to DeltaColors.Canceled.copy(alpha = 0.18f)
}
Text(label, style = DeltaText.StatusPill, color = fg,
modifier = Modifier.clip(CircleShape).background(bg).padding(horizontal = 14.dp, vertical = 7.dp))
}
@Composable
fun DeltaPrimaryButton(title: String, onClick: () -> Unit) {
androidx.compose.material3.Button(
onClick = onClick,
shape = RoundedCornerShape(6.dp),
colors = androidx.compose.material3.ButtonDefaults.buttonColors(
containerColor = DeltaColors.Red, contentColor = Color.White),
modifier = Modifier.fillMaxWidth().height(48.dp),
) { Text(title, style = DeltaText.Button) }
}
4. Navigation
Delta has minimal chrome: a 5-tab bottom strip. On Android model it as a NavigationBar — no tint pill, active is the brand-blue icon + label.
import androidx.compose.material3.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
@Composable
fun DeltaBottomBar(selected: Int, onSelect: (Int) -> Unit) {
NavigationBar(containerColor = DeltaColors.DarkCanvas, tonalElevation = 0.dp) {
val items = listOf(
"Today" to Icons.Filled.Home,
"Book" to Icons.Filled.Flight,
"Trips" to Icons.Filled.Receipt,
"SkyMiles" to Icons.Filled.AccountCircle,
"More" to Icons.Filled.MoreHoriz,
)
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 = DeltaText.Tab) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = DeltaColors.BoardingDark,
selectedTextColor = DeltaColors.BoardingDark,
unselectedIconColor = DeltaColors.DarkTextTertiary,
unselectedTextColor = DeltaColors.DarkTextTertiary,
indicatorColor = Color.Transparent, // no Material pill — Delta has none
),
)
}
}
}
Use a NavHost for Today/Book/Trips/SkyMiles/More; a ModalBottomSheet for the seat map and fare details; push transitions are a 300ms horizontal slide.
5. Motion
Delta motion is calm and functional — never decorative bounce. Shadows signal “the thing you act on”.
| Moment | Compose recipe |
|---|---|
| Plane glide (header) | animateFloatAsState 0 → 1 tween(1200, easing = FastOutSlowInEasing); offset the plane glyph along the line |
| Status change | pill color animateColorAsState(tween(250)); affected step tween(300); soft haptic |
| Active timeline dot | rememberInfiniteTransition scale 1 → 1.15 tween(1600) reverse |
| Boarding pass open | AnimatedVisibility scale-in spring(dampingRatio = 0.8f); raise screen brightness |
| Bag tracker advance | active dot fill tween(300) + light haptic on scan event |
| Tab switch | Crossfade(tween(150)) |
| Page navigation | Nav slide push tween(300) |
val planeProgress by animateFloatAsState(if (visible) 1f else 0f,
animationSpec = tween(1200, easing = FastOutSlowInEasing), label = "planeGlide")
val dotScale by rememberInfiniteTransition(label = "dot").animateFloat(
initialValue = 1f, targetValue = 1.15f,
animationSpec = infiniteRepeatable(tween(1600), RepeatMode.Reverse), label = "dotPulse")
Haptics: LocalHapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) for the soft impact on status change and bag-tracker advance; on gate change use a stronger pattern via view.performHapticFeedback(HapticFeedbackConstants.CONFIRM). Brightness: cache and raise via WindowManager.LayoutParams.screenBrightness = 1f while the full-screen pass is shown; restore on dismiss. Auto-refresh is silent.
6. Icons
Closest first-party set is androidx.compose.material:material-icons-extended.
| Purpose | SF Symbol (iOS) | Material Icon (Compose) |
|---|---|---|
| Today (tab) | house.fill |
Icons.Filled.Home |
| Book (tab) | airplane |
Icons.Filled.Flight |
| Trips (tab) | rectangle.stack.fill |
Icons.Filled.Receipt |
| SkyMiles (tab) | person.crop.circle.fill |
Icons.Filled.AccountCircle |
| More (tab) | ellipsis.circle.fill |
Icons.Filled.MoreHoriz |
| In-flight plane | airplane |
Icons.Filled.Flight |
| Departed | airplane.departure |
Icons.Filled.FlightTakeoff |
| Arrived | airplane.arrival |
Icons.Filled.FlightLand |
| Gate change | exclamationmark.triangle.fill |
Icons.Filled.Warning |
| Bag tracker | suitcase.fill |
Icons.Filled.Luggage |
| Add to Wallet | wallet.pass |
Icons.Filled.AccountBalanceWallet |
| Boarding pass | qrcode |
Icons.Filled.QrCode2 |
| Seat | chair.lounge.fill |
Icons.Filled.EventSeat |
| Back | chevron.left |
Icons.AutoMirrored.Filled.ArrowBack |
| Share | square.and.arrow.up |
Icons.Filled.IosShare |
| Refresh status | arrow.clockwise |
Icons.Filled.Refresh |
| Medallion tier | star.circle.fill |
Icons.Filled.Stars |
7. Minimum SDK & Accessibility Notes
- minSdk 24 (
Popup, modern motion comfortable at 24).compileSdk 34+, Compose BOM2024.09+, Kotlin2.0+. - Edge-to-edge: call
enableEdgeToEdge(); the navy trip header draws under the status bar with light-content system icons; the boarding-pass barcode must stay above the navigation-bar inset. - Font scaling:
sphonors the user’s font scale — keep it on route IATA, screen title, section, body, meta. Pin layout-sensitive text (barcode, 10sp cell labels, 10sp tab labels, status-pill text) viaCompositionLocalProvider(LocalDensity provides Density(density, fontScale = 1f)). Data values scale one step then clamp to preserve the 3-cell grid. - TalkBack: label the pass “Boarding pass, {name}, gate {gate}, seat {seat}, group {group}, boards {time}”; each timeline step “{title}, {meta}, {completed/upcoming}”; the status chip announces the full status word; the bag tracker announces the current step + carousel.
- Touch targets: Material guidance is 48.dp — primary button ≥ 48.dp; list rows 52.dp full-row tappable; the timeline step rows are full-row tappable for segment detail.
- Contrast:
#E8ECF3on#0B1320and#10243Fon#FFFFFFpass WCAG AA; the functional status pairs are validated against their translucent fills — re-check any custom delay/amber pair. - Brightness: raise
WindowManager.LayoutParams.screenBrightness = 1fwhile the full-screen pass is visible (gate scanning); restore the user’s value on dismiss. - Reduce motion: when
Settings.Global.ANIMATOR_DURATION_SCALE == 0, disable the plane glide and active-dot pulse; keep the status color change as an instant swap (it conveys state). - Dark mode: invert via the
Dark*palette —#0B1320, NOT true black, so the Delta navy survives; the barcode flips to a light bar pattern so it stays machine-scannable. Do not enable Material YoudynamicColorScheme()— Delta’s navy + widget-red identity must hold regardless of wallpaper.