Hevy (iOS) — Jetpack Compose Implementation Guide
Companion to DESIGN.md — the framework-neutral spec. This file ports Hevy’s visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, paste-ready @Composables for the live workout log, 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 (Hevy’s near-black training canvas, the single Hevy-Blue action color, the dense set table with tabular figures, the gold PR badge) while making everything idiomatic Android —
NavigationBarinstead of a UITabBar,BasicTextFieldset cells, anOngoingActivitynotification instead of a Live Activity,sp/dpinstead ofpt.
Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ for avatars. Hevy is dark-first; a light scheme is provided but opt-in. Do not enable Material You dynamic color — the single Hevy-Blue accent must hold regardless of wallpaper.
1. Color Tokens
// ui/theme/HevyColors.kt
import androidx.compose.ui.graphics.Color
object HevyColors {
// Canvas & Surfaces (Dark — default)
val Canvas = Color(0xFF0E1116) // near-black, blue-cool — NOT pure black
val Card = Color(0xFF161B22)
val Input = Color(0xFF1F2630)
val Surface3 = Color(0xFF283040)
val Divider = Color(0xFF262C36)
// Canvas & Surfaces (Light — optional, opt-in)
val LightCanvas = Color(0xFFF4F6FA)
val LightCard = Color(0xFFFFFFFF)
val LightInput = Color(0xFFEEF1F6)
val LightDivider = Color(0xFFE2E6EE)
// Brand — single action color
val Blue = Color(0xFF1E6FFF)
val BluePressed = Color(0xFF1857CC)
val BlueSoft = Color(0xFF14233F)
// Text
val TextPrimary = Color(0xFFF2F4F8)
val TextSecondary = Color(0xFF9BA3B0)
val TextTertiary = Color(0xFF5E6675)
// Semantic
val Done = Color(0xFF2FBF71)
val PR = Color(0xFFF5B83D)
val Error = Color(0xFFF1545B)
val Warn = Color(0xFFF5A623)
// Translucent
val RowWash = Color(0xFF2FBF71).copy(alpha = 0.16f)
val PRBadgeBg = Color(0xFFF5B83D).copy(alpha = 0.18f)
val RingTrack = Color(0xFF1E6FFF).copy(alpha = 0.30f)
val RingEdge = Color(0xFF1E6FFF).copy(alpha = 0.35f)
val TabFill = Color(0xFF1E6FFF).copy(alpha = 0.16f)
}
Wire it into both schemes. Hevy is dark-first; the dark scheme uses the signature #0E1116, never true black.
// ui/theme/Theme.kt
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
private val HevyDark = darkColorScheme(
primary = HevyColors.Blue,
onPrimary = Color(0xFFFFFFFF),
background = HevyColors.Canvas,
onBackground = HevyColors.TextPrimary,
surface = HevyColors.Card,
onSurface = HevyColors.TextPrimary,
surfaceVariant = HevyColors.Input,
outline = HevyColors.Divider,
error = HevyColors.Error,
)
private val HevyLight = lightColorScheme(
primary = HevyColors.Blue,
onPrimary = Color(0xFFFFFFFF),
background = HevyColors.LightCanvas,
onBackground = Color(0xFF11151C),
surface = HevyColors.LightCard,
onSurface = Color(0xFF11151C),
surfaceVariant = HevyColors.LightInput,
outline = HevyColors.LightDivider,
error = HevyColors.Error,
)
@Composable
fun HevyTheme(
dark: Boolean = isSystemInDarkTheme(), // default true in app settings
content: @Composable () -> Unit,
) = MaterialTheme(
colorScheme = if (dark) HevyDark else HevyLight,
typography = HevyTypography,
content = content,
)
2. Typography
Hevy ships one face — Inter — drop the TTFs in res/font/. Every numeric style sets a tabular-figures FontFeatureSetting so the set table and timers never reflow.
// ui/theme/HevyType.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 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),
)
private const val TNUM = "tnum" // tabular figures — lock column widths
object HevyText {
val ScreenTitle = TextStyle(Inter, fontWeight = FontWeight.ExtraBold, fontSize = 32.sp, lineHeight = 37.sp, letterSpacing = (-0.6).sp)
val Routine = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 26.sp, lineHeight = 31.sp, letterSpacing = (-0.4).sp)
val Section = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 22.sp, lineHeight = 28.sp, letterSpacing = (-0.3).sp)
val Exercise = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 18.sp, lineHeight = 23.sp, letterSpacing = (-0.2).sp)
val Metric = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 17.sp, lineHeight = 20.sp, fontFeatureSettings = TNUM)
val Body = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 24.sp)
val SetCell = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, lineHeight = 20.sp, fontFeatureSettings = TNUM)
val CardTitle = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, lineHeight = 20.sp, letterSpacing = (-0.1).sp)
val Caption = TextStyle(Inter, fontWeight = FontWeight.Medium, fontSize = 13.sp, lineHeight = 18.sp)
val Column = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 11.sp, lineHeight = 12.sp, letterSpacing = 0.5.sp)
val Tab = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 10.sp, lineHeight = 11.sp, letterSpacing = 0.1.sp)
val Button = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 16.sp, lineHeight = 16.sp)
val Badge = TextStyle(Inter, fontWeight = FontWeight.ExtraBold, fontSize = 10.sp, lineHeight = 11.sp, letterSpacing = 0.3.sp)
}
// Map onto Material 3 slots so stock components inherit the brand
val HevyTypography = Typography(
headlineLarge = HevyText.ScreenTitle,
headlineMedium = HevyText.Routine,
titleMedium = HevyText.Exercise,
bodyMedium = HevyText.Body,
labelSmall = HevyText.Tab,
)
3. Signature Components
Active-Workout Header
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun WorkoutHeader(
routineName: String,
duration: String, volume: String, setCount: Int,
onFinish: () -> Unit,
) {
Column(Modifier.fillMaxWidth().padding(horizontal = 18.dp).padding(bottom = 12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(routineName, style = HevyText.Routine, color = HevyColors.TextPrimary)
Spacer(Modifier.weight(1f))
Button(
onClick = onFinish,
shape = RoundedCornerShape(10.dp),
colors = ButtonDefaults.buttonColors(containerColor = HevyColors.Blue),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 7.dp),
) { Text("Finish", style = HevyText.Button.copy(fontSize = 13.sp), color = Color.White) }
}
Row(horizontalArrangement = Arrangement.spacedBy(22.dp)) {
Stat("Duration", duration, live = true)
Stat("Volume", volume, live = false)
Stat("Sets", "$setCount", live = false)
}
}
}
@Composable
private fun Stat(label: String, value: String, live: Boolean) {
Column {
Text(label, style = HevyText.Caption.copy(fontSize = 11.sp), color = HevyColors.TextSecondary)
Text(value, style = HevyText.Metric, color = if (live) HevyColors.Blue else HevyColors.TextPrimary)
}
}
Set Row (the core atom)
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Icon
import androidx.compose.runtime.*
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
@Composable
fun SetRow(index: Int, previous: String) {
var weight by remember { mutableStateOf("") }
var reps by remember { mutableStateOf("") }
var done by remember { mutableStateOf(false) }
val haptics = LocalHapticFeedback.current
val rowBg by animateColorAsState(if (done) HevyColors.RowWash else Color.Transparent, tween(150), label = "rowBg")
Row(
Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)).background(rowBg).padding(vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Box(Modifier.size(28.dp).clip(RoundedCornerShape(8.dp)).background(HevyColors.Input),
contentAlignment = Alignment.Center) {
Text("$index", style = HevyText.Caption.copy(fontWeight = FontWeight.Bold), color = HevyColors.TextSecondary)
}
Text(previous, style = HevyText.Caption.copy(fontFeatureSettings = "tnum"),
color = HevyColors.TextTertiary, modifier = Modifier.weight(1f), textAlign = TextAlign.Center)
Cell(weight, { weight = it }, done)
Cell(reps, { reps = it }, done)
Box(
Modifier.size(26.dp).clip(RoundedCornerShape(8.dp))
.background(if (done) HevyColors.Done else Color.Transparent)
.border(1.5.dp, if (done) HevyColors.Done else HevyColors.Divider, RoundedCornerShape(8.dp))
.clickable {
done = !done
haptics.performHapticFeedback(HapticFeedbackType.LongPress) // success analog
},
contentAlignment = Alignment.Center,
) {
if (done) Icon(Icons.Filled.Check, null, tint = Color.White, modifier = Modifier.size(14.dp))
}
}
}
@Composable
private fun RowScope.Cell(value: String, onChange: (String) -> Unit, done: Boolean) {
val src = remember { MutableInteractionSource() }
val focused by src.collectIsFocusedAsState()
BasicTextField(
value = value, onValueChange = onChange,
interactionSource = src,
textStyle = HevyText.SetCell.copy(
color = if (done) HevyColors.Done else HevyColors.TextPrimary, textAlign = TextAlign.Center),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
modifier = Modifier.weight(1f).height(36.dp).clip(RoundedCornerShape(8.dp))
.background(HevyColors.Input)
.border(1.5.dp, if (focused) HevyColors.Blue else Color.Transparent, RoundedCornerShape(8.dp)),
)
}
Rest-Timer Pill
import androidx.compose.foundation.Canvas
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
@Composable
fun RestTimerPill(remaining: Int, total: Int, onSkip: () -> Unit) {
val progress = if (total == 0) 0f else 1f - remaining.toFloat() / total
Row(
Modifier.fillMaxWidth().padding(horizontal = 18.dp).height(44.dp)
.clip(RoundedCornerShape(12.dp)).background(HevyColors.BlueSoft)
.border(1.dp, HevyColors.RingEdge, RoundedCornerShape(12.dp))
.padding(horizontal = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Canvas(Modifier.size(22.dp)) {
drawCircle(HevyColors.RingTrack, style = Stroke(width = 3.dp.toPx()))
drawArc(
color = HevyColors.Blue, startAngle = -90f, sweepAngle = 360f * progress,
useCenter = false, size = Size(size.width, size.height),
style = Stroke(width = 3.dp.toPx(), cap = StrokeCap.Round),
)
}
Text("Rest · ${remaining / 60}:${(remaining % 60).toString().padStart(2, '0')}",
style = HevyText.SetCell.copy(fontSize = 14.sp), color = HevyColors.Blue,
modifier = Modifier.weight(1f))
Text("Skip", style = HevyText.Caption.copy(fontWeight = FontWeight.SemiBold),
color = HevyColors.TextSecondary, modifier = Modifier.clickable { onSkip() })
}
}
PR Badge
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
@Composable
fun PRBadge(text: String) {
var shown by remember { mutableStateOf(false) }
val scale by animateFloatAsState(
if (shown) 1f else 0.9f,
spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow),
label = "prScale",
)
val haptics = LocalHapticFeedback.current
LaunchedEffect(Unit) { shown = true; haptics.performHapticFeedback(HapticFeedbackType.LongPress) }
Box(
Modifier.graphicsLayer { scaleX = scale; scaleY = scale; alpha = if (shown) 1f else 0f }
.clip(RoundedCornerShape(6.dp)).background(HevyColors.PRBadgeBg)
.padding(horizontal = 7.dp, vertical = 3.dp),
) {
Text(text.uppercase(), style = HevyText.Badge, color = HevyColors.PR)
}
}
Exercise Card
import androidx.compose.material.icons.filled.MoreHoriz
import androidx.compose.material.icons.filled.FitnessCenter
@Composable
fun ExerciseCard(
name: String,
onAddSet: () -> Unit,
sets: @Composable ColumnScope.() -> Unit,
) {
Column(
Modifier.fillMaxWidth().clip(RoundedCornerShape(16.dp)).background(HevyColors.Card)
.border(1.dp, HevyColors.Divider, RoundedCornerShape(16.dp)).padding(14.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Box(Modifier.size(36.dp).clip(RoundedCornerShape(10.dp)).background(HevyColors.Surface3),
contentAlignment = Alignment.Center) {
Icon(Icons.Filled.FitnessCenter, null, tint = HevyColors.Blue, modifier = Modifier.size(20.dp))
}
Text(name, style = HevyText.Exercise, color = HevyColors.Blue, modifier = Modifier.weight(1f))
Icon(Icons.Filled.MoreHoriz, "Exercise menu", tint = HevyColors.TextTertiary)
}
sets()
Box(
Modifier.fillMaxWidth().height(34.dp).clip(RoundedCornerShape(9.dp))
.background(HevyColors.Input).clickable { onAddSet() },
contentAlignment = Alignment.Center,
) { Text("+ Add Set", style = HevyText.Caption.copy(fontWeight = FontWeight.Bold), color = HevyColors.TextSecondary) }
}
}
4. Navigation
Hevy has a 5-tab bottom strip and large-title screens. On Android, model the strip as a NavigationBar. There is no tint pill — active is Hevy Blue with a soft fill.
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
@Composable
fun HevyBottomBar(selected: Int, onSelect: (Int) -> Unit) {
NavigationBar(containerColor = HevyColors.Canvas, tonalElevation = 0.dp) {
val items = listOf(
"Home" to Icons.Filled.Home,
"Profile" to Icons.Filled.AccountCircle,
"Workout" to Icons.Filled.AddCircle,
"History" to Icons.Filled.CalendarMonth,
"Exercises" to Icons.Filled.FitnessCenter,
)
items.forEachIndexed { i, (label, icon) ->
NavigationBarItem(
selected = selected == i,
onClick = { onSelect(i) },
icon = { Icon(icon, label, modifier = Modifier.size(if (i == 2) 24.dp else 22.dp)) },
label = { Text(label, style = HevyText.Tab) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = HevyColors.Blue,
selectedTextColor = HevyColors.Blue,
unselectedIconColor = HevyColors.TextTertiary,
unselectedTextColor = HevyColors.TextTertiary,
indicatorColor = HevyColors.TabFill, // soft blue fill, not a Material pill
),
)
}
}
}
Large-title screens (Home / History / Exercises) use a LargeTopAppBar whose 32sp title collapses to a 17sp centered title on scroll (TopAppBarDefaults.exitUntilCollapsedScrollBehavior).
5. Motion
Hevy motion is functional and fast — a gym tool must feel instant. Range 120–320ms.
| Moment | Compose recipe |
|---|---|
| Set check | row bg animateColorAsState → RowWash tween(150); checkmark fills; success haptic; auto-start rest |
| PR badge | animateFloatAsState 0.9→1.0 spring(MediumBouncy, Low) + alpha; medium haptic |
| Add set | new row AnimatedVisibility expandVertically(tween(200)) + fadeIn |
| Finish → summary | navigate to full-screen route, slide-up tween(320) |
| Rest ring | Canvas arc driven by a 1s-tick countdown (or animateFloatAsState linear over total) |
| Tab switch | instant — no crossfade (immediacy over polish) |
| Pull-to-refresh (feed) | Material PullToRefreshBox; cards fadeIn on load |
// Set check — the canonical Hevy motion
val rowBg by animateColorAsState(
targetValue = if (done) HevyColors.RowWash else Color.Transparent,
animationSpec = tween(150),
label = "rowBg",
)
Haptics: LocalHapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) for the soft success on set check and the medium tap on a PR; for the rest-timer ±/end use LocalView.current.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK). Auto-save of the in-progress workout is silent — no motion, no haptic.
6. Icons
The closest first-party set is androidx.compose.material:material-icons-extended. The set-checkmark is Check; exercise glyphs map to FitnessCenter.
| Purpose | SF Symbol (iOS) | Material Icon (Compose) |
|---|---|---|
| Home (tab) | house / house.fill |
Icons.Filled.Home |
| Profile (tab) | person.crop.circle |
Icons.Filled.AccountCircle |
| Workout (tab) | plus.circle.fill |
Icons.Filled.AddCircle |
| History (tab) | calendar |
Icons.Filled.CalendarMonth |
| Exercises (tab) | chart.bar.fill |
Icons.Filled.FitnessCenter |
| Set checkmark | checkmark |
Icons.Filled.Check |
| Exercise menu | ellipsis |
Icons.Filled.MoreHoriz |
| Rest timer | timer |
Icons.Filled.Timer |
| Add | plus |
Icons.Filled.Add |
| PR badge | star.fill |
Icons.Filled.Star |
| Superset | link |
Icons.Filled.Link |
| Warm-up | flame |
Icons.Filled.LocalFireDepartment |
| Reorder | line.3.horizontal |
Icons.Filled.DragHandle |
| Delete | trash |
Icons.Filled.Delete |
| Volume chart | chart.xyaxis.line |
Icons.Filled.ShowChart |
| Kudos | hand.thumbsup |
Icons.Filled.ThumbUp |
| Comment | bubble.left |
Icons.Filled.ChatBubbleOutline |
| Share | square.and.arrow.up |
Icons.Filled.IosShare |
7. Minimum SDK & Accessibility Notes
- minSdk 24 (Compose floor is 21; modern motion +
BasicTextFieldare comfortable at 24).compileSdk 34+, Compose BOM2024.09+, Kotlin2.0+. - Edge-to-edge: call
enableEdgeToEdge(); the near-black canvas wants light-content system bars. The set-cell keyboard must not hide the active row — useModifier.imePadding()on the workout list and keep the rest pill pinned above the IME. - Tabular figures: every numeric
TextStylesetsfontFeatureSettings = "tnum"(weights, reps, sets, volume, 1RM, timers) so the set table and timers never reflow. - Font scaling:
sphonors the user’s font scale — keep it on titles/body/cells. Pin layout-sensitive text (11sp column labels, 10sp tab labels, PR badge, set-index chip) by deriving fromdporCompositionLocalProvider(LocalDensity provides Density(density, fontScale = 1f)). - TalkBack: label a set row “Set {n}, previous {prev}, {weight} kilograms, {reps} reps, {completed/not completed}”; expose the checkmark via
Modifier.toggleable; announce a PR withLiveRegionMode.Assertive(“New personal record, {detail}”). - Touch targets: Material guidance is 48.dp. Give the 26.dp checkmark and 28.dp index chip a 48.dp hit area via padding; set cells are 36.dp tall but full-cell tappable; primary buttons ≥ 36.dp.
- Contrast:
#F2F4F8on#0E1116and#1E6FFFon#0E1116pass WCAG AA. The green row-wash is decorative — the checkmark + TalkBack state carry completion, not color alone. - Reduce motion: when
Settings.Global.ANIMATOR_DURATION_SCALE == 0, disable the PR spring/pulse and the add-set expand (substitute a plainCrossfade); keep the rest-ring sweep (it conveys remaining time). - Ongoing rest timer: mirror the rest countdown to an
OngoingActivity/ foreground-service notification (and WearOngoingActivityif targeting Wear OS) with the remaining time; keep digits monospaced. - Dark mode: dark is the default; the light scheme is opt-in via settings. Do not enable
dynamicColorScheme()— Hevy’s single-blue identity must hold regardless of wallpaper.