Plenty of Fish (iOS) — Jetpack Compose Implementation Guide
Companion to DESIGN.md — the framework-neutral spec. This file ports Plenty of Fish’s visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, paste-ready @Composables, the match grid + Meet Me card, the tab bar, 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 (POF’s dominant blue, the photo-forward 2-column match grid, Meet Me’s yes/maybe/no card, aggressive teal presence, messages-first) while making everything idiomatic Android — a
NavigationBarwith a MaterialBadge, aLazyVerticalGrid,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 — POF’s palette is fixed. A full dark scheme is provided (cool near-black, never true black).
1. Color Tokens
// ui/theme/POFColors.kt
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Brush
object POFColors {
// Canvas & Surfaces (Light)
val White = Color(0xFFFFFFFF)
val Background = Color(0xFFF4F5F7)
val PressedLight = Color(0xFFE9EBEF)
val DividerLight = Color(0xFFE4E6EA)
// Canvas & Surfaces (Dark)
val CanvasDark = Color(0xFF121417) // cool near-black — NOT pure black
val Surface1 = Color(0xFF1A1D21)
val Surface2 = Color(0xFF23272C)
val Divider = Color(0xFF2C3036)
// Text
val TextPrimary = Color(0xFFEDEFF2) // dark
val TextPrimaryLt = Color(0xFF16181C) // light
val TextSecondary = Color(0xFF9BA1AB)
val TextTertiary = Color(0xFF6B7079)
val OnPhoto = Color(0xFFFFFFFF)
// Brand
val Blue = Color(0xFF0098DB)
val BlueLight = Color(0xFF00A6E2)
val BluePressed = Color(0xFF0079B0)
// Accent / status
val Teal = Color(0xFF00C9B7) // online
val Pink = Color(0xFFFF4F8B) // unread
val Gold = Color(0xFFFFB23E) // upgrade
// Semantic
val Success = Color(0xFF20C997)
val Error = Color(0xFFFF5267)
// The POF brand gradient — logo, primary CTA, the Meet Me "yes" button
val BrandGradient = Brush.linearGradient(listOf(Blue, BlueLight))
// Bottom-of-card scrim for white name/meta over photos
val Scrim = Brush.verticalGradient(listOf(Color.Transparent, Color.Black.copy(alpha = 0.78f)))
}
Wire it into both schemes; the dark scheme uses cool near-black #121417, never 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
import androidx.compose.runtime.Composable
private val POFLight = lightColorScheme(
primary = POFColors.Blue,
onPrimary = POFColors.White,
background = POFColors.Background,
onBackground = POFColors.TextPrimaryLt,
surface = POFColors.White,
onSurface = POFColors.TextPrimaryLt,
surfaceVariant = POFColors.PressedLight,
outline = POFColors.DividerLight,
error = POFColors.Error,
)
private val POFDark = darkColorScheme(
primary = POFColors.Blue,
onPrimary = POFColors.White,
background = POFColors.CanvasDark,
onBackground = POFColors.TextPrimary,
surface = POFColors.Surface1,
onSurface = POFColors.TextPrimary,
surfaceVariant = POFColors.Surface2,
outline = POFColors.Divider,
error = POFColors.Error,
)
@Composable
fun POFTheme(
dark: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) = MaterialTheme(
colorScheme = if (dark) POFDark else POFLight,
typography = POFTypography,
content = content,
)
2. Typography
POF’s UI sans stand-in: Nunito Sans (SIL OFL) — drop the TTFs in res/font/. Heavy rounded weights (800/900) for names and titles; approachable, unintimidating.
// ui/theme/POFType.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 Nunito = FontFamily(
Font(R.font.nunitosans_regular, FontWeight.Normal),
Font(R.font.nunitosans_semibold, FontWeight.SemiBold),
Font(R.font.nunitosans_bold, FontWeight.Bold),
Font(R.font.nunitosans_extrabold, FontWeight.ExtraBold),
Font(R.font.nunitosans_black, FontWeight.Black),
)
// Named ramp — mirrors DESIGN.md §3 exactly (pt → sp 1:1)
object POFText {
val Display = TextStyle(Nunito, fontWeight = FontWeight.Black, fontSize = 32.sp, lineHeight = 37.sp, letterSpacing = (-0.6).sp)
val Title = TextStyle(Nunito, fontWeight = FontWeight.ExtraBold, fontSize = 26.sp, lineHeight = 31.sp, letterSpacing = (-0.3).sp)
val Section = TextStyle(Nunito, fontWeight = FontWeight.ExtraBold, fontSize = 22.sp, lineHeight = 28.sp, letterSpacing = (-0.2).sp)
val Profile = TextStyle(Nunito, fontWeight = FontWeight.ExtraBold, fontSize = 18.sp, lineHeight = 23.sp, letterSpacing = (-0.1).sp)
val Body = TextStyle(Nunito, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 24.sp)
val CardName = TextStyle(Nunito, fontWeight = FontWeight.Bold, fontSize = 15.sp, lineHeight = 19.sp)
val Button = TextStyle(Nunito, fontWeight = FontWeight.ExtraBold, fontSize = 16.sp, lineHeight = 16.sp)
val Meta = TextStyle(Nunito, fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 20.sp)
val CardMeta = TextStyle(Nunito, fontWeight = FontWeight.SemiBold, fontSize = 12.sp, lineHeight = 16.sp)
val Chip = TextStyle(Nunito, fontWeight = FontWeight.Bold, fontSize = 12.sp, lineHeight = 16.sp, letterSpacing = 0.1.sp)
val Tab = TextStyle(Nunito, fontWeight = FontWeight.ExtraBold, fontSize = 10.sp, lineHeight = 12.sp, letterSpacing = 0.1.sp)
val Badge = TextStyle(Nunito, fontWeight = FontWeight.ExtraBold, fontSize = 9.sp, lineHeight = 11.sp, letterSpacing = 0.2.sp)
val Bubble = TextStyle(Nunito, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, lineHeight = 21.sp)
}
// Map onto Material 3 slots so stock components inherit the brand
val POFTypography = Typography(
headlineLarge = POFText.Display,
headlineMedium = POFText.Title,
titleLarge = POFText.Section,
titleMedium = POFText.Profile,
bodyMedium = POFText.Body,
labelSmall = POFText.Tab,
)
3. Signature Components
Profile Grid Card
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite
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.layout.ContentScale
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
@Composable
fun ProfileGridCard(
photoUrl: String, name: String, age: Int, meta: String,
isOnline: Boolean, onOpen: () -> Unit, onLike: () -> Unit,
) {
Box(
Modifier
.fillMaxWidth()
.aspectRatio(3f / 4f)
.clip(RoundedCornerShape(16.dp))
.clickable(onClick = onOpen),
) {
AsyncImage(photoUrl, null, Modifier.matchParentSize(), contentScale = ContentScale.Crop)
if (isOnline) {
Row(
Modifier
.align(Alignment.TopStart)
.padding(10.dp)
.clip(RoundedCornerShape(999.dp))
.background(Color.Black.copy(alpha = 0.42f))
.padding(horizontal = 9.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(5.dp),
) {
Box(Modifier.size(7.dp).clip(CircleShape).background(POFColors.Teal))
Text("Online", style = POFText.Chip, color = POFColors.OnPhoto)
}
}
Box(Modifier.fillMaxWidth().height(90.dp).align(Alignment.BottomCenter).background(POFColors.Scrim))
Column(Modifier.align(Alignment.BottomStart).padding(12.dp)) {
Text("$name, $age", style = POFText.CardName, color = POFColors.OnPhoto)
Text(meta, style = POFText.CardMeta, color = POFColors.OnPhoto.copy(alpha = 0.85f))
}
Box(
Modifier
.align(Alignment.BottomEnd)
.padding(12.dp)
.size(36.dp)
.clip(CircleShape)
.background(POFColors.Blue)
.clickable(onClick = onLike),
contentAlignment = Alignment.Center,
) { Icon(Icons.Filled.Favorite, "Like $name", tint = POFColors.OnPhoto, modifier = Modifier.size(18.dp)) }
}
}
Match Grid
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
@Composable
fun MatchGrid(profiles: List<ProfileUi>) {
LazyVerticalGrid(
columns = GridCells.Fixed(2),
contentPadding = PaddingValues(14.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxSize().background(POFColors.CanvasDark),
) {
items(profiles, key = { it.id }) { p ->
ProfileGridCard(p.photoUrl, p.name, p.age, p.meta, p.isOnline, p.onOpen, p.onLike)
}
}
}
Meet Me Card
import androidx.compose.foundation.border
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.QuestionMark
import androidx.compose.ui.graphics.vector.ImageVector
@Composable
fun MeetMeCard(
photoUrl: String, name: String, age: Int,
onNo: () -> Unit, onMaybe: () -> Unit, onYes: () -> Unit,
) {
Column(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(20.dp))
.background(POFColors.Surface1)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
AsyncImage(
photoUrl, null,
Modifier.fillMaxWidth().aspectRatio(1f).clip(RoundedCornerShape(16.dp)),
contentScale = ContentScale.Crop,
)
Text("$name, $age", style = POFText.Profile, color = POFColors.TextPrimary)
Text("Would you like to meet $name?",
style = POFText.Meta.copy(fontWeight = FontWeight.Bold), color = POFColors.TextSecondary)
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
RoundBtn(Icons.Filled.Close, POFColors.Divider, POFColors.TextSecondary, onNo)
RoundBtn(Icons.Filled.QuestionMark, POFColors.Gold, POFColors.Gold, onMaybe)
Box(
Modifier.size(56.dp).clip(CircleShape).background(POFColors.Blue).clickable(onClick = onYes),
contentAlignment = Alignment.Center,
) { Icon(Icons.Filled.Favorite, "Yes, like $name", tint = POFColors.OnPhoto, modifier = Modifier.size(24.dp)) }
}
}
}
@Composable
private fun RoundBtn(icon: ImageVector, borderColor: Color, tint: Color, onClick: () -> Unit) {
Box(
Modifier
.size(56.dp)
.clip(CircleShape)
.background(POFColors.Surface1)
.border(1.5.dp, borderColor, CircleShape)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center,
) { Icon(icon, null, tint = tint, modifier = Modifier.size(22.dp)) }
}
Buttons + “Online” Pill
import androidx.compose.animation.core.*
import androidx.compose.material.icons.filled.WorkspacePremium
@Composable
fun PrimaryButton(title: String, onClick: () -> Unit) {
Box(
Modifier
.clip(RoundedCornerShape(999.dp))
.background(POFColors.BrandGradient)
.clickable(onClick = onClick)
.padding(horizontal = 28.dp, vertical = 14.dp),
contentAlignment = Alignment.Center,
) { Text(title, style = POFText.Button, color = POFColors.White) }
}
@Composable
fun UpgradeButton(title: String, onClick: () -> Unit) {
Row(
Modifier
.clip(RoundedCornerShape(999.dp))
.background(POFColors.Gold)
.clickable(onClick = onClick)
.padding(horizontal = 18.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Icon(Icons.Filled.WorkspacePremium, null, tint = Color(0xFF2A1B05), modifier = Modifier.size(12.dp))
Text(title, style = POFText.Button.copy(fontSize = 14.sp), color = Color(0xFF2A1B05))
}
}
@Composable
fun OnlinePill() {
val t = rememberInfiniteTransition(label = "pulse")
val alpha by t.animateFloat(1f, 0.5f, infiniteRepeatable(tween(2000), RepeatMode.Reverse), label = "dot")
Row(
Modifier
.clip(RoundedCornerShape(999.dp))
.background(Color.Black.copy(alpha = 0.42f))
.padding(horizontal = 9.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(5.dp),
) {
Box(Modifier.size(7.dp).clip(CircleShape).background(POFColors.Teal.copy(alpha = alpha)))
Text("Online", style = POFText.Chip, color = POFColors.OnPhoto)
}
}
4. Navigation
5 slots; active is POF Blue (no Material indicator pill — POF has none). The Messages tab uses a Material Badge in pink.
import androidx.compose.material3.*
import androidx.compose.material.icons.filled.*
@Composable
fun POFBottomBar(selected: Int, unread: Int, onSelect: (Int) -> Unit) {
NavigationBar(containerColor = POFColors.CanvasDark, tonalElevation = 0.dp) {
val items = listOf(
"Matches" to Icons.Filled.GridView,
"Meet Me" to Icons.Filled.Favorite,
"Search" to Icons.Filled.Search,
"Messages" to Icons.Filled.Chat,
"Me" to Icons.Filled.AccountCircle,
)
items.forEachIndexed { i, (label, icon) ->
NavigationBarItem(
selected = selected == i,
onClick = { onSelect(i) },
icon = {
if (i == 3 && unread > 0) {
BadgedBox(badge = { Badge(containerColor = POFColors.Pink) { Text("$unread", style = POFText.Badge, color = POFColors.White) } }) {
Icon(icon, label, modifier = Modifier.size(23.dp))
}
} else {
Icon(icon, label, modifier = Modifier.size(23.dp))
}
},
label = { Text(label, style = POFText.Tab) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = POFColors.Blue,
selectedTextColor = POFColors.Blue,
unselectedIconColor = POFColors.TextSecondary,
unselectedTextColor = POFColors.TextSecondary,
indicatorColor = Color.Transparent, // no Material pill — POF has none
),
)
}
}
}
The top bar holds the POF logo + wordmark and filter/search glyphs in Blue, with a segmented strip (Matches / Meet Me / Search) — active item ExtraBold primary with a 3.dp POF-Blue underline. The profile detail is a full-bleed HorizontalPager photo carousel with dots, an overlaid name+age, scrollable bio/interest chips, and a sticky bottom bar with a gradient “Send Message” pill.
5. Motion
POF motion is friendly — 180–320ms, soft and confident.
| Moment | Compose recipe |
|---|---|
| Like (grid card) | heart animateFloatAsState scale 1 → 1.25 → 1 via keyframes (~220ms); light haptic |
| Meet Me decision | card offsetX animateDpAsState to ±screen tween(260) (yes/no); “maybe” offsetY up tween(240) |
| Meet Me drag | Modifier.draggable; rotationZ ≈ dragX / 18; commit > 35% width else spring(dampingRatio = 0.8f) back |
| Online dot pulse | rememberInfiniteTransition alpha 1 ↔ 0.5 tween(2000) Reverse |
| Tab active pop | selected icon animateFloatAsState scale → 1.05 tween(120) |
| Profile open | shared-element AnimatedContent zoom into the carousel tween(320) |
| Message send | bubble scaleIn(tween(180)) + fadeIn from the composer |
| Skeleton shimmer | rememberInfiniteTransition sweep over 1200ms LinearEasing |
// Like heart pop — the canonical POF micro-interaction
val scale by animateFloatAsState(
targetValue = if (liked) 1f else 1f,
animationSpec = keyframes { durationMillis = 220; 1.25f at 110; 1f at 220 },
label = "heartPop",
)
Modifier.graphicsLayer { scaleX = scale; scaleY = scale }
Haptics: prefer LocalHapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) for the light tick on like, Meet Me decision and tab switch. Use HapticFeedbackConstants.CONFIRM on a new match. Presence is live but quiet — the dot pulse has no haptic.
6. Icons
POF ships custom glyphs; the closest first-party set is androidx.compose.material:material-icons-extended. Blue = brand/action; teal = online; pink = unread; gold = upgrade.
| Purpose | SF Symbol (iOS) | Material Icon (Compose) |
|---|---|---|
| Matches (tab) | square.grid.2x2 |
Icons.Filled.GridView |
| Meet Me (tab) | heart |
Icons.Filled.Favorite |
| Search (tab) | magnifyingglass |
Icons.Filled.Search |
| Messages (tab) | bubble.left.and.bubble.right |
Icons.Filled.Chat |
| Me (tab) | person.crop.circle |
Icons.Filled.AccountCircle |
| Like (card / yes) | heart.fill |
Icons.Filled.Favorite |
| Meet Me — no | xmark |
Icons.Filled.Close |
| Meet Me — maybe | questionmark |
Icons.Filled.QuestionMark |
| Filter | slider.horizontal.3 |
Icons.Filled.Tune |
| Upgrade / premium | crown.fill |
Icons.Filled.WorkspacePremium |
| Send message | arrow.up.circle.fill |
Icons.Filled.Send |
| Photo (composer) | photo |
Icons.Filled.Image |
| Block / report | exclamationmark.shield |
Icons.Filled.Flag |
| Back | chevron.left |
Icons.Filled.ArrowBack |
| More | ellipsis |
Icons.Filled.MoreHoriz |
| Distance | location.fill |
Icons.Filled.LocationOn |
| Verified | checkmark.seal.fill |
Icons.Filled.Verified |
| Online dot | (teal circle) | (teal Box + CircleShape) |
7. Minimum SDK & Accessibility Notes
- minSdk 24 (Compose floor is 21; modern motion +
BadgedBoxcomfortable at 24).compileSdk 34+, Compose BOM2024.09+, Kotlin2.0+. - Edge-to-edge: call
enableEdgeToEdge(); the cool near-black canvas wants light-content system bars (dark-content on the light theme). The message composer must sit above the IME — useModifier.imePadding(). - Font scaling:
sphonors the user’s font scale — keep it on display, titles, body, meta. Pin layout-sensitive text (card-scrim name/meta, 10sp tab labels, 9sp badge, 12sp status chips) viadp-derived sizes or a fixed-fontScaleLocalDensityso they don’t break the photo composition. - TalkBack: merge each grid card into one node —
Modifier.semantics(mergeDescendants = true) { contentDescription = "Profile, $name, $age, $meta${if (isOnline) ", Online" else ""}" }; expose the like button as a separateonClickLabel(“Like $name”); Meet Me buttons get clear labels (“No”, “Maybe”, “Yes, like $name”); the unreadBadgeneeds astateDescription(“$unread unread messages”). - Touch targets: Material guidance is 48.dp. The grid-card like button is 36.dp — give it a 48.dp hit via padding; Meet Me buttons are 56.dp; the whole card is clickable to open the profile.
- Contrast: white name/meta on the
rgba(0,0,0,0.78)scrim passes WCAG AA;#16181Con#FFFFFFand#EDEFF2on#121417pass AA; verify POF Blue keeps ≥4.5:1 behind white button text (it does at bold 16sp — AA-large). - Reduce motion: when
Settings.Global.ANIMATOR_DURATION_SCALE == 0, disable the heart pop, the online-dot pulse, and Meet Me card-fly — substitute a crossfade; keep selection state changes. - Dark mode: invert via the
Dark*palette — cool near-black#121417, NOT true black. Never desaturate profile photos — only chrome dims. Sheets add a faint 1dpDividertop border since shadows nearly vanish on dark. Do not enable Material YoudynamicColorScheme()— POF Blue is the brand and must hold regardless of wallpaper.