Calm (iOS) — Jetpack Compose Implementation Guide
Companion to DESIGN.md — the framework-neutral spec. This file ports Calm’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 (Calm’s night-sky gradient, glass surfaces, serif/sans pairing, the breathe bubble) while making everything idiomatic Android —
NavigationBarinstead of a UITabBar,Brush.verticalGradientinstead of CAGradientLayer,sp/dpinstead ofpt.
Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, Coil 2.6+ for imagery, and Lora + Inter FontFamilys in res/font/.
1. Color Tokens
// ui/theme/CalmColors.kt
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
object CalmColors {
// Night-sky gradient
val GradientTop = Color(0xFF2A6FD6)
val GradientMid = Color(0xFF1A4A8F)
val GradientBottom = Color(0xFF0B1E3F)
val ScrimNavy = Color(0xFF091830)
// Glass surfaces
val Glass = Color.White.copy(alpha = 0.08f)
val GlassRaised = Color.White.copy(alpha = 0.12f)
val GlassDivider = Color.White.copy(alpha = 0.12f)
val GlassHover = Color.White.copy(alpha = 0.16f)
// Text
val TextPrimary = Color(0xFFFFFFFF)
val TextSecondary = Color(0xFFB8C4D8)
val TextTertiary = Color(0xFF8794A8)
// Accent
val CalmBlue = Color(0xFF2A6FD6)
val CalmBluePressed = Color(0xFF1F57AB)
val CalmBlueSoft = Color(0xFF4A85E0)
val StreakGold = Color(0xFFF2C94C)
val GentleError = Color(0xFFE08A8A)
val Success = Color(0xFF7FB8A0)
}
val CalmSky = Brush.verticalGradient(
0.0f to CalmColors.GradientTop,
0.45f to CalmColors.GradientMid,
1.0f to CalmColors.GradientBottom,
)
Wire it into a Material 3 darkColorScheme. Calm is dark-only; do not provide a light scheme.
// ui/theme/Theme.kt
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
private val CalmScheme = darkColorScheme(
primary = CalmColors.CalmBlue,
onPrimary = CalmColors.TextPrimary,
background = CalmColors.GradientBottom,
onBackground = CalmColors.TextPrimary,
surface = CalmColors.GradientMid,
onSurface = CalmColors.TextPrimary,
surfaceVariant = CalmColors.GlassRaised,
outline = CalmColors.GlassDivider,
error = CalmColors.GentleError,
)
@Composable
fun CalmTheme(content: @Composable () -> Unit) =
MaterialTheme(colorScheme = CalmScheme, typography = CalmTypography, content = content)
2. Typography
Lora (serif headlines) and Inter (sans body) are both open-source (SIL OFL). Drop the TTFs in res/font/ (lowercase, snake_case) and build two FontFamilys.
// ui/theme/CalmType.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 Lora = FontFamily(
Font(R.font.lora_regular, FontWeight.Normal), // 400
Font(R.font.lora_semibold, FontWeight.SemiBold), // 600
)
val Inter = FontFamily(
Font(R.font.inter_light, FontWeight.Light), // 300
Font(R.font.inter_regular, FontWeight.Normal), // 400
Font(R.font.inter_medium, FontWeight.Medium), // 500
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 CalmText {
// Lora — serif, the brand voice
val Greeting = TextStyle(Lora, fontWeight = FontWeight.SemiBold, fontSize = 30.sp, lineHeight = 38.sp, letterSpacing = (-0.2).sp)
val ScreenTitle = TextStyle(Lora, fontWeight = FontWeight.SemiBold, fontSize = 26.sp, lineHeight = 34.sp, letterSpacing = (-0.2).sp)
val DailyTitle = TextStyle(Lora, fontWeight = FontWeight.SemiBold, fontSize = 24.sp, lineHeight = 31.sp, letterSpacing = (-0.1).sp)
val CardTitle = TextStyle(Lora, fontWeight = FontWeight.SemiBold, fontSize = 19.sp, lineHeight = 26.sp)
val BreatheCue = TextStyle(Lora, fontWeight = FontWeight.Normal, fontSize = 22.sp, letterSpacing = 0.5.sp)
// Inter — sans, supporting
val Section = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 16.sp, letterSpacing = 0.2.sp)
val RowTitle = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 17.sp, lineHeight = 23.sp)
val Body = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 17.sp, lineHeight = 26.sp)
val Subtitle = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 15.sp, lineHeight = 21.sp)
val Button = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 17.sp, letterSpacing = 0.1.sp)
val Meta = TextStyle(Inter, fontWeight = FontWeight.Medium, fontSize = 13.sp, letterSpacing = 0.2.sp)
val Tab = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 11.sp, letterSpacing = 0.2.sp)
val Timer = TextStyle(Inter, fontWeight = FontWeight.Light, fontSize = 44.sp)
val LabelUpper = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 12.sp, letterSpacing = 1.0.sp)
}
// Map onto Material 3 slots so stock components inherit the brand
val CalmTypography = Typography(
headlineLarge = CalmText.Greeting,
headlineSmall = CalmText.ScreenTitle,
titleMedium = CalmText.RowTitle,
bodyLarge = CalmText.Body,
labelSmall = CalmText.Tab,
)
3. Signature Components
Night-Sky Background
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@Composable
fun CalmBackground(content: @Composable BoxScope.() -> Unit) {
Box(Modifier.fillMaxSize().background(CalmSky), content = content)
}
Breathe Bubble (signature)
import androidx.compose.animation.core.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
@Composable
fun BreatheBubble(modifier: Modifier = Modifier) {
val anim = remember { Animatable(120f) }
var cue by remember { mutableStateOf("Breathe in") }
LaunchedEffect(Unit) {
while (true) {
cue = "Breathe in"
anim.animateTo(220f, tween(4000, easing = EaseIn)) // inhale
cue = "Hold"
delay(7000) // hold
cue = "Breathe out"
anim.animateTo(120f, tween(8000, easing = EaseOut)) // exhale
}
}
Box(modifier, contentAlignment = Alignment.Center) {
Box(
Modifier
.size(anim.value.dp)
.shadow(40.dp, CircleShape, spotColor = CalmColors.CalmBlueSoft)
.background(Color.White.copy(alpha = 0.9f), CircleShape),
contentAlignment = Alignment.Center,
) {
Text(cue, style = CalmText.BreatheCue, color = CalmColors.GradientBottom)
}
}
}
Primary Play Pill
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Icon
import androidx.compose.ui.draw.scale
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
@Composable
fun CalmPlayPill(title: String, onClick: () -> Unit, modifier: Modifier = Modifier) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.97f else 1f,
tween(250, easing = EaseOut), label = "pillScale")
val haptics = LocalHapticFeedback.current
Row(
modifier
.scale(scale)
.clip(CircleShape)
.background(if (pressed) CalmColors.CalmBluePressed else CalmColors.CalmBlue)
.clickable(interaction, indication = null) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress) // ~iOS soft
onClick()
}
.padding(horizontal = 40.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Icon(Icons.Filled.PlayArrow, null, tint = Color.White, modifier = Modifier.size(16.dp))
Text(title, style = CalmText.Button, color = CalmColors.TextPrimary)
}
}
Daily Calm Hero Card (signature)
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.graphics.Brush
@Composable
fun DailyCalmHero(
title: String, subtitle: String, photoUrl: String, onPlay: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(
modifier
.fillMaxWidth()
.height(220.dp)
.clip(RoundedCornerShape(20.dp)),
) {
AsyncImage(model = photoUrl, contentDescription = null,
contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize())
Box(
Modifier.fillMaxSize().background(
Brush.verticalGradient(
0.4f to Color.Transparent,
1f to CalmColors.ScrimNavy.copy(alpha = 0.85f),
)
)
)
Row(
Modifier.align(Alignment.BottomStart).padding(20.dp).fillMaxWidth(),
verticalAlignment = Alignment.Bottom,
) {
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("DAILY CALM", style = CalmText.LabelUpper, color = CalmColors.TextPrimary)
Text(title, style = CalmText.DailyTitle, color = CalmColors.TextPrimary)
Text(subtitle, style = CalmText.Subtitle, color = CalmColors.TextSecondary)
}
Box(
Modifier.size(44.dp).clip(CircleShape)
.background(CalmColors.GlassHover)
.clickable(onClick = onPlay),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Filled.PlayArrow, "Play",
tint = Color.White, modifier = Modifier.size(18.dp))
}
}
}
}
Sleep Story List Row
import androidx.compose.material.icons.outlined.Download
@Composable
fun SleepStoryRow(
title: String, narrator: String, duration: String, thumbUrl: String,
onClick: () -> Unit, modifier: Modifier = Modifier,
) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
Column {
Row(
modifier
.fillMaxWidth()
.height(76.dp)
.background(if (pressed) CalmColors.GlassRaised else Color.Transparent)
.clickable(interaction, indication = null, onClick = onClick)
.padding(horizontal = 20.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
AsyncImage(model = thumbUrl, contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.size(56.dp).clip(RoundedCornerShape(12.dp)))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(title, style = CalmText.RowTitle, color = CalmColors.TextPrimary, maxLines = 1)
Text("Narrated by $narrator · $duration",
style = CalmText.Subtitle, color = CalmColors.TextSecondary, maxLines = 1)
}
Icon(Icons.Outlined.Download, "Download",
tint = Color.White.copy(alpha = 0.8f), modifier = Modifier.size(22.dp))
}
HorizontalDivider(color = CalmColors.GlassDivider, thickness = 1.dp)
}
}
Glass Card
@Composable
fun CalmGlassCard(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) {
Column(
modifier
.clip(RoundedCornerShape(20.dp))
.background(CalmColors.Glass) // Android has no live blur; value-only glass
.border(1.dp, CalmColors.GlassDivider, RoundedCornerShape(20.dp))
.padding(20.dp),
content = content,
)
}
4. Bottom Navigation (Tab Bar)
Material 3 NavigationBar. Calm’s iOS tab bar is .regularMaterial glass over the deep navy; Android has no first-class live blur, so use an 88%-opaque navy surface. Active tint is Calm Blue.
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
@Composable
fun CalmBottomBar(selected: Int, onSelect: (Int) -> Unit) {
NavigationBar(
containerColor = CalmColors.GradientBottom.copy(alpha = 0.88f),
tonalElevation = 0.dp,
) {
val items = listOf(
"Home" to Icons.Filled.Home,
"Sleep" to Icons.Filled.Bedtime,
"Meditate" to Icons.Filled.SelfImprovement,
"Music" to Icons.Filled.MusicNote,
"More" to Icons.Filled.MoreHoriz,
)
items.forEachIndexed { i, (label, icon) ->
NavigationBarItem(
selected = selected == i,
onClick = { onSelect(i) },
icon = { Icon(icon, label, modifier = Modifier.size(24.dp)) },
label = { Text(label, style = CalmText.Tab) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = CalmColors.CalmBlue,
selectedTextColor = CalmColors.CalmBlue,
unselectedIconColor = Color.White.copy(alpha = 0.6f),
unselectedTextColor = Color.White.copy(alpha = 0.6f),
indicatorColor = Color.Transparent, // Calm has no Material pill
),
)
}
}
}
5. Motion
| Moment | Compose recipe |
|---|---|
| Breathe bubble | Animatable 120 → 220 tween(4000, EaseIn), delay(7000), 220 → 120 tween(8000, EaseOut), looped in LaunchedEffect |
| Card / pill tap | animateFloatAsState 1 → 0.97 tween(250, EaseOut), HapticFeedbackType.LongPress |
| Screen transition | AnimatedContent with fadeIn(tween(550)) togetherWith fadeOut(tween(550)) — Calm fades, never hard-slides |
| Session timer ring | Animatable 0 → 1 tween(sessionMs, LinearEasing) driving a drawArc sweep |
| Session complete | soft sage check fadeIn(tween(600)) + single HapticFeedbackType.LongPress |
| Photo parallax | offset hero ~8.dp from scroll via Modifier.graphicsLayer { translationY = scroll * 0.1f } |
@Composable
fun SessionTimerRing(progress: Float) { // progress 0f..1f animated linearly
Canvas(Modifier.size(220.dp)) {
drawArc(CalmColors.GlassHover, -90f, 360f, false,
style = Stroke(3.dp.toPx(), cap = StrokeCap.Round))
drawArc(CalmColors.CalmBlue, -90f, 360f * progress, false,
style = Stroke(3.dp.toPx(), cap = StrokeCap.Round))
}
}
Haptics: prefer LocalHapticFeedback. Keep them gentle and rare — Calm is serene. For a softer pulse use Vibrator VibrationEffect.createOneShot(8, 60) rather than the default click.
6. Icons
The closest first-party set is androidx.compose.material:material-icons-extended.
| Purpose | SF Symbol (iOS) | Material Icon (Compose) |
|---|---|---|
| Play | play.fill |
Icons.Filled.PlayArrow |
| Pause | pause.fill |
Icons.Filled.Pause |
| Favorite | heart / heart.fill |
Icons.Outlined.FavoriteBorder / Icons.Filled.Favorite |
| Download | arrow.down.circle |
Icons.Outlined.Download |
| Share | square.and.arrow.up |
Icons.Filled.IosShare |
| Background sound | slider.horizontal.3 |
Icons.Filled.Tune |
| Timer | timer |
Icons.Filled.Timer |
| Search | magnifyingglass |
Icons.Filled.Search |
| Streak | flame.fill |
Icons.Filled.LocalFireDepartment |
| Settings | gearshape |
Icons.Filled.Settings |
| Home (tab) | house.fill |
Icons.Filled.Home |
| Sleep (tab) | moon.stars.fill |
Icons.Filled.Bedtime |
| Meditate (tab) | figure.mind.and.body |
Icons.Filled.SelfImprovement |
| Music (tab) | music.note |
Icons.Filled.MusicNote |
| More (tab) | ellipsis |
Icons.Filled.MoreHoriz |
7. 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(); the night-sky gradient runs full-bleed behind the system bars (light-content). ApplyModifier.windowInsetsPadding(WindowInsets.systemBars)to content so the tab bar clears the gesture nav, but let the gradient ignore insets. - No live blur: Android lacks a first-class backdrop blur — glass surfaces are value-only (
Color.White.copy(alpha = 0.08f)over the gradient). Bump alpha slightly vs iOS so cards register without the blur depth cue. - Font scaling:
sphonors the user’s font scale — keep it on greetings, titles, body. Pin layout-sensitive text (tab labels, duration meta, the 44sp timer) by wrapping inCompositionLocalProvider(LocalDensity provides Density(density, fontScale = 1f)). - TalkBack: mark the breathe bubble
Modifier.clearAndSetSemantics { }(decorative) and announce phase changes via a live region; label the play pill (“Play Daily Calm”); merge story-row text withModifier.semantics(mergeDescendants = true). - Reduce Motion: gate the breathe-bubble animation on
Settings.Global.ANIMATOR_DURATION_SCALE— render a static circle with a text-only phase cue when animations are off; replace fades with instant swaps. - Touch targets: Material guidance is 48.dp minimum. The 44.dp play buttons are close — give the 22.dp download glyph a
Modifier.size(48.dp)hit area via padding; rows are already 76.dp. - Contrast:
#B8C4D8on the mid-gradient passes AA at 15sp+; validate the 13sp meta over the lighter top band and darken the scrim if your build targets accessibility compliance. - Dynamic color: do not enable Material You
dynamicDarkColorScheme()— Calm’s identity requires the fixed night-sky gradient and single Calm Blue accent regardless of wallpaper.