happn (iOS) — Jetpack Compose Implementation Guide
Companion to DESIGN.md — the framework-neutral spec. This file ports happn’s visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, paste-ready @Composables, the Crossings timeline + Charm gesture, 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 (happn’s Crossings timeline on a connector spine, the pink Charm heart, the pink→magenta gradient, reserved Crush gold, location+time stamps) while making everything idiomatic Android —
NavigationBarinstead of a UITabBar,LazyColumnfor the timeline, Maps Compose markers,dp/spinstead ofpt.
Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, Coil 2.6+ for avatars, and maps-compose4.x for the Map view. No Material You — happn’s pink is a fixed brand color and must not be overridden by wallpaper extraction.
1. Color Tokens
// ui/theme/HappnColors.kt
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
object HappnColors {
// Canvas & Surfaces (Dark)
val Canvas = Color(0xFF0E0E12) // cool graphite — NOT pure black
val Surface1 = Color(0xFF18181F)
val Surface2 = Color(0xFF21212B)
val Surface3 = Color(0xFF2C2C38)
val Divider = Color(0xFF2A2A33) // also the timeline spine
// Canvas & Surfaces (Light)
val CanvasLight = Color(0xFFFFFFFF)
val Surface1Light = Color(0xFFF6F6F8)
val Surface2Light = Color(0xFFEDEDF1)
val DividerLight = Color(0xFFE6E6EC)
// Brand
val Pink = Color(0xFFFF4865)
val PinkPress = Color(0xFFE5354F)
val Magenta = Color(0xFFE91E63)
val Rose = Color(0xFFFF7B93)
val Gold = Color(0xFFFFC24B) // Crush / premium ONLY
// Text
val TextPrimary = Color(0xFFF4F4F6)
val TextSecondary = Color(0xFFA0A0AE)
val TextTertiary = Color(0xFF6C6C7A)
val TextPrimaryLight = Color(0xFF15151B)
val OnPink = Color(0xFFFFFFFF)
val OnGold = Color(0xFF1A1A1A)
// Semantic
val Success = Color(0xFF4ED9A4)
val Error = Color(0xFFFF5C5C)
// Hero gradient — primary "Say hi" / Crush star / splash
val HeroGradient = Brush.linearGradient(listOf(Pink, Magenta))
}
happn ships both themes; the brand is most recognizable on dark. The brand pink, magenta, gradient, and Crush gold are identical across schemes.
// 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 HappnDark = darkColorScheme(
primary = HappnColors.Pink,
onPrimary = HappnColors.OnPink,
secondary = HappnColors.Magenta,
background = HappnColors.Canvas,
onBackground = HappnColors.TextPrimary,
surface = HappnColors.Surface1,
onSurface = HappnColors.TextPrimary,
surfaceVariant = HappnColors.Surface2,
outline = HappnColors.Divider,
error = HappnColors.Error,
)
private val HappnLight = lightColorScheme(
primary = HappnColors.Pink,
onPrimary = HappnColors.OnPink,
secondary = HappnColors.Magenta,
background = HappnColors.CanvasLight,
onBackground = HappnColors.TextPrimaryLight,
surface = HappnColors.Surface1Light,
onSurface = HappnColors.TextPrimaryLight,
surfaceVariant = HappnColors.Surface2Light,
outline = HappnColors.DividerLight,
error = HappnColors.Error,
)
@Composable
fun HappnTheme(
dark: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) = MaterialTheme(
colorScheme = if (dark) HappnDark else HappnLight,
typography = HappnTypography,
content = content,
)
2. Typography
happn pairs Poppins (warmth: wordmark, names, headings, buttons, crossing count) with Inter (legibility: body, location, chat). Both SIL OFL — drop the TTFs in res/font/. Weights 400–700.
// ui/theme/HappnType.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 Poppins = FontFamily(
Font(R.font.poppins_semibold, FontWeight.SemiBold),
Font(R.font.poppins_bold, FontWeight.Bold),
)
val Inter = FontFamily(
Font(R.font.inter_regular, FontWeight.Normal),
Font(R.font.inter_medium, FontWeight.Medium),
)
object HappnText {
val Display = TextStyle(Poppins, fontWeight = FontWeight.Bold, fontSize = 32.sp, lineHeight = 37.sp, letterSpacing = (-0.5).sp)
val Name = TextStyle(Poppins, fontWeight = FontWeight.Bold, fontSize = 26.sp, lineHeight = 30.sp, letterSpacing = (-0.4).sp)
val Section = TextStyle(Poppins, fontWeight = FontWeight.SemiBold, fontSize = 22.sp, lineHeight = 26.sp, letterSpacing = (-0.3).sp)
val Subsection = TextStyle(Poppins, fontWeight = FontWeight.SemiBold, fontSize = 18.sp, lineHeight = 24.sp)
val CardName = TextStyle(Poppins, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, lineHeight = 20.sp)
val Button = TextStyle(Poppins, fontWeight = FontWeight.SemiBold, fontSize = 16.sp, lineHeight = 16.sp)
val Count = TextStyle(Poppins, fontWeight = FontWeight.SemiBold, fontSize = 12.sp, lineHeight = 16.sp)
val Tab = TextStyle(Poppins, fontWeight = FontWeight.SemiBold, fontSize = 10.sp, lineHeight = 10.sp, letterSpacing = 0.2.sp)
val Body = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 24.sp)
val BodyMeta = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 20.sp)
val Location = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 12.sp, lineHeight = 16.sp)
val Caption = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 12.sp, lineHeight = 16.sp)
}
val HappnTypography = Typography(
headlineLarge = HappnText.Display,
headlineMedium = HappnText.Name,
titleLarge = HappnText.Section,
bodyMedium = HappnText.Body,
labelSmall = HappnText.Tab,
)
3. Signature Components
Crossings Timeline
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
data class Crossing(
val id: String,
val avatarUrl: String,
val name: String, // "Camille, 27"
val place: String, // "Le Marais"
val timeAgo: String, // "11 min ago"
val crossCount: Int,
val charmed: Boolean,
)
@Composable
fun CrossingsTimeline(items: List<Crossing>, onCharm: (String) -> Unit) {
LazyColumn(
modifier = Modifier.fillMaxSize().background(HappnColors.Canvas),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
items(items, key = { it.id }) { c -> CrossingRow(c) { onCharm(c.id) } }
}
}
@Composable
fun CrossingRow(c: Crossing, onCharm: () -> Unit) {
Row(Modifier.fillMaxWidth()) {
// Connector spine + dot
Box(Modifier.width(18.dp).fillMaxHeight(), contentAlignment = Alignment.TopCenter) {
Box(
Modifier
.width(2.dp).fillMaxHeight().padding(top = 6.dp)
.background(HappnColors.Divider)
)
Box(
Modifier
.padding(top = 6.dp).size(12.dp)
.clip(CircleShape).background(HappnColors.Pink)
.border(4.dp, HappnColors.Pink.copy(alpha = 0.18f), CircleShape)
)
}
// Card
Row(
Modifier
.weight(1f).padding(start = 8.dp)
.clip(RoundedCornerShape(18.dp))
.background(HappnColors.Surface1)
.border(1.dp, HappnColors.Divider, RoundedCornerShape(18.dp))
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
AsyncImage(
model = c.avatarUrl, contentDescription = null,
modifier = Modifier.size(56.dp).clip(RoundedCornerShape(16.dp)),
)
Column(Modifier.weight(1f)) {
Text(c.name, style = HappnText.CardName, color = HappnColors.TextPrimary,
maxLines = 1, overflow = TextOverflow.Ellipsis)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) {
Icon(Icons.Filled.LocationOn, null, tint = HappnColors.TextTertiary, modifier = Modifier.size(11.dp))
Text("${c.place} · ${c.timeAgo}", style = HappnText.Location, color = HappnColors.TextSecondary,
maxLines = 1, overflow = TextOverflow.Ellipsis)
}
Spacer(Modifier.height(5.dp))
Text(
if (c.crossCount == 1) "You crossed paths once" else "You crossed paths ${c.crossCount} times",
style = HappnText.Count, color = HappnColors.Pink,
)
}
CharmButton(charmed = c.charmed, onCharm = onCharm, size = 40.dp)
}
}
}
Charm Button (heart)
import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.clickable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.outlined.FavoriteBorder
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.draw.scale
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.Dp
import kotlinx.coroutines.launch
@Composable
fun CharmButton(charmed: Boolean, onCharm: () -> Unit, size: Dp = 40.dp) {
val scope = rememberCoroutineScope()
val scale = remember { Animatable(1f) }
val haptics = LocalHapticFeedback.current
Box(
Modifier
.size(size)
.then(if (charmed) Modifier.shadow(8.dp, CircleShape, spotColor = HappnColors.Pink.copy(alpha = 0.5f)) else Modifier)
.scale(scale.value)
.clip(CircleShape)
.background(if (charmed) HappnColors.Pink else HappnColors.Surface2)
.then(if (charmed) Modifier else Modifier.border(1.dp, HappnColors.Divider, CircleShape))
.clickable {
scope.launch {
scale.animateTo(1.25f, spring(dampingRatio = 0.45f))
scale.animateTo(1f, spring(dampingRatio = 0.7f))
}
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
onCharm()
},
contentAlignment = Alignment.Center,
) {
Icon(
if (charmed) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder,
contentDescription = if (charmed) "Charm sent" else "Send a Charm",
tint = if (charmed) HappnColors.OnPink else HappnColors.TextSecondary,
modifier = Modifier.size(size * 0.45f),
)
}
}
Crush Celebration
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.tween
@Composable
fun CrushCelebration(
leftAvatar: Brush, rightAvatar: Brush, onStartChat: () -> Unit,
) {
var together by remember { mutableStateOf(false) }
val offset by animateDpAsState(if (together) 28.dp else 160.dp, tween(360), label = "crush")
val haptics = LocalHapticFeedback.current
LaunchedEffect(Unit) {
together = true
haptics.performHapticFeedback(HapticFeedbackType.LongPress) // success analog
}
Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.92f)), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(28.dp)) {
Row {
Box(Modifier.offset(x = -offset).size(96.dp).clip(CircleShape).background(leftAvatar))
Box(Modifier.offset(x = offset).size(96.dp).clip(CircleShape).background(rightAvatar))
}
Text("It's a Crush!", style = HappnText.Display, color = HappnColors.TextPrimary)
// Gold sparkle layer here — the ONLY place HappnColors.Gold appears (besides premium)
Box(
Modifier
.clip(RoundedCornerShape(999.dp))
.background(HappnColors.HeroGradient)
.clickable(onClick = onStartChat)
.padding(horizontal = 30.dp, vertical = 15.dp),
) { Text("Start chatting", style = HappnText.Button, color = HappnColors.OnPink) }
}
}
}
Map Crossing Pin (teardrop)
import androidx.compose.ui.draw.rotate
@Composable
fun CrossingPin(kind: PinKind) {
Box(
Modifier
.size(44.dp)
.rotate(45f)
.clip(RoundedCornerShape(topStart = 22.dp, topEnd = 22.dp, bottomEnd = 22.dp, bottomStart = 6.dp))
.then(
when (kind) {
PinKind.Standard -> Modifier.background(HappnColors.Pink)
PinKind.Mutual -> Modifier.background(HappnColors.HeroGradient)
PinKind.Ghost -> Modifier.background(HappnColors.Surface2).border(1.dp, HappnColors.Divider, RoundedCornerShape(topStart = 22.dp, topEnd = 22.dp, bottomEnd = 22.dp, bottomStart = 6.dp))
}
),
contentAlignment = Alignment.Center,
) {
Box(
Modifier
.size(30.dp).rotate(-45f).clip(CircleShape)
.background(if (kind == PinKind.Ghost) HappnColors.TextTertiary else Color.White)
)
}
}
enum class PinKind { Standard, Mutual, Ghost }
Use as a maps-compose MarkerComposable content; keep the +45° body / -45° inner counter-rotation so the dot stays upright.
4. Navigation
happn has a transparent top bar and a 4-tab bottom strip with no tint pill — active is the pink icon + pink label.
@Composable
fun HappnBottomBar(selected: Int, onSelect: (Int) -> Unit) {
NavigationBar(
containerColor = Color(0xFF0E0E12).copy(alpha = 0.94f),
tonalElevation = 0.dp,
) {
val items = listOf(
Icons.Filled.Favorite to "Timeline",
Icons.Filled.Map to "Map",
Icons.Filled.ChatBubble to "Chats",
Icons.Filled.Person to "Profile",
)
items.forEachIndexed { i, (icon, label) ->
NavigationBarItem(
selected = selected == i,
onClick = { onSelect(i) },
icon = { Icon(icon, label, modifier = Modifier.size(23.dp)) },
label = { Text(label, style = HappnText.Tab) },
alwaysShowLabel = true,
colors = NavigationBarItemDefaults.colors(
selectedIconColor = HappnColors.Pink, // pink icon + pink label
selectedTextColor = HappnColors.Pink,
unselectedIconColor = HappnColors.TextTertiary,
unselectedTextColor = HappnColors.TextTertiary,
indicatorColor = Color.Transparent, // NO Material pill — happn has none
),
)
}
}
}
The top bar is a transparent Row: leading wordmark “happn” (Poppins 22sp Bold, trailing “n” in HappnColors.Pink), trailing search icon (17dp in a 34dp Surface2 circle).
5. Motion
happn motion is warm and emotional — the Charm bounce and the gold Crush takeover are the signatures.
| Moment | Compose recipe |
|---|---|
| Charm tap | scale.animateTo(1.25f, spring(0.45f)) then animateTo(1f, spring(0.7f)) + long-press haptic; ghost → filled |
| Crush reveal | avatars animateDpAsState from edge offset to center tween(360); gold sparkle layer; success haptic |
| Timeline new crossing | LazyColumn item animateItemPlacement() + AnimatedVisibility(slideInVertically + fadeIn, tween(240)) |
| Dot pulse (fresh) | halo Animatable scale 1 → 1.4 → 1 once, tween(300) each leg |
| Card press | Modifier.scale 0.98 via interactionSource, tween(80) |
| Tab switch | icon tint cross-dissolves to pink tween(180); no pill slide |
| Map pin tap | pin offset y -6dp + spring, then profile ModalBottomSheet up |
| Sheet present | ModalBottomSheet slide-up (Material default) + scrim |
// Charm bounce — the canonical happn motion
scope.launch {
scale.animateTo(1.25f, spring(dampingRatio = 0.45f))
scale.animateTo(1f, spring(dampingRatio = 0.70f))
}
Haptics: LocalHapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) for Charm sent and the Crush moment; a lighter HapticFeedbackConstants.CLOCK_TICK (via LocalView) for tab switch and map-pin tap.
6. Icons
happn iconography is rounded/simple; androidx.compose.material:material-icons-extended covers it.
| Purpose | SF Symbol (iOS) | Material Icon (Compose) |
|---|---|---|
| Timeline (tab) | heart.text.square |
Icons.Filled.Favorite |
| Map (tab) | map |
Icons.Filled.Map |
| Chats (tab) | bubble.left.and.bubble.right |
Icons.Filled.ChatBubble |
| Profile (tab) | person |
Icons.Filled.Person |
| Charm (heart) | heart / heart.fill |
Icons.Outlined.FavoriteBorder / Icons.Filled.Favorite |
| Crush (star) | star.fill |
Icons.Filled.Star |
| Location stamp | mappin.and.ellipse |
Icons.Filled.LocationOn |
| Search | magnifyingglass |
Icons.Filled.Search |
| Back | chevron.left |
Icons.Filled.ArrowBack |
| Send message | arrow.up.circle.fill |
Icons.Filled.ArrowCircleUp |
| FlashNote | text.bubble |
Icons.Filled.ChatBubbleOutline |
| Block / report | exclamationmark.shield |
Icons.Filled.Shield |
| Settings | gearshape |
Icons.Filled.Settings |
7. Minimum SDK & Accessibility Notes
- minSdk 24 (Compose floor is 21;
LazyColumnanimateItemPlacement,maps-composemarkers comfortable at 24).compileSdk 34+, Compose BOM2024.09+, Kotlin2.0+. - Edge-to-edge: call
enableEdgeToEdge(); the graphite canvas wants light system-bar icons on dark, dark on light. The wordmark + first card sit below the camera cutout; the last card scrolls clear of the nav inset. - Both themes: drive scheme by
isSystemInDarkTheme(); the brand is most recognizable on dark. KeepPink,Magenta,HeroGradient, andGoldidentical across schemes — only canvas/surface/text swap. Do not enabledynamicColorScheme()— happn pink is a fixed brand color and must hold regardless of wallpaper. - Font scaling:
sphonors the user’s scale — keep it on display, name, section, body. Pin the crossing count, 10sp tab labels, and location/time meta via a fixedDensity(fontScale = 1f)CompositionLocalProviderso the timeline row doesn’t reflow and misalign the connector dot. - Card name:
maxLines = 1+overflow = Ellipsisso a long name never pushes the card to 2 lines and breaks dot alignment. - TalkBack: give each
CrossingRowModifier.semantics { contentDescription = "Crossed paths with ${c.name}, near ${c.place}, ${c.timeAgo}, ${c.crossCount} times" }; label the Charm button “Send a Charm to {name}” / “Charm sent”; the Crush screen announces “It’s a Crush with {name}”. The crossing count must be exposed to screen readers — it is meaning, not decoration. - Touch targets: Material guidance is 48dp — give the 40dp Charm a 48dp hit via padding; the 44dp map pin a 48dp hit; top-bar search a 48dp hit; the full crossing card is tappable (→ profile).
- Contrast:
#F4F4F6on#0E0E12is strong;#FFFFFFon#FF4865passes AA for the Charm; the pink crossing count (#FF4865on#18181F) passes for 12sp semibold — keep the weight. - Reduce motion: when
Settings.Global.ANIMATOR_DURATION_SCALE == 0, disable the dot pulse, the Charm 1.25 overshoot (use a fillCrossfade), and the Crush avatar slide; keep the haptic. - Gold discipline:
HappnColors.Goldis reserved for the Crush celebration and premium upsell only — never use it as a status, accent, or TalkBack highlight color elsewhere.