Coffee Meets Bagel (iOS) — Jetpack Compose Implementation Guide
Companion to DESIGN.md — the framework-neutral spec. This file ports Coffee Meets Bagel’s visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, paste-ready @Composables, the Today’s Bagel card + action trio + match takeover, 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 (CMB’s cozy coffee-house palette, the one-at-a-time curated card, the oversized Like, cream-instead-of-white) while making everything idiomatic Android —
NavigationBarinstead of a UITabBar,Brush.verticalGradientfor the photo scrim,sp/dpinstead ofpt.
Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ for profile photos. No color extraction — CMB’s palette is fixed warm tones.
1. Color Tokens
// ui/theme/CmbColors.kt
import androidx.compose.ui.graphics.Color
object CmbColors {
// Primary action (Bagel Orange)
val Bagel = Color(0xFFF4623A)
val BagelDeep = Color(0xFFD94E2A)
// Secondary brand (Brew Brown)
val Brew = Color(0xFFA0522D)
val BrewDeep = Color(0xFF7E3F22)
val BrewPressed = Color(0xFF693318)
// Cream (replaces white)
val Cream = Color(0xFFF3E4CF)
val CreamSoft = Color(0xFFE8D4B8)
// Canvas & Surfaces (Light)
val Canvas = Color(0xFFFBF6EF) // warm off-white — NOT pure white
val Surface1L = Color(0xFFFFFFFF)
val Surface2L = Color(0xFFF3ECE1)
val DividerL = Color(0xFFE8DECF)
// Canvas & Surfaces (Dark)
val DarkCanvas = Color(0xFF14100D) // roasted brown-black — NOT neutral grey
val DarkSurface1 = Color(0xFF1F1813)
val DarkSurface2 = Color(0xFF2A2017)
val DarkDivider = Color(0xFF33281D)
// Text
val TextPrimaryL = Color(0xFF3A2A18) // warm dark brown — NOT pure black
val TextSecondaryL = Color(0xFF8A7458)
val TextPrimaryD = Color(0xFFEFE6DA)
val TextSecondaryD = Color(0xFFB7A48E)
val TextTertiaryD = Color(0xFF7C6B57)
// Status
val MatchGreen = Color(0xFF4CC38A)
val PremiumGold = Color(0xFFE0A82E)
}
Wire it into both schemes. CMB is warm-first; the dark scheme uses the signature roasted brown-black #14100D, never neutral grey or true black.
// ui/theme/Theme.kt
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.MaterialTheme
private val CmbLight = lightColorScheme(
primary = CmbColors.Bagel,
onPrimary = Color.White,
secondary = CmbColors.Brew,
onSecondary = CmbColors.Cream,
background = CmbColors.Canvas,
onBackground = CmbColors.TextPrimaryL,
surface = CmbColors.Surface1L,
onSurface = CmbColors.TextPrimaryL,
surfaceVariant = CmbColors.Surface2L,
outline = CmbColors.DividerL,
error = CmbColors.BagelDeep,
)
private val CmbDark = darkColorScheme(
primary = CmbColors.Bagel,
onPrimary = Color.White,
secondary = CmbColors.Brew,
onSecondary = CmbColors.Cream,
background = CmbColors.DarkCanvas,
onBackground = CmbColors.TextPrimaryD,
surface = CmbColors.DarkSurface1,
onSurface = CmbColors.TextPrimaryD,
surfaceVariant = CmbColors.DarkSurface2,
outline = CmbColors.DarkDivider,
error = CmbColors.BagelDeep,
)
@Composable
fun CmbTheme(
dark: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) = MaterialTheme(
colorScheme = if (dark) CmbDark else CmbLight,
typography = CmbTypography,
content = content,
)
2. Typography
CMB’s brand face is Brandon Grotesque / Gotham-family (licensed). Use Poppins (SIL OFL — free) as the closest face: drop the TTFs in res/font/. People-first hierarchy; the rounded geometric warmth is the tone — never a cold grotesque.
// ui/theme/CmbType.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_regular, FontWeight.Normal),
Font(R.font.poppins_medium, FontWeight.Medium),
Font(R.font.poppins_semibold, FontWeight.SemiBold),
Font(R.font.poppins_bold, FontWeight.Bold),
Font(R.font.poppins_extrabold, FontWeight.ExtraBold),
)
// Named ramp — mirrors DESIGN.md §3 exactly (pt → sp 1:1)
object CmbText {
val ScreenTitle = TextStyle(Poppins, fontWeight = FontWeight.ExtraBold, fontSize = 32.sp, lineHeight = 37.sp, letterSpacing = (-0.6).sp)
val ProfileName = TextStyle(Poppins, fontWeight = FontWeight.Bold, fontSize = 26.sp, lineHeight = 31.sp, letterSpacing = (-0.3).sp)
val Section = TextStyle(Poppins, fontWeight = FontWeight.Bold, fontSize = 22.sp, lineHeight = 28.sp, letterSpacing = (-0.2).sp)
val CardTitle = TextStyle(Poppins, fontWeight = FontWeight.SemiBold, fontSize = 18.sp, lineHeight = 23.sp)
val Body = TextStyle(Poppins, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 24.sp)
val PromptAns = TextStyle(Poppins, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, lineHeight = 21.sp)
val Meta = TextStyle(Poppins, fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 19.sp)
val Chip = TextStyle(Poppins, fontWeight = FontWeight.SemiBold, fontSize = 13.sp, lineHeight = 16.sp)
val Badge = TextStyle(Poppins, fontWeight = FontWeight.Bold, fontSize = 12.sp, lineHeight = 12.sp, letterSpacing = 0.4.sp)
val Number = TextStyle(Poppins, fontWeight = FontWeight.Bold, fontSize = 16.sp, lineHeight = 16.sp)
val Tab = TextStyle(Poppins, fontWeight = FontWeight.SemiBold, fontSize = 11.sp, lineHeight = 11.sp, letterSpacing = 0.1.sp)
val Button = TextStyle(Poppins, fontWeight = FontWeight.Bold, fontSize = 16.sp, lineHeight = 16.sp)
}
val CmbTypography = Typography(
headlineLarge = CmbText.ScreenTitle,
headlineMedium = CmbText.ProfileName,
titleMedium = CmbText.CardTitle,
bodyMedium = CmbText.Body,
labelSmall = CmbText.Tab,
)
3. Signature Components
Today’s Bagel Card (the core atom)
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Schedule
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.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
@Composable
fun BagelCard(
imageUrl: String,
name: String, age: Int, meta: String, badge: String, interests: List<String>,
) {
Box(
Modifier
.fillMaxWidth()
.aspectRatio(3f / 4f)
.shadow(40.dp, RoundedCornerShape(24.dp), spotColor = Color.Black.copy(alpha = 0.7f))
.clip(RoundedCornerShape(24.dp)),
) {
AsyncImage(model = imageUrl, contentDescription = null, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
// Bottom scrim
Box(
Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.fillMaxHeight(0.55f)
.background(Brush.verticalGradient(listOf(Color.Transparent, Color(0xFF14100D).copy(alpha = 0.95f)))),
)
// Badge (top-left)
Row(
Modifier
.align(Alignment.TopStart)
.padding(16.dp)
.clip(RoundedCornerShape(999.dp))
.background(Color(0xFF14100D).copy(alpha = 0.55f))
.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Icon(Icons.Filled.Schedule, contentDescription = null, tint = CmbColors.Cream, modifier = Modifier.size(12.dp))
Text(badge.uppercase(), style = CmbText.Badge, color = CmbColors.Cream)
}
// Info block (bottom-left)
Column(
Modifier
.align(Alignment.BottomStart)
.padding(20.dp),
) {
Row {
Text(name, style = CmbText.ProfileName, color = Color.White)
Text(" $age", style = CmbText.ProfileName.copy(fontWeight = FontWeight.Medium), color = Color.White)
}
Text(meta, style = CmbText.Meta, color = CmbColors.Cream, modifier = Modifier.padding(top = 4.dp))
Row(Modifier.padding(top = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
interests.forEach { c ->
Box(
Modifier
.clip(RoundedCornerShape(999.dp))
.background(CmbColors.Cream.copy(alpha = 0.18f))
.padding(horizontal = 12.dp, vertical = 5.dp),
) { Text(c, style = CmbText.Chip, color = CmbColors.Cream) }
}
}
}
}
}
Action Trio (Pass / Like / Send a Bagel)
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.clickable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Star
import androidx.compose.runtime.*
import androidx.compose.ui.draw.scale
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
@Composable
fun ActionTrio(onPass: () -> Unit, onLike: () -> Unit, onSend: () -> Unit) {
val haptics = LocalHapticFeedback.current
var pop by remember { mutableStateOf(false) }
val heartScale by animateFloatAsState(if (pop) 1.15f else 1f, spring(Spring.DampingRatioMediumBouncy), label = "heart")
Row(
Modifier.fillMaxWidth().padding(vertical = 16.dp),
horizontalArrangement = Arrangement.spacedBy(28.dp, Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically,
) {
// Pass — 60dp
Box(
Modifier.size(60.dp).clip(CircleShape).background(CmbColors.DarkSurface2)
.clickable { haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove); onPass() },
contentAlignment = Alignment.Center,
) { Icon(Icons.Filled.Close, "Pass", tint = CmbColors.TextSecondaryD, modifier = Modifier.size(24.dp)) }
// Like — 72dp (largest)
Box(
Modifier.size(72.dp).clip(CircleShape).background(CmbColors.Bagel)
.clickable {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
pop = true; onLike()
},
contentAlignment = Alignment.Center,
) { Icon(Icons.Filled.Favorite, "Like", tint = Color.White, modifier = Modifier.size(32.dp).scale(heartScale)) }
// Send a Bagel — 60dp
Box(
Modifier.size(60.dp).clip(CircleShape).background(CmbColors.Brew)
.clickable { haptics.performHapticFeedback(HapticFeedbackType.LongPress); onSend() },
contentAlignment = Alignment.Center,
) { Icon(Icons.Filled.Star, "Send a Bagel", tint = CmbColors.Cream, modifier = Modifier.size(20.dp)) }
}
LaunchedEffect(pop) { if (pop) { kotlinx.coroutines.delay(140); pop = false } }
}
Curated Batch Header
@Composable
fun BatchHeader(title: String, countText: String, onBeans: () -> Unit) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 12.dp),
verticalAlignment = Alignment.Top,
) {
Column(Modifier.weight(1f)) {
Text(title, style = CmbText.CardTitle.copy(fontSize = 19.sp), color = CmbColors.TextPrimaryD)
Text(countText, style = CmbText.Badge.copy(fontWeight = FontWeight.SemiBold, letterSpacing = 0.sp), color = CmbColors.TextSecondaryD)
}
Box(
Modifier.size(38.dp).clip(CircleShape).background(CmbColors.DarkSurface2).clickable { onBeans() },
contentAlignment = Alignment.Center,
) { Icon(Icons.Filled.Spa, contentDescription = "Beans", tint = CmbColors.Cream, modifier = Modifier.size(18.dp)) }
}
}
Interest Chip
@Composable
fun InterestChip(label: String, selected: Boolean = false, onClick: () -> Unit = {}) {
Box(
Modifier
.clip(RoundedCornerShape(999.dp))
.background(if (selected) CmbColors.Brew else CmbColors.DarkSurface2)
.clickable { onClick() }
.padding(horizontal = 14.dp, vertical = 8.dp),
) {
Text(label, style = CmbText.Chip, color = if (selected) CmbColors.Cream else CmbColors.TextPrimaryD)
}
}
Match Takeover
import androidx.compose.material.icons.filled.CheckCircle
@Composable
fun MatchTakeover(onSayHi: () -> Unit, onKeep: () -> Unit) {
val haptics = LocalHapticFeedback.current
var appear by remember { mutableStateOf(false) }
val scale by animateFloatAsState(if (appear) 1f else 0.9f, tween(400), label = "match")
LaunchedEffect(Unit) { haptics.performHapticFeedback(HapticFeedbackType.LongPress); appear = true }
Box(
Modifier.fillMaxSize().background(Brush.verticalGradient(listOf(CmbColors.Bagel, CmbColors.Brew))),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(20.dp), modifier = Modifier.scale(scale)) {
Text("It's a match!", style = CmbText.ScreenTitle, color = Color.White)
Icon(Icons.Filled.CheckCircle, contentDescription = null, tint = CmbColors.MatchGreen, modifier = Modifier.size(44.dp))
Box(
Modifier.clip(RoundedCornerShape(999.dp)).background(CmbColors.Bagel).clickable { onSayHi() }
.padding(horizontal = 40.dp, vertical = 14.dp),
) { Text("Say hi", style = CmbText.Button, color = Color.White) }
Text("Keep browsing", style = CmbText.Chip, color = CmbColors.Cream, modifier = Modifier.clickable { onKeep() })
}
}
}
4. Navigation
CMB has a top bar (title + circular beans/filter button) and a 4-tab bottom strip (Suggested / Discover / Chats / Profile). On Android use a NavigationBar. There is no tint pill — active icon fills and the label goes Bagel Orange.
import androidx.compose.material3.*
import androidx.compose.material.icons.filled.*
@Composable
fun CmbBottomBar(selected: Int, unread: Int, onSelect: (Int) -> Unit) {
NavigationBar(containerColor = CmbColors.DarkCanvas, tonalElevation = 0.dp) {
val items = listOf(
"Suggested" to Icons.Filled.Lens, // stand-in for bagel logomark
"Discover" to Icons.Filled.Search,
"Chats" to Icons.Filled.ChatBubble,
"Profile" to Icons.Filled.Person,
)
items.forEachIndexed { i, (label, icon) ->
NavigationBarItem(
selected = selected == i,
onClick = { onSelect(i) },
icon = {
BadgedBox(badge = { if (i == 2 && unread > 0) Badge(containerColor = CmbColors.Bagel) }) {
Icon(icon, contentDescription = label, modifier = Modifier.size(22.dp))
}
},
label = { Text(label, style = CmbText.Tab) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = CmbColors.Bagel, // orange, no tint pill
selectedTextColor = CmbColors.Bagel,
unselectedIconColor = CmbColors.TextTertiaryD,
unselectedTextColor = CmbColors.TextTertiaryD,
indicatorColor = Color.Transparent, // CMB has no Material pill
),
)
}
}
}
The bagel card is shown one at a time (preload the next behind it for the cross-fade); the curated batch is finite, never an infinite LazyColumn. The profile detail is a separate scrollable screen reached via a shared-element transition on the hero photo.
5. Motion
CMB motion is warm and gentle — 180–400ms ease-out with soft haptics; shadows are brown-warm like café lamplight.
| Moment | Compose recipe |
|---|---|
| Like heart pop | animateFloatAsState 1.0 → 1.15 → 1.0 spring(DampingRatioMediumBouncy) + soft haptic |
| Just-liked card wash | orange overlay animateColorAsState alpha 0.12 → 0 over 500ms |
| Card advance (like) | liked card animateOffsetAsState up-right + rotate(12f) tween(320); next card Crossfade + scale 0.96 → 1.0 tween(260) |
| Pass | passed card slides down-left tween(280); light tick haptic |
| Match takeover | gradient scale(0.9 → 1.0) + fade tween(400); bagel-glyph confetti tween(1200); medium haptic |
| Chip select | background animateColorAsState Surface2 → Brew tween(180); scale 0.95 tap-down |
| Tab switch | Crossfade(tween(200)); active icon gentle 1.0→1.1→1.0 pop |
| Profile open | shared-element hero photo + AnimatedContent slide push tween(300) |
// Like heart pop — the canonical CMB motion
val heartScale by animateFloatAsState(
targetValue = if (pop) 1.15f else 1f,
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessMedium),
label = "heartPop",
)
Haptics: prefer LocalHapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) for the soft success on Like / Send / match takeover, and HapticFeedbackType.TextHandleMove (or HapticFeedbackConstants.CLOCK_TICK via LocalView) for the light tick on Pass and chip select.
6. Icons
CMB’s bagel logomark is a brand mark — render it as a bundled ImageVector / VectorDrawable (a ring) for fidelity. The closest first-party set is androidx.compose.material:material-icons-extended.
| Purpose | SF Symbol (iOS) | Material Icon (Compose) |
|---|---|---|
| Suggested (tab) | bagel logomark | bundled vector (stand-in Icons.Filled.Lens) |
| Discover (tab) | magnifyingglass |
Icons.Filled.Search |
| Chats (tab) | bubble.left.fill |
Icons.Filled.ChatBubble |
| Profile (tab) | person.fill |
Icons.Filled.Person |
| Pass | xmark |
Icons.Filled.Close |
| Like | heart.fill |
Icons.Filled.Favorite |
| Send a Bagel | star.fill |
Icons.Filled.Star |
| Beans / currency | leaf.fill |
Icons.Filled.Spa (stand-in for coffee bean) |
| Active badge | clock.fill |
Icons.Filled.Schedule |
| Match check | checkmark.circle.fill |
Icons.Filled.CheckCircle |
| Back | chevron.left |
Icons.Filled.ArrowBack |
| Send message | arrow.up |
Icons.Filled.ArrowUpward |
| Filter | slider.horizontal.3 |
Icons.Filled.Tune |
| Report / overflow | ellipsis |
Icons.Filled.MoreHoriz |
| Premium / Boost | bolt.fill |
Icons.Filled.Bolt |
7. Minimum SDK & Accessibility Notes
- minSdk 24 (Compose floor is 21; modern motion + shared-element comfortable at 24).
compileSdk 34+, Compose BOM2024.09+, Kotlin2.0+. - Edge-to-edge: call
enableEdgeToEdge(); the warm-charcoal dark canvas wants light-content system bars (dark-content on the warm light canvas). The action trio + tab bar neednavigationBarsPadding(); the chat composer needsimePadding(). - Font scaling:
sphonors the user’s font scale — keep it on profile name, section, body, prompt answer. Pin layout-sensitive text (chips, badges, bean numbers, tab labels) by deriving fromdporCompositionLocalProvider(LocalDensity provides Density(density, fontScale = 1f)). - Brand font: use Poppins (SIL OFL — free) as the closest face; do not redistribute Brandon Grotesque (CMB’s licensed brand grotesque). Never substitute a cold grotesque — the rounded warmth is the tone.
- TalkBack: the bagel card is one node (“Profile: {name}, {age}, {meta}, {interests}”); the action buttons read “Like {name}” / “Pass on {name}” / “Send a Bagel to {name}”; expose
customActions(Like, Pass) on the card so users need not reach the buttons. - Touch targets: Like is 72.dp, Pass/Send 60.dp (all exceed the 48.dp guidance); chips ≥ 32.dp tall, full-chip tappable; tab icons 22.dp glyph with 48.dp hit via the NavigationBar.
- Color is not the only signal: Like is also the largest control and uses a filled heart; Pass uses ×; Send uses ★ — shape distinguishes them for color-blind users.
- Contrast: white/cream over the photo scrim passes WCAG AA at rendered sizes;
#3A2A18on#FBF6EFand#EFE6DAon#14100Dpass AA for 16sp body; white on#F4623Apasses AA for button text. - Reduce motion: when
Settings.Global.ANIMATOR_DURATION_SCALE == 0, replace the card fly-out with aCrossfade, snap the heart (no pop), keep the orange like-flash (conveys state) shortened to 250ms; the match takeover fades instead of scaling. - Dark mode: invert via the
Dark*palette — roasted brown-black#14100D, NOT neutral grey or true black; text becomes warm cream#EFE6DA. Do not enable Material YoudynamicColorScheme()— CMB’s coffee-house identity must hold regardless of wallpaper.