Feeld (iOS) — Jetpack Compose Implementation Guide
Companion to DESIGN.md — the framework-neutral spec. This file ports Feeld’s visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, paste-ready @Composables, the swipe deck + Desire chips, 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 (Feeld’s true-black canvas, the single rationed acid accent, couple/group-first cards, the Desire chip system, “Connections”) while making everything idiomatic Android —
NavigationBarinstead of a UITabBar,swipeable/drag for the deck,dp/spinstead ofpt.
Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ for profile photos and avatars. No color extraction / Material You — Feeld’s identity is a fixed black + acid; dynamic wallpaper color must never override it. Feeld is dark-only — there is no light scheme.
1. Color Tokens
// ui/theme/FeeldColors.kt
import androidx.compose.ui.graphics.Color
object FeeldColors {
// Canvas & Surfaces (dark-native, only mode)
val Canvas = Color(0xFF000000) // true black — Feeld is dark-native
val Surface1 = Color(0xFF121212)
val Surface2 = Color(0xFF1C1C1E)
val Surface3 = Color(0xFF262626)
val Divider = Color(0xFF2A2A2A)
// Brand
val Acid = Color(0xFFE8FF63) // rationed to ONE primary action / screen
val AcidPress = Color(0xFFD4EB4F)
val Pink = Color(0xFFFF5C8A)
val PinkDeep = Color(0xFFE0447A)
val Lilac = Color(0xFFC9B8FF)
// Text
val TextPrimary = Color(0xFFFFFFFF)
val TextSecondary = Color(0xFFA8A8A8)
val TextTertiary = Color(0xFF6E6E6E)
val OnAcid = Color(0xFF000000)
val OnPink = Color(0xFFFFFFFF)
// Semantic
val Success = Color(0xFF66E0A3)
val Error = Color(0xFFFF6B6B)
}
Feeld is dark-only; the “light” scheme below exists solely so Material components don’t crash if the system forces light — it still renders the dark identity.
// ui/theme/Theme.kt
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
private val FeeldScheme = darkColorScheme(
primary = FeeldColors.Acid,
onPrimary = FeeldColors.OnAcid,
secondary = FeeldColors.Pink,
onSecondary = FeeldColors.OnPink,
background = FeeldColors.Canvas,
onBackground = FeeldColors.TextPrimary,
surface = FeeldColors.Surface1,
onSurface = FeeldColors.TextPrimary,
surfaceVariant = FeeldColors.Surface2,
outline = FeeldColors.Divider,
error = FeeldColors.Error,
)
@Composable
fun FeeldTheme(content: @Composable () -> Unit) =
MaterialTheme( // always dark — never isSystemInDarkTheme()
colorScheme = FeeldScheme,
typography = FeeldTypography,
content = content,
)
2. Typography
Feeld pairs Space Grotesk (personality: display, names, sections, buttons) with Inter (legibility: body, metadata, snippets). Both SIL OFL — drop the TTFs in res/font/. Weights 400–700 only.
// ui/theme/FeeldType.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 SpaceGrotesk = FontFamily(
Font(R.font.space_grotesk_semibold, FontWeight.SemiBold),
Font(R.font.space_grotesk_bold, FontWeight.Bold),
)
val Inter = FontFamily(
Font(R.font.inter_regular, FontWeight.Normal),
Font(R.font.inter_semibold, FontWeight.SemiBold),
)
object FeeldText {
val Display = TextStyle(SpaceGrotesk, fontWeight = FontWeight.Bold, fontSize = 32.sp, lineHeight = 37.sp, letterSpacing = (-0.5).sp)
val Name = TextStyle(SpaceGrotesk, fontWeight = FontWeight.Bold, fontSize = 26.sp, lineHeight = 30.sp, letterSpacing = (-0.4).sp)
val Section = TextStyle(SpaceGrotesk, fontWeight = FontWeight.SemiBold, fontSize = 22.sp, lineHeight = 26.sp, letterSpacing = (-0.3).sp)
val Subsection = TextStyle(SpaceGrotesk, fontWeight = FontWeight.SemiBold, fontSize = 18.sp, lineHeight = 24.sp)
val ListTitle = TextStyle(SpaceGrotesk, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, lineHeight = 20.sp)
val Button = TextStyle(SpaceGrotesk, fontWeight = FontWeight.SemiBold, fontSize = 16.sp, lineHeight = 16.sp)
val Body = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 24.sp)
val Meta = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 20.sp)
val Snippet = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 13.sp, lineHeight = 18.sp)
val Chip = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 12.sp, lineHeight = 12.sp, letterSpacing = 0.1.sp)
val Caption = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 12.sp, lineHeight = 16.sp)
val Tab = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 10.sp, lineHeight = 10.sp, letterSpacing = 0.2.sp)
}
val FeeldTypography = Typography(
headlineLarge = FeeldText.Display,
headlineMedium = FeeldText.Name,
titleLarge = FeeldText.Section,
bodyMedium = FeeldText.Body,
labelSmall = FeeldText.Tab,
)
3. Signature Components
Discover Card (Couple/Group-aware)
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.Check
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.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
@Composable
fun DiscoverCard(
photoUrl: String,
names: List<String>, // ["Robin", "Sky"]
ages: List<Int>,
distanceKm: Int,
kind: String, // "Couple" / "Single" / "Group"
desires: List<DesireChipModel>,
verified: Boolean,
modifier: Modifier = Modifier,
) {
Box(
modifier
.fillMaxSize()
.padding(horizontal = 16.dp)
.clip(RoundedCornerShape(28.dp)), // signature 28dp card
) {
AsyncImage(
model = photoUrl, contentDescription = null,
modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop,
)
Box(
Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth().fillMaxHeight(0.56f)
.background(Brush.verticalGradient(
0f to Color.Transparent,
0.6f to Color.Black.copy(alpha = 0.72f),
1f to Color.Black.copy(alpha = 0.92f),
)),
)
if (verified) {
Row(
Modifier
.align(Alignment.TopEnd).padding(18.dp)
.clip(RoundedCornerShape(999.dp))
.background(Color.Black.copy(alpha = 0.5f)) // Android: no live blur — solid scrim
.padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(5.dp),
) {
Icon(Icons.Filled.Check, null, tint = FeeldColors.Acid, modifier = Modifier.size(12.dp))
Text("Verified", style = FeeldText.Caption.copy(fontWeight = FontWeight.SemiBold), color = FeeldColors.TextPrimary)
}
}
Column(
Modifier
.align(Alignment.BottomStart)
.padding(horizontal = 22.dp).padding(bottom = 18.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
names.joinToString(" & "),
style = FeeldText.Name, color = Color.White,
maxLines = 2, overflow = TextOverflow.Ellipsis, // never truncate 2nd name
)
Text(
"$kind · ${ages.joinToString(" & ")} · $distanceKm km away",
style = FeeldText.Meta, color = Color.White.copy(alpha = 0.78f),
)
DesireChipRow(desires, modifier = Modifier.padding(top = 8.dp))
}
}
}
Action Row (Pass · Like · Wink)
import androidx.compose.animation.core.Animatable
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.ui.draw.scale
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import kotlinx.coroutines.launch
@Composable
fun ActionRow(onPass: () -> Unit, onLike: () -> Unit, onWink: () -> Unit) {
val scope = rememberCoroutineScope()
val likeScale = remember { Animatable(1f) }
val haptics = LocalHapticFeedback.current
Row(
Modifier.fillMaxWidth().padding(vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(22.dp, Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically,
) {
CircleAction(52.dp, FeeldColors.Surface2, true) {
Icon(Icons.Filled.Close, "Pass", tint = FeeldColors.TextSecondary, modifier = Modifier.size(22.dp))
}.also { /* attach onPass via clickable below */ }
Box(
Modifier
.size(64.dp).scale(likeScale.value)
.shadow(15.dp, CircleShape, spotColor = FeeldColors.Acid.copy(alpha = 0.5f))
.clip(CircleShape).background(FeeldColors.Acid)
.clickable {
scope.launch { likeScale.snapTo(0.92f); likeScale.animateTo(1f, tween(180)) }
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
onLike()
},
contentAlignment = Alignment.Center,
) { Icon(Icons.Filled.Favorite, "Like", tint = FeeldColors.OnAcid, modifier = Modifier.size(28.dp)) }
Box(
Modifier.size(52.dp).clip(CircleShape).background(FeeldColors.Surface2)
.border(1.dp, FeeldColors.Divider, CircleShape).clickable(onClick = onWink),
contentAlignment = Alignment.Center,
) { Icon(Icons.Filled.Star, "Send a Wink", tint = FeeldColors.Lilac, modifier = Modifier.size(22.dp)) }
}
}
@Composable
private fun CircleAction(size: Dp, bg: Color, bordered: Boolean, content: @Composable () -> Unit): Unit =
Box(
Modifier.size(size).clip(CircleShape).background(bg)
.then(if (bordered) Modifier.border(1.dp, FeeldColors.Divider, CircleShape) else Modifier),
contentAlignment = Alignment.Center,
) { content() }
Desire Chip + Row
data class DesireChipModel(val label: String, val state: DesireState)
enum class DesireState { Selected, Unselected, Emotional }
@Composable
fun DesireChip(model: DesireChipModel) {
val (bg, fg, border) = when (model.state) {
DesireState.Selected -> Triple(FeeldColors.Acid, FeeldColors.OnAcid, Color.Transparent)
DesireState.Unselected -> Triple(FeeldColors.Surface2, FeeldColors.TextPrimary, FeeldColors.Divider)
DesireState.Emotional -> Triple(FeeldColors.Pink.copy(alpha = 0.16f), FeeldColors.Pink, FeeldColors.Pink.copy(alpha = 0.4f))
}
Box(
Modifier
.clip(RoundedCornerShape(999.dp))
.background(bg)
.border(1.dp, border, RoundedCornerShape(999.dp))
.padding(horizontal = 15.dp, vertical = 9.dp),
) { Text(model.label, style = FeeldText.Chip, color = fg) }
}
@Composable
fun DesireChipRow(desires: List<DesireChipModel>, modifier: Modifier = Modifier) {
FlowRow(modifier, horizontalArrangement = Arrangement.spacedBy(7.dp), verticalArrangement = Arrangement.spacedBy(7.dp)) {
desires.forEach { DesireChip(it) } // wraps, never truncates
}
}
Connections Row
@Composable
fun ConnectionRow(
avatar: Brush, name: String, snippet: String, time: String, unread: Boolean,
) {
Row(
Modifier
.fillMaxWidth()
.padding(vertical = 12.dp)
.drawBehind {
drawLine(FeeldColors.Divider, Offset(0f, size.height), Offset(size.width, size.height), 1f)
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
Box(Modifier.size(48.dp).clip(CircleShape).background(avatar))
Column(Modifier.weight(1f)) {
Text(name, style = FeeldText.ListTitle, color = FeeldColors.TextPrimary)
Text(snippet, style = FeeldText.Snippet, color = FeeldColors.TextSecondary, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(time, style = FeeldText.Caption, color = FeeldColors.TextTertiary)
if (unread) Box(Modifier.size(6.dp).clip(CircleShape).background(FeeldColors.Acid))
}
}
}
4. Navigation
Feeld has minimal chrome: a transparent top bar and a 4-tab bottom strip with no tint pill — active is the acid icon + a small dot.
@Composable
fun FeeldBottomBar(selected: Int, onSelect: (Int) -> Unit) {
NavigationBar(
containerColor = Color.Black.copy(alpha = 0.92f),
tonalElevation = 0.dp,
) {
val items = listOf(
Icons.Filled.Search to "Discover",
Icons.Filled.FavoriteBorder to "Likes",
Icons.Filled.ChatBubbleOutline to "Connections",
Icons.Filled.PersonOutline to "Profile",
)
items.forEachIndexed { i, (icon, label) ->
NavigationBarItem(
selected = selected == i,
onClick = { onSelect(i) },
icon = { Icon(icon, label, modifier = Modifier.size(23.dp)) },
label = null, // icons-only is valid Feeld
alwaysShowLabel = false,
colors = NavigationBarItemDefaults.colors(
selectedIconColor = FeeldColors.Acid, // acid icon
unselectedIconColor = FeeldColors.TextTertiary,
indicatorColor = Color.Transparent, // NO Material pill — Feeld has none
),
)
}
}
}
A 5dp acid dot under the active icon (draw a small Box in the item, or overlay) is the only active-state signal beyond the acid tint. The top bar is a transparent Row: leading filters icon (17dp in a 34dp Surface2 circle), center title in FeeldText.Section, trailing settings icon.
5. Motion
Feeld motion is confident and physical — card swipe is the hero gesture; the acid Like alone pulses.
| Moment | Compose recipe |
|---|---|
| Card swipe (like/pass) | Modifier.pointerInput drag → offsetX Animatable; rotationZ = offsetX / cardWidth * 8; release > 35% width → animateTo(±800, spring(0.8)), else animateTo(0) |
| Like tap | likeScale snapTo(0.92f) then animateTo(1f, tween(180)) + long-press haptic |
| Wink bounce | winkScale.animateTo(1.25f, tween(130)) then animateTo(1f, tween(130)) |
| Connection made | full-screen overlay; two avatars animateOffset from edges to center tween(320) + acid particle burst |
| Chip toggle | instant color swap + animateFloatAsState scale 0.96 → 1.0 tween(120); selection haptic |
| Sheet present | ModalBottomSheet slide-up (Material default) + scrim |
| Tab switch | active acid dot animateDpAsState slides under new icon tween(200) |
// Card swipe — the canonical Feeld gesture
val offsetX = remember { Animatable(0f) }
Modifier.pointerInput(Unit) {
detectDragGestures(
onDragEnd = {
scope.launch {
if (kotlin.math.abs(offsetX.value) > cardWidthPx * 0.35f) {
offsetX.animateTo(if (offsetX.value > 0) 800f else -800f, spring(dampingRatio = 0.8f))
onSwiped(offsetX.value > 0)
} else offsetX.animateTo(0f, spring(dampingRatio = 0.8f))
}
},
onDrag = { _, drag -> scope.launch { offsetX.snapTo(offsetX.value + drag.x) } },
)
}.graphicsLayer { translationX = offsetX.value; rotationZ = offsetX.value / cardWidthPx * 8f }
Haptics: LocalHapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) for the soft impact on Like commit; a lighter HapticFeedbackConstants.CLOCK_TICK (via LocalView) for chip toggle and Wink; the OS confirm pattern for a new Connection.
6. Icons
Feeld iconography is simple line/solid; androidx.compose.material:material-icons-extended covers it.
| Purpose | SF Symbol (iOS) | Material Icon (Compose) |
|---|---|---|
| Discover (tab) | magnifyingglass |
Icons.Filled.Search |
| Likes (tab) | heart / heart.fill |
Icons.Filled.FavoriteBorder / Favorite |
| Connections (tab) | bubble.left.and.bubble.right |
Icons.Filled.ChatBubbleOutline |
| Profile (tab) | person / person.fill |
Icons.Filled.PersonOutline |
| Pass | xmark |
Icons.Filled.Close |
| Like | heart.fill |
Icons.Filled.Favorite |
| Wink | star.fill |
Icons.Filled.Star |
| Verified | checkmark |
Icons.Filled.Check |
| Filters | slider.horizontal.3 |
Icons.Filled.Tune |
| Settings | gearshape |
Icons.Filled.Settings |
| Back | chevron.left |
Icons.Filled.ArrowBack |
| Send message | arrow.up.circle.fill |
Icons.Filled.ArrowCircleUp |
| Block / report | exclamationmark.triangle |
Icons.Filled.ReportGmailerrorred |
| Add photo | plus |
Icons.Filled.Add |
7. Minimum SDK & Accessibility Notes
- minSdk 24 (Compose floor is 21; the drag deck +
FlowRoware comfortable at 24).compileSdk 34+, Compose BOM2024.09+, Kotlin2.0+.FlowRowis inandroidx.compose.foundation(stable from BOM 2024.06). - Edge-to-edge: call
enableEdgeToEdge(); the canvas is true black so system bars should be transparent with light icons. The Discover card sits below the camera cutout; the action row + bottom bar respect the nav inset. - Dark-only: never call
isSystemInDarkTheme()to pick a scheme and never enabledynamicColorScheme()— Feeld’s black + acid identity must hold regardless of system theme or wallpaper (there is no light Feeld and no brand accent to harmonize with Material You). - Couple/group names: set
maxLines = 2+overflow = Ellipsisand considerModifier.basicMarquee()only if a two-name couple still overflows — never silently drop the second name. - Font scaling:
sphonors the user’s font scale — keep it on display, name, section, body, snippet. Pin layout-sensitive text (Desire chips, 10sp tab labels, verified-pill text) via a fixedDensity(fontScale = 1f)CompositionLocalProviderso pills don’t reflow. - TalkBack: give the Discover card
Modifier.semantics { contentDescription = "Profile: $names, $kind, ${ages} years, $distanceKm km away, ${desires.size} desires" }; expose Like/Pass/Wink viacustomActionsso the card is operable without the swipe gesture; label the acid dot’s tab as selected. - Touch targets: Material guidance is 48dp — the 52/64dp action buttons and 48dp avatars clear it; give Desire chips ≥ 48dp hit height via padding even though the visible pill is shorter; top-bar icons get a 48dp hit area.
- Contrast:
#FFFFFFon#000000is maximal;#000000on#E8FF63passes AA for the acid Like; validate#FF5C8Aon#000000for small text (passes for large/semibold — bump weight for body). - Reduce motion: when
Settings.Global.ANIMATOR_DURATION_SCALE == 0, disable card rotation and the Connection avatar slide — substitute aCrossfade; keep the chip color swap (it conveys state). - The acid glow is decorative — the
Favoriteicon must carry the “this is Like” meaning for users who can’t perceive the shadow halo.