Overcast (iOS) — Jetpack Compose Implementation Guide
Companion to DESIGN.md — the framework-neutral spec. This file ports Overcast’s visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, paste-ready @Composables (the orange-ring play button, Now Playing player, Smart Speed / Voice Boost rows, playlist rows), core navigation, motion, and haptics.
Why a Compose guide for an iOS-only app? Overcast is iOS-exclusive, but the DESIGN.md tokens are platform-neutral and its identity ports cleanly: the warm paper-cream canvas, the single Overcast Orange accent, the outlined orange-ring play button, and Smart Speed / Voice Boost as first-class toggles. This file keeps that identity while making everything idiomatic Android —
NavigationBarinstead of a UITabBar,Sliderfor the scrubber,dp/spinstead ofpt.
Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, Coil 2.6+ for cover images. Overcast is light-first (paper-cream); a full dark scheme is provided.
1. Color Tokens
// ui/theme/OCColors.kt
import androidx.compose.ui.graphics.Color
object OCColors {
// Brand (single accent)
val Orange = Color(0xFFFC7E0F)
val OrangePressed = Color(0xFFE06A00)
val LinkBlue = Color(0xFF2D7FF9)
// Canvas & Surfaces (Light — paper)
val Paper = Color(0xFFFBFAF6) // warm off-white — NOT pure white
val Cream = Color(0xFFF7F5EF)
val PressedLight = Color(0xFFEDEBE3)
val DividerLight = Color(0xFFE4E1D8)
// Canvas & Surfaces (Dark)
val Canvas = Color(0xFF121212)
val Surface1 = Color(0xFF1C1C1E)
val Surface2 = Color(0xFF2A2A2C)
val DividerDk = Color(0xFF303032)
// Text
val TextPrimaryLt = Color(0xFF1A1A1A)
val TextSecondaryLt = Color(0xFF6E6E6E)
val TextPrimaryDk = Color(0xFFF2F2F2)
val TextSecondaryDk = Color(0xFF9A9A9A)
val TextTertiary = Color(0xFF9A9A9A)
// Semantic
val Success = Color(0xFF34C759)
val Error = Color(0xFFFF3B30)
val Warn = Color(0xFFFF9F0A)
// Playlist icon tiles
val PlAll = Color(0xFFFC7E0F)
val PlProgress = Color(0xFF2D7FF9)
val PlStarred = Color(0xFF34C759)
val PlDownload = Color(0xFF5856D6)
}
Wire it into both schemes. Overcast is light-first (paper-cream); the dark scheme uses a true #121212.
// ui/theme/Theme.kt
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
private val OCLight = lightColorScheme(
primary = OCColors.Orange,
onPrimary = Color(0xFFFFFFFF),
background = OCColors.Paper,
onBackground = OCColors.TextPrimaryLt,
surface = OCColors.Cream,
onSurface = OCColors.TextPrimaryLt,
surfaceVariant = OCColors.PressedLight,
outline = OCColors.DividerLight,
error = OCColors.Error,
)
private val OCDark = darkColorScheme(
primary = OCColors.Orange,
onPrimary = Color(0xFFFFFFFF),
background = OCColors.Canvas,
onBackground = OCColors.TextPrimaryDk,
surface = OCColors.Surface1,
onSurface = OCColors.TextPrimaryDk,
surfaceVariant = OCColors.Surface2,
outline = OCColors.DividerDk,
error = OCColors.Error,
)
@Composable
fun OCTheme(
dark: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) = MaterialTheme(
colorScheme = if (dark) OCDark else OCLight,
typography = OCTypography,
content = content,
)
2. Typography (M3)
Overcast uses the platform system font (Roboto on Android stands in for SF Pro). Body 400, titles 700/800; timecodes render in a fixed-width box for tabular stability.
// ui/theme/OCType.kt
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
object OCText {
val ScreenTitle = TextStyle(fontWeight = FontWeight.ExtraBold, fontSize = 32.sp, lineHeight = 38.sp, letterSpacing = (-0.4).sp)
val NowPlaying = TextStyle(fontWeight = FontWeight.Bold, fontSize = 22.sp, lineHeight = 28.sp, letterSpacing = (-0.3).sp)
val Section = TextStyle(fontWeight = FontWeight.Bold, fontSize = 22.sp, lineHeight = 28.sp, letterSpacing = (-0.2).sp)
val Episode = TextStyle(fontWeight = FontWeight.Bold, fontSize = 18.sp, lineHeight = 23.sp)
val RowTitle = TextStyle(fontWeight = FontWeight.SemiBold, fontSize = 15.sp, lineHeight = 20.sp)
val Body = TextStyle(fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 24.sp)
val ShowName = TextStyle(fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 20.sp)
val Meta = TextStyle(fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 20.sp)
val PlDesc = TextStyle(fontWeight = FontWeight.Normal, fontSize = 12.sp, lineHeight = 16.sp)
val Button = TextStyle(fontWeight = FontWeight.SemiBold, fontSize = 16.sp, lineHeight = 16.sp)
val Toggle = TextStyle(fontWeight = FontWeight.SemiBold, fontSize = 15.sp, lineHeight = 20.sp)
val Chip = TextStyle(fontWeight = FontWeight.SemiBold, fontSize = 10.sp, lineHeight = 12.sp, letterSpacing = 0.1.sp)
val Tab = TextStyle(fontWeight = FontWeight.SemiBold, fontSize = 10.sp, lineHeight = 12.sp, letterSpacing = 0.1.sp)
val Count = TextStyle(fontWeight = FontWeight.SemiBold, fontSize = 14.sp, lineHeight = 16.sp)
val SkipLabel = TextStyle(fontWeight = FontWeight.Bold, fontSize = 9.sp, lineHeight = 10.sp)
val StatCaption = TextStyle(fontWeight = FontWeight.SemiBold, fontSize = 12.sp, lineHeight = 16.sp, letterSpacing = 0.4.sp)
val Timecode = TextStyle(fontWeight = FontWeight.Medium, fontSize = 12.sp, lineHeight = 14.sp)
}
val OCTypography = Typography(
headlineLarge = OCText.ScreenTitle,
headlineMedium = OCText.NowPlaying,
titleMedium = OCText.Episode,
bodyMedium = OCText.Body,
labelSmall = OCText.Tab,
)
For tabular timecodes, render durations/timecodes inside a fixed-width
Box(or use thetnumfont feature on API 26+) so digits never shift while scrubbing.
3. Signature Components
Orange-Ring Play Button (the signature control)
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Icon
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun OCPlayRing(playing: Boolean, onToggle: () -> Unit, diameter: androidx.compose.ui.unit.Dp = 66.dp) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val ring = if (pressed) OCColors.OrangePressed else OCColors.Orange
Box(
Modifier
.size(diameter)
.border(2.5.dp, ring, CircleShape) // outlined ring — never a filled disc
.clickable(interaction, indication = null) { onToggle() },
contentAlignment = Alignment.Center,
) {
Icon(
if (playing) Icons.Filled.Pause else Icons.Filled.PlayArrow,
contentDescription = if (playing) "Pause" else "Play",
tint = OCColors.Orange,
modifier = Modifier.size(diameter * 0.36f),
)
}
}
Skip Button (chevron + interval label)
import androidx.compose.material.icons.filled.FastRewind
import androidx.compose.material.icons.filled.FastForward
import androidx.compose.material3.Text
@Composable
fun OCSkipButton(forward: Boolean, seconds: Int, primary: Color, secondary: Color, onClick: () -> Unit) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier.clickable { onClick() },
) {
Icon(
if (forward) Icons.Filled.FastForward else Icons.Filled.FastRewind,
contentDescription = if (forward) "Skip forward $seconds seconds" else "Skip back $seconds seconds",
tint = primary, modifier = Modifier.size(27.dp),
)
Text("$seconds", style = OCText.SkipLabel, color = secondary)
}
}
Now Playing Player
import androidx.compose.foundation.background
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
@Composable
fun OCNowPlaying(episodeTitle: String, showName: String, dark: Boolean) {
var progress by remember { mutableFloatStateOf(0.38f) }
var playing by remember { mutableStateOf(true) }
val canvas = if (dark) OCColors.Canvas else OCColors.Paper
val primary = if (dark) OCColors.TextPrimaryDk else OCColors.TextPrimaryLt
val secondary = if (dark) OCColors.TextSecondaryDk else OCColors.TextSecondaryLt
val track = if (dark) OCColors.Surface2 else OCColors.DividerLight
Column(Modifier.fillMaxSize().background(canvas)) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 18.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Icon(Icons.AutoMirrored.Filled.KeyboardArrowLeft, "Collapse", tint = OCColors.Orange)
Text("Now Playing", style = OCText.RowTitle.copy(fontSize = 13.sp), color = primary)
Icon(Icons.AutoMirrored.Filled.List, "Queue", tint = primary)
}
Column(
Modifier.padding(horizontal = 34.dp, vertical = 6.dp)
.clip(RoundedCornerShape(10.dp)),
) {
Box(
Modifier.fillMaxWidth().aspectRatio(1f)
.background(Brush.linearGradient(listOf(Color(0xFF2B3A55), Color(0xFF121A2E))))
)
Box(Modifier.fillMaxWidth().height(5.dp).background(OCColors.Orange))
}
Column(Modifier.padding(horizontal = 34.dp)) {
Text(episodeTitle, style = OCText.NowPlaying, color = primary)
Text(showName, style = OCText.ShowName, color = secondary, modifier = Modifier.padding(top = 4.dp))
}
Column(Modifier.padding(horizontal = 30.dp, vertical = 4.dp)) {
Slider(
value = progress, onValueChange = { progress = it },
colors = SliderDefaults.colors(
thumbColor = OCColors.Orange,
activeTrackColor = OCColors.Orange,
inactiveTrackColor = track,
),
)
Row(Modifier.fillMaxWidth().padding(horizontal = 6.dp), horizontalArrangement = Arrangement.SpaceBetween) {
Text("48:12", style = OCText.Timecode, color = secondary)
Text("-1:22:40", style = OCText.Timecode, color = secondary)
}
}
Row(
Modifier.fillMaxWidth().padding(horizontal = 50.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
OCSkipButton(false, 30, primary, secondary) {}
OCPlayRing(playing, { playing = !playing })
OCSkipButton(true, 30, primary, secondary) {}
}
Row(
Modifier.fillMaxWidth().padding(horizontal = 26.dp).padding(bottom = 14.dp),
horizontalArrangement = Arrangement.SpaceAround,
) {
Effect(Icons.Filled.Speed, "1.3×", secondary, active = true)
Effect(Icons.Filled.GraphicEq, "Smart Speed", secondary, active = true)
Effect(Icons.Filled.VolumeUp, "Voice Boost", secondary, active = true)
Effect(Icons.Filled.StarBorder, "Star", secondary, active = false)
}
}
}
@Composable
private fun Effect(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, secondary: Color, active: Boolean) {
val c = if (active) OCColors.Orange else secondary
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(5.dp)) {
Icon(icon, label, tint = c, modifier = Modifier.size(19.dp))
Text(label, style = OCText.Chip, color = c)
}
}
Smart Speed / Voice Boost Toggle Row
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
@Composable
fun OCEffectToggleRow(
title: String, subtitle: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit,
dark: Boolean,
) {
val primary = if (dark) OCColors.TextPrimaryDk else OCColors.TextPrimaryLt
val secondary = if (dark) OCColors.TextSecondaryDk else OCColors.TextSecondaryLt
val divider = if (dark) OCColors.DividerDk else OCColors.DividerLight
val track = if (dark) OCColors.Surface2 else OCColors.DividerLight
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 15.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(Modifier.weight(1f)) {
Text(title, style = OCText.Toggle, color = primary)
Text(subtitle, style = OCText.PlDesc, color = secondary)
}
Switch(
checked = checked, onCheckedChange = onCheckedChange,
colors = SwitchDefaults.colors(
checkedThumbColor = Color.White,
checkedTrackColor = OCColors.Orange,
uncheckedThumbColor = Color.White,
uncheckedTrackColor = track,
),
)
}
HorizontalDivider(color = divider)
}
Playlist Row
@Composable
fun OCPlaylistRow(
icon: androidx.compose.ui.graphics.vector.ImageVector,
tile: Color, name: String, desc: String, count: Int, dark: Boolean,
) {
val primary = if (dark) OCColors.TextPrimaryDk else OCColors.TextPrimaryLt
val secondary = if (dark) OCColors.TextSecondaryDk else OCColors.TextSecondaryLt
val divider = if (dark) OCColors.DividerDk else OCColors.DividerLight
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
Box(
Modifier.size(38.dp).clip(RoundedCornerShape(9.dp)).background(tile),
contentAlignment = Alignment.Center,
) { Icon(icon, name, tint = Color.White, modifier = Modifier.size(18.dp)) }
Column(Modifier.weight(1f)) {
Text(name, style = OCText.RowTitle, color = primary)
Text(desc, style = OCText.PlDesc, color = secondary, maxLines = 1)
}
Text("$count", style = OCText.Count, color = secondary)
}
HorizontalDivider(color = divider)
}
Buttons
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.OutlinedButton
@Composable
fun OCPrimaryButton(title: String, onClick: () -> Unit) {
Button(
onClick = onClick,
shape = RoundedCornerShape(10.dp),
colors = ButtonDefaults.buttonColors(containerColor = OCColors.Orange, contentColor = Color.White),
contentPadding = PaddingValues(horizontal = 26.dp, vertical = 14.dp),
) { Text(title, style = OCText.Button) }
}
@Composable
fun OCOutlineButton(title: String, onClick: () -> Unit) {
OutlinedButton(
onClick = onClick,
shape = RoundedCornerShape(10.dp),
border = BorderStroke(1.5.dp, OCColors.Orange),
colors = ButtonDefaults.outlinedButtonColors(contentColor = OCColors.Orange),
contentPadding = PaddingValues(horizontal = 22.dp, vertical = 12.dp),
) { Text(title, style = OCText.RowTitle) }
}
4. Core Navigation (Bottom Bar)
Overcast has a 4-tab bottom strip. No tint pill — active is just the single orange accent.
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.filled.GridView
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
@Composable
fun OCBottomBar(selected: Int, onSelect: (Int) -> Unit, dark: Boolean) {
NavigationBar(
containerColor = if (dark) OCColors.Canvas else OCColors.Paper,
tonalElevation = 0.dp,
) {
val items = listOf(
"Playlists" to Icons.AutoMirrored.Filled.List,
"Podcasts" to Icons.Filled.GridView,
"Search" to Icons.Filled.Search,
"Settings" to Icons.Filled.Settings,
)
items.forEachIndexed { i, (label, icon) ->
NavigationBarItem(
selected = selected == i,
onClick = { onSelect(i) },
icon = { Icon(icon, label, modifier = Modifier.size(22.dp)) },
label = { Text(label, style = OCText.Tab) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = OCColors.Orange,
selectedTextColor = OCColors.Orange,
unselectedIconColor = OCColors.TextTertiary,
unselectedTextColor = OCColors.TextTertiary,
indicatorColor = Color.Transparent, // no Material pill — Overcast has none
),
)
}
}
}
5. Navigation
Use Navigation Compose / Nav3 with four destinations (Playlists, Podcasts, Search, Settings) plus a modal NowPlaying route presented as a bottom sheet that expands from the mini-player. Page transitions slide horizontally tween(300); the mini-player → full-player transition is a slideInVertically from the bottom tween(300) with the artwork as a shared element transitioning into the cover.
6. Motion
Overcast motion is quiet and editorial — 150–300ms ease ranges. Only the player cover gets a soft shadow.
| Moment | Compose recipe |
|---|---|
| Play/pause | icon swap Crossfade(playing, tween(200)); ring press scale 0.96 via animateFloatAsState |
| Scrubber drag | Slider thumb; grow via interactionSource pressed → thumb radius animate |
| Toggle flip | Switch animates track cross-fade to #FC7E0F implicitly |
| Mini → full player | slideInVertically(tween(300)) + shared-element artwork |
| Playlist row swipe | SwipeToDismissBox 1:1 reveal, 50% commit, spring(dampingRatio = 0.85f) |
| Theme switch | animateColorAsState on canvas/text tween(250) — cross-fade, no hard cut |
| Page navigation | NavHost slide push tween(300) |
// Ring press — the canonical Overcast micro-motion
val scale by animateFloatAsState(if (pressed) 0.96f else 1f, tween(120), label = "ringScale")
Haptics: use LocalHapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) for the soft impact on play, toggle flip, scrubber boundary, and the recommend action. The Smart Speed time-saved stat updates silently — no haptic.
7. Icons
Overcast uses simple stroked glyphs; the play control is an orange ring (a bordered Box, not an icon). androidx.compose.material:material-icons-extended covers the rest.
| Purpose | SF Symbol (iOS) | Material Icon (Compose) |
|---|---|---|
| Playlists (tab) | list.bullet |
Icons.AutoMirrored.Filled.List |
| Podcasts (tab) | square.grid.2x2 |
Icons.Filled.GridView |
| Search (tab) | magnifyingglass |
Icons.Filled.Search |
| Settings (tab) | gearshape |
Icons.Filled.Settings |
| Play / Pause | play.fill / pause.fill |
Icons.Filled.PlayArrow / Icons.Filled.Pause |
| Skip back / forward | chevron.left.2 / chevron.right.2 |
Icons.Filled.FastRewind / Icons.Filled.FastForward |
| Smart Speed | waveform.path |
Icons.Filled.GraphicEq |
| Voice Boost | speaker.wave.2.fill |
Icons.Filled.VolumeUp |
| Playback speed | speedometer |
Icons.Filled.Speed |
| Star | star / star.fill |
Icons.Filled.StarBorder / Icons.Filled.Star |
| In Progress (playlist) | clock.arrow.circlepath |
Icons.Filled.History |
| Downloaded | arrow.down.circle.fill |
Icons.Filled.DownloadDone |
| Collapse player | chevron.left |
Icons.AutoMirrored.Filled.KeyboardArrowLeft |
| Queue / list | list.bullet |
Icons.AutoMirrored.Filled.List |
| Recommend | hand.thumbsup |
Icons.Filled.ThumbUp |
| More | ellipsis |
Icons.Filled.MoreHoriz |
| Add podcast | plus |
Icons.Filled.Add |
8. Minimum SDK & Accessibility Notes
- minSdk 24 (Compose floor is 21;
Slider, modern motion comfortable at 24).compileSdk 34+, Compose BOM2024.09+, Kotlin2.0+. - Edge-to-edge: call
enableEdgeToEdge(); the paper-cream light theme wants dark-content system bars (light-content in dark mode). The mini-player sits directly above theNavigationBarinside the navigation-bar inset. - Paper canvas: use
#FBFAF6(surfaces#F7F5EF) for light — never pure white. Do not enable Material YoudynamicColorScheme(); the warm paper + single-orange identity must hold regardless of wallpaper. - Tabular timecodes: render durations/timecodes in a fixed-width
Box(ortnumfont feature on API 26+) so digits never shift while scrubbing. - Orange ring: the play button is a
BoxwithModifier.border(2.5.dp, OCColors.Orange, CircleShape)— never a filled circle. - Font scaling:
sphonors the user’s font scale — keep it on screen titles, episode titles, body, metadata. Pin layout-sensitive text (10sp tab labels, effect chips, timecodes, counts, skip-interval labels) viaCompositionLocalProvider(LocalDensity provides Density(density, fontScale = 1f)). - TalkBack: label the play ring “Play” / “Pause, {episode}”; the
Sliderexposes a percentagestateDescription— add custom actions for “Skip forward {n} seconds” / “Skip back {n} seconds”; playlist rows announce “Playlist: {name}, {count} episodes, {description}”. - Touch targets: Material guidance is 48.dp. The 66.dp play ring is fine; give 27.dp skip glyphs a 48.dp hit via padding; playlist rows are full-row tappable ≥56.dp; tab items are 48.dp by default.
- Contrast:
#FC7E0Fkeeps ≥3:1 against both#FBFAF6and#121212for the ring/icons;#1A1A1Aon#FBFAF6passes WCAG AAA for body. Never rely on orange alone for active effects — pair Smart Speed / Voice Boost ON with the labeled toggle + time-saved stat. - Reduce motion: when
Settings.Global.ANIMATOR_DURATION_SCALE == 0, disable the scrubber-thumb grow, the play/pause crossfade, and the theme cross-fade; keep state changes. - Dark mode: invert via the dark token set — true
#121212,#F2F2F2text; the single orange accent is unchanged.