AllTrails (iOS) — Jetpack Compose Implementation Guide
Companion to DESIGN.md — the framework-neutral spec. This file ports AllTrails’ 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 (AllTrails’ topo-paper light space, the trail card, the difficulty color scale, map-first explore, the route trace) while making everything idiomatic Android —
NavigationBarinstead of a UITabBar,Canvas/drawPathinstead of aCAShapeLayer,sp/dpinstead ofpt.
Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, Coil 2.6+ for photos, the Maps Compose library for the map, and an Inter FontFamily in res/font/.
1. Color Tokens
// ui/theme/AllTrailsColors.kt
import androidx.compose.ui.graphics.Color
object AllTrailsColors {
// Canvas & Surfaces
val Canvas = Color(0xFFFFFFFF)
val SurfaceSage = Color(0xFFF2F4F1)
val SurfaceSunken = Color(0xFFEBEEE8)
val Divider = Color(0xFFE2E6DF)
// Text
val TextPrimary = Color(0xFF1A1A1A)
val TextSecondary = Color(0xFF6B6F68)
val TextTertiary = Color(0xFF9A9E96)
// Brand
val Green = Color(0xFF428000)
val GreenPressed = Color(0xFF336300)
val GreenSoft = Color(0xFFE8F0DF)
// Difficulty scale (color = meaning)
val Easy = Color(0xFF428000)
val Moderate = Color(0xFFC77700)
val Hard = Color(0xFFB3261E)
val EasyBg = Color(0xFFE8F0DF)
val ModerateBg = Color(0xFFFBEEDD)
val HardBg = Color(0xFFF7E0DE)
// Semantic
val StarGold = Color(0xFFF2A93B)
}
enum class Difficulty(val color: Color, val bg: Color, val label: String) {
Easy(AllTrailsColors.Easy, AllTrailsColors.EasyBg, "EASY"),
Moderate(AllTrailsColors.Moderate, AllTrailsColors.ModerateBg, "MODERATE"),
Hard(AllTrailsColors.Hard, AllTrailsColors.HardBg, "HARD"),
}
Wire it into a Material 3 lightColorScheme — AllTrails is light-first. A dark scheme exists only for OS parity / night nav.
// ui/theme/Theme.kt
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
private val AllTrailsScheme = lightColorScheme(
primary = AllTrailsColors.Green,
onPrimary = Color.White,
background = AllTrailsColors.Canvas,
onBackground = AllTrailsColors.TextPrimary,
surface = AllTrailsColors.SurfaceSage,
onSurface = AllTrailsColors.TextPrimary,
surfaceVariant = AllTrailsColors.GreenSoft,
outline = AllTrailsColors.Divider,
error = AllTrailsColors.Hard,
)
@Composable
fun AllTrailsTheme(content: @Composable () -> Unit) =
MaterialTheme(colorScheme = AllTrailsScheme, typography = AllTrailsTypography, content = content)
2. Typography
Inter is open-source (SIL OFL). Drop the TTFs in res/font/ (lowercase, snake_case) and build a FontFamily. Enable tabular figures on stats and ratings.
// ui/theme/AllTrailsType.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), // 400
Font(R.font.inter_semibold, FontWeight.SemiBold), // 600
Font(R.font.inter_bold, FontWeight.Bold), // 700
)
private const val TNUM = "tnum" // tabular figures
// Named ramp — mirrors DESIGN.md §3 exactly (pt → sp 1:1)
object AllTrailsText {
val ScreenTitle = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 28.sp, letterSpacing = (-0.4).sp)
val DetailTitle = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 24.sp, letterSpacing = (-0.3).sp)
val Section = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 20.sp, letterSpacing = (-0.2).sp)
val CardTitle = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 17.sp, letterSpacing = (-0.1).sp)
val Body = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 25.sp)
val StatValue = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 15.sp, fontFeatureSettings = TNUM)
val Subtitle = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 20.sp)
val DifficultyT = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 13.sp, letterSpacing = 0.3.sp)
val StatLabel = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 13.sp)
val Button = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 16.sp, letterSpacing = 0.1.sp)
val Rating = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 14.sp, fontFeatureSettings = TNUM)
val Meta = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 13.sp)
val Tab = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 11.sp, letterSpacing = 0.2.sp)
val PinLabel = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 12.sp)
val LabelUpper = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 12.sp, letterSpacing = 0.8.sp)
}
// Map onto Material 3 slots so stock components inherit the brand
val AllTrailsTypography = Typography(
headlineLarge = AllTrailsText.ScreenTitle,
headlineSmall = AllTrailsText.Section,
titleMedium = AllTrailsText.CardTitle,
bodyLarge = AllTrailsText.Body,
labelSmall = AllTrailsText.Tab,
)
3. Signature Components
Difficulty Pill
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.padding
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.Color
import androidx.compose.ui.unit.dp
@Composable
fun DifficultyPill(difficulty: Difficulty, solid: Boolean = false, modifier: Modifier = Modifier) {
Box(
modifier
.clip(RoundedCornerShape(11.dp))
.background(if (solid) difficulty.color else difficulty.bg)
.padding(horizontal = 12.dp, vertical = 6.dp),
) {
Text(
difficulty.label,
style = AllTrailsText.DifficultyT,
color = if (solid) Color.White else difficulty.color,
)
}
}
Trail Card (signature)
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.outlined.FavoriteBorder
import androidx.compose.material3.Icon
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.draw.scale
import androidx.compose.ui.layout.ContentScale
@Composable
fun TrailCard(
name: String, location: String, difficulty: Difficulty,
rating: Double, reviews: Int, length: String, time: String, elevation: String,
photoUrl: String, saved: Boolean, onToggleSave: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.98f else 1f, label = "cardScale")
Column(
modifier
.scale(scale)
.clickable(interaction, indication = null, onClick = onClick),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Box {
AsyncImage(
model = photoUrl, contentDescription = null, contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxWidth().aspectRatio(4f / 3f)
.clip(RoundedCornerShape(12.dp)),
)
Box(
Modifier.align(Alignment.TopEnd).padding(12.dp)
.size(34.dp).clip(CircleShape).background(Color.Black.copy(alpha = 0.25f))
.clickable(onClick = onToggleSave),
contentAlignment = Alignment.Center,
) {
Icon(
if (saved) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder,
contentDescription = if (saved) "Saved" else "Save",
tint = if (saved) AllTrailsColors.Green else Color.White,
modifier = Modifier.size(18.dp),
)
}
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically) {
DifficultyPill(difficulty)
Row(verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Icon(Icons.Filled.Star, null, tint = AllTrailsColors.StarGold,
modifier = Modifier.size(12.dp))
Text("%.1f".format(rating), style = AllTrailsText.Rating,
color = AllTrailsColors.TextPrimary)
Text("($reviews)", style = AllTrailsText.Meta,
color = AllTrailsColors.TextSecondary)
}
}
Text(name, style = AllTrailsText.CardTitle, color = AllTrailsColors.TextPrimary, maxLines = 1)
Text(location, style = AllTrailsText.Subtitle, color = AllTrailsColors.TextSecondary)
Row(verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Stat("Length", length); Dot(); Stat("Est.", time); Dot(); Stat("↑", elevation)
}
}
}
@Composable private fun Dot() =
Text("·", color = AllTrailsColors.TextTertiary)
@Composable private fun Stat(label: String, value: String) {
Row(verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Text(label, style = AllTrailsText.StatLabel, color = AllTrailsColors.TextSecondary)
Text(value, style = AllTrailsText.StatValue, color = AllTrailsColors.TextPrimary)
}
}
Record Button (signature)
import androidx.compose.animation.core.*
import androidx.compose.material.icons.filled.FiberManualRecord
import androidx.compose.material.icons.filled.Stop
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
@Composable
fun RecordButton(recording: Boolean, onToggle: () -> Unit, modifier: Modifier = Modifier) {
val haptics = LocalHapticFeedback.current
val pulse by rememberInfiniteTransition(label = "rec").animateFloat(
1f, 1.06f, infiniteRepeatable(tween(800), RepeatMode.Reverse), label = "pulse",
)
val tint = if (recording) AllTrailsColors.Hard else AllTrailsColors.Green
Box(
modifier
.size(60.dp)
.scale(if (recording) pulse else 1f)
.shadow(18.dp, CircleShape, spotColor = tint.copy(alpha = 0.30f))
.clip(CircleShape)
.background(tint)
.clickable(indication = null,
interactionSource = remember { MutableInteractionSource() }) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress) // ~iOS medium impact
onToggle()
},
contentAlignment = Alignment.Center,
) {
Icon(
if (recording) Icons.Filled.Stop else Icons.Filled.FiberManualRecord,
contentDescription = if (recording) "Stop recording" else "Record",
tint = Color.White, modifier = Modifier.size(26.dp),
)
}
}
Route Trace (signature)
import androidx.compose.foundation.Canvas
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathMeasure
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.Stroke
@Composable
fun RouteTrace(points: List<Offset>, modifier: Modifier = Modifier) {
val progress = remember { Animatable(0f) }
LaunchedEffect(Unit) {
progress.animateTo(1f, tween(1000, easing = EaseOutCubic))
}
Canvas(modifier.fillMaxSize()) {
if (points.size < 2) return@Canvas
val full = Path().apply {
moveTo(points.first().x, points.first().y)
points.drop(1).forEach { lineTo(it.x, it.y) }
}
val measure = PathMeasure().apply { setPath(full, false) }
val drawn = Path()
measure.getSegment(0f, measure.length * progress.value, drawn, true)
drawPath(
drawn, AllTrailsColors.Green,
style = Stroke(width = 4.dp.toPx(), cap = StrokeCap.Round, join = StrokeJoin.Round),
)
}
}
Primary / Outline Buttons
@Composable
fun ATPrimaryButton(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(200, easing = EaseOut), label = "btnScale")
val haptics = LocalHapticFeedback.current
Box(
modifier
.scale(scale)
.clip(RoundedCornerShape(25.dp))
.background(if (pressed) AllTrailsColors.GreenPressed else AllTrailsColors.Green)
.clickable(interaction, indication = null) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
onClick()
}
.padding(horizontal = 28.dp, vertical = 14.dp),
contentAlignment = Alignment.Center,
) {
Text(title, style = AllTrailsText.Button, color = Color.White)
}
}
@Composable
fun ATOutlineButton(title: String, onClick: () -> Unit, modifier: Modifier = Modifier) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
Box(
modifier
.clip(RoundedCornerShape(25.dp))
.background(if (pressed) AllTrailsColors.GreenSoft else Color.Transparent)
.border(1.5.dp, AllTrailsColors.Green, RoundedCornerShape(25.dp))
.clickable(interaction, indication = null, onClick = onClick)
.padding(horizontal = 24.dp, vertical = 13.dp),
contentAlignment = Alignment.Center,
) {
Text(title, style = AllTrailsText.Button, color = AllTrailsColors.Green)
}
}
4. Bottom Navigation (Tab Bar)
Material 3 NavigationBar. AllTrails’ iOS tab bar is a near-opaque white .regularMaterial; Android has no first-class live blur, so use a 96%-opaque white surface with a sage hairline. Active tint is AllTrails Green.
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
@Composable
fun AllTrailsBottomBar(selected: Int, onSelect: (Int) -> Unit) {
NavigationBar(
containerColor = Color.White.copy(alpha = 0.96f),
tonalElevation = 0.dp,
) {
val items = listOf(
"Explore" to Icons.Filled.Map,
"Saved" to Icons.Filled.Bookmark,
"Navigator" to Icons.Filled.NearMe,
"Community" to Icons.Filled.Groups,
"Profile" to Icons.Filled.AccountCircle,
)
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 = AllTrailsText.Tab) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = AllTrailsColors.Green,
selectedTextColor = AllTrailsColors.Green,
unselectedIconColor = AllTrailsColors.TextTertiary,
unselectedTextColor = AllTrailsColors.TextTertiary,
indicatorColor = AllTrailsColors.GreenSoft,
),
)
}
}
}
5. Motion
| Moment | Compose recipe |
|---|---|
| Route trace draw | Animatable 0 → 1 tween(1000, EaseOutCubic); PathMeasure.getSegment reveals the polyline |
| Map pin select | animateFloatAsState pin 1 → 1.2 + white ring; mini card slideInVertically over 300ms |
| Card tap | animateFloatAsState 1 → 0.98 tween(200, EaseOut), HapticFeedbackType.LongPress |
| Map/List toggle | animateFloatAsState active-fill offset over tween(250) |
| Record start | color swap green → red, infinite pulse scale, firm haptic, banner expandVertically |
| Save heart | Animatable keyframes 1 → 1.15 → 1 over 300ms, HapticFeedbackType.LongPress |
| Live tracking | append GPS points to the path; the Canvas recomposes to extend the polyline; a Hard dot pulses |
Haptics: prefer LocalHapticFeedback. For richer control use Vibrator VibrationEffect.createOneShot(12, ...) to approximate iOS’s medium impact on Record start/stop.
6. Icons
The closest first-party set is androidx.compose.material:material-icons-extended.
| Purpose | SF Symbol (iOS) | Material Icon (Compose) |
|---|---|---|
| Record | record.circle |
Icons.Filled.FiberManualRecord |
| Stop recording | stop.fill |
Icons.Filled.Stop |
| Save | heart / heart.fill |
Icons.Outlined.FavoriteBorder / Icons.Filled.Favorite |
| Star (rating) | star.fill |
Icons.Filled.Star |
| Directions | arrow.triangle.turn.up.right.diamond.fill |
Icons.Filled.Directions |
| Share | square.and.arrow.up |
Icons.Filled.IosShare |
| Filter | slider.horizontal.3 |
Icons.Filled.Tune |
| Search | magnifyingglass |
Icons.Filled.Search |
| Elevation | arrow.up.forward |
Icons.Filled.TrendingUp |
| Explore (tab) | map.fill |
Icons.Filled.Map |
| Saved (tab) | bookmark.fill |
Icons.Filled.Bookmark |
| Navigator (tab) | location.north.circle.fill |
Icons.Filled.NearMe |
| Community (tab) | person.2.fill |
Icons.Filled.Groups |
| Profile (tab) | person.crop.circle.fill |
Icons.Filled.AccountCircle |
7. Minimum SDK & Accessibility Notes
- minSdk 24 (Compose floor is 21;
Canvas/PathMeasureand modern motion are comfortable at 24).compileSdk 34+, Compose BOM2024.09+, Kotlin2.0+. Use the Maps Compose library for the Explore base map. - Edge-to-edge: call
enableEdgeToEdge(); the white topo-paper canvas wants dark-content system bars. ApplyModifier.windowInsetsPadding(WindowInsets.systemBars)so the tab bar and Record FAB clear the gesture nav; let the map extend edge-to-edge under floating controls. - Tabular figures: set
fontFeatureSettings = "tnum"on stat/rating styles (already inAllTrailsText) so a scrolling trail list keeps its columns aligned; confirm the bundled Inter shipstnum. - Font scaling:
sphonors the user’s font scale — keep it on titles, trail names, body; let the stat row wrap at large sizes; pin the difficulty pill, tab labels, and map-pin labels by wrapping inCompositionLocalProvider(LocalDensity provides Density(density, fontScale = 1f)). - Color is information, not the only signal: the difficulty pill always carries the word (EASY/MODERATE/HARD); a legend decodes the map-pin tints. Never communicate difficulty by hue alone — required for color-blind safety.
- TalkBack: make the trail card one node with
Modifier.semantics(mergeDescendants = true)and a fullcontentDescription(“Eagle Peak Trail, moderate, 4.7 stars, 1,284 reviews, 5.2 miles, 1,200 feet gain”); mark the save toggle as a separate button. - Reduce Motion: gate the route-trace draw and pin-scale on
Settings.Global.ANIMATOR_DURATION_SCALE— render the full route immediately when animations are off. - Outdoor legibility: support the system “high contrast text” setting (bump
#6B6F68toward#54584F); do not auto-dim — the bright high-contrast theme is intentional for in-sun reading. - Touch targets: Material guidance is 48.dp minimum — the 60.dp Record button and 50.dp CTAs clear it; give the 18-20.dp save/share glyphs a
Modifier.size(48.dp)hit area via padding; enlarge map-pin tap targets to ~44.dp even though the glyph is smaller. - Contrast:
#6B6F68on white passes AA at 14sp+; the Hard difficulty#B3261Eon#F7E0DEand Moderate#C77700on#FBEEDDpass AA at 13sp bold — keep the pill bold. - Dynamic color: do not enable Material You
dynamicLightColorScheme()— AllTrails’ identity requires the fixed topo-paper canvas, brand green, and the exact difficulty scale regardless of wallpaper.