Flickr (iOS) — Jetpack Compose Implementation Guide
Companion to DESIGN.md — the framework-neutral spec. This file ports Flickr’s visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, the justified mosaic (a custom Layout), the photo detail page, the EXIF table, and the favorite-star animation with haptics.
Why a Compose guide for an iOS-referenced app? The DESIGN.md tokens are platform-neutral. This file keeps the visual identity (Flickr’s near-black gallery canvas, justified mosaic, EXIF table, twin Pink/Blue dots, five-point favorite star) while making everything idiomatic Android —
NavigationBarinstead of a UITabBar, a customLayoutfor the row-packing mosaic,sp/dpinstead ofpt.
Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ for photos. Flickr is dark-first for gallery viewing; a full light scheme is provided. No Material You dynamic color — Flickr’s twin-dot brand must hold regardless of wallpaper.
1. Color Tokens
// ui/theme/FlickrColors.kt
import androidx.compose.ui.graphics.Color
object FlickrColors {
// Canvas & Surfaces (Dark — default for gallery viewing)
val Canvas = Color(0xFF0C0D0E) // near-black so photos dominate
val Surface1 = Color(0xFF1A1B1D)
val Surface2 = Color(0xFF232427)
val Divider = Color(0xFF2A2C30)
// Canvas & Surfaces (Light)
val CanvasLight = Color(0xFFFFFFFF)
val SurfaceLight = Color(0xFFF5F6F7)
val SurfacePressed = Color(0xFFEAECEF)
val DividerLight = Color(0xFFE2E4E8)
// Text
val TextPrimary = Color(0xFFFFFFFF)
val TextSecondary = Color(0xFFB0B3B8)
val TextTertiary = Color(0xFF6E7176)
val TextPrimaryLt = Color(0xFF1C1F23)
val TextSecondaryLt = Color(0xFF6B7077)
// Brand (twin dots)
val Pink = Color(0xFFFF0084)
val PinkPressed = Color(0xFFD60070)
val Blue = Color(0xFF0063DC)
val BluePressed = Color(0xFF0052B8)
// Accent & semantic
val ProGold = Color(0xFFFFB200)
val Success = Color(0xFF00C781)
val Error = Color(0xFFFF3B5C)
// Photo scrim
val Scrim = Color(0x73000000) // rgba(0,0,0,0.45)
}
Wire it into both schemes. Flickr is dark-first (gallery wall); the light scheme uses pure white.
// 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 FlickrDark = darkColorScheme(
primary = FlickrColors.Pink,
onPrimary = Color.White,
secondary = FlickrColors.Blue,
background = FlickrColors.Canvas,
onBackground = FlickrColors.TextPrimary,
surface = FlickrColors.Surface1,
onSurface = FlickrColors.TextPrimary,
surfaceVariant = FlickrColors.Surface2,
outline = FlickrColors.Divider,
error = FlickrColors.Error,
)
private val FlickrLight = lightColorScheme(
primary = FlickrColors.Pink,
onPrimary = Color.White,
secondary = FlickrColors.Blue,
background = FlickrColors.CanvasLight,
onBackground = FlickrColors.TextPrimaryLt,
surface = FlickrColors.SurfaceLight,
onSurface = FlickrColors.TextPrimaryLt,
surfaceVariant = FlickrColors.SurfacePressed,
outline = FlickrColors.DividerLight,
error = FlickrColors.Error,
)
@Composable
fun FlickrTheme(
dark: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) = MaterialTheme(
colorScheme = if (dark) FlickrDark else FlickrLight,
typography = FlickrTypography,
content = content,
)
2. Typography
Flickr pairs an editorial serif (Proza Libre, titles/headers) with a UI sans (Inter) and a monospace EXIF readout (IBM Plex Mono). All SIL OFL — drop the TTFs in res/font/. Serif for curation, sans for function; never set EXIF or tab labels in the serif.
// ui/theme/FlickrType.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 ProzaLibre = FontFamily(
Font(R.font.proza_libre_semibold, FontWeight.SemiBold),
Font(R.font.proza_libre_bold, FontWeight.Bold),
)
val Inter = FontFamily(
Font(R.font.inter_regular, FontWeight.Normal),
Font(R.font.inter_medium, FontWeight.Medium),
Font(R.font.inter_semibold, FontWeight.SemiBold),
Font(R.font.inter_bold, FontWeight.Bold),
)
val IBMPlexMono = FontFamily(
Font(R.font.ibm_plex_mono_semibold, FontWeight.SemiBold),
)
// Named ramp — mirrors DESIGN.md §3 exactly (pt -> sp 1:1)
object FlickrText {
val ScreenTitle = TextStyle(ProzaLibre, fontWeight = FontWeight.Bold, fontSize = 32.sp, lineHeight = 38.sp, letterSpacing = (-0.4).sp)
val SectionHead = TextStyle(ProzaLibre, fontWeight = FontWeight.Bold, fontSize = 26.sp, lineHeight = 33.sp, letterSpacing = (-0.3).sp)
val NavTitle = TextStyle(ProzaLibre, fontWeight = FontWeight.SemiBold, fontSize = 22.sp, lineHeight = 28.sp, letterSpacing = (-0.3).sp)
val PhotoTitle = TextStyle(ProzaLibre, fontWeight = FontWeight.SemiBold, fontSize = 18.sp, lineHeight = 24.sp, letterSpacing = (-0.2).sp)
val Body = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 24.sp)
val CardTitle = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, lineHeight = 20.sp)
val Button = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 15.sp, lineHeight = 15.sp)
val Meta = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 13.sp, lineHeight = 18.sp)
val FaveCount = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 11.sp, lineHeight = 11.sp)
val ExifKey = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 12.sp, lineHeight = 17.sp)
val TagChip = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 12.sp, lineHeight = 12.sp)
val Tab = TextStyle(Inter, fontWeight = FontWeight.Medium, fontSize = 10.sp, lineHeight = 10.sp, letterSpacing = 0.1.sp)
val Caption = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 12.sp, lineHeight = 16.sp)
val ExifValue = TextStyle(IBMPlexMono, fontWeight = FontWeight.SemiBold, fontSize = 11.sp, lineHeight = 16.sp, letterSpacing = 0.3.sp)
}
val FlickrTypography = Typography(
headlineLarge = FlickrText.ScreenTitle,
headlineMedium = FlickrText.SectionHead,
titleLarge = FlickrText.NavTitle,
titleMedium = FlickrText.PhotoTitle,
bodyMedium = FlickrText.Body,
labelSmall = FlickrText.Tab,
)
3. Signature Components
Justified Photo Mosaic (custom Layout)
The heritage row-packing layout. A custom Layout measures each photo by its aspect ratio and scales rows to fill width at a target height.
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
data class Photo(val id: String, val aspect: Float, val gradient: List<Color>, val faves: Int?)
private const val GUTTER = 3 // dp
private const val TARGET_ROW = 115 // dp
@Composable
fun JustifiedMosaic(photos: List<Photo>, modifier: Modifier = Modifier) {
BoxWithConstraints(modifier.fillMaxSize().background(FlickrColors.Canvas)) {
val widthDp = maxWidth.value
// Pack rows
val rows = remember(photos, widthDp) {
val out = mutableListOf<MutableList<Photo>>()
var cur = mutableListOf<Photo>(); var sum = 0f
photos.forEach { p ->
cur.add(p); sum += p.aspect
val h = (widthDp - GUTTER * (cur.size - 1)) / sum
if (h <= TARGET_ROW) { out.add(cur); cur = mutableListOf(); sum = 0f }
}
if (cur.isNotEmpty()) out.add(cur)
out
}
Column(Modifier.verticalScroll(rememberScrollState())) {
rows.forEach { row ->
val sum = row.sumOf { it.aspect.toDouble() }.toFloat()
val h = ((widthDp - GUTTER * (row.size - 1)) / sum).dp
Row(Modifier.fillMaxWidth().padding(bottom = GUTTER.dp)) {
row.forEachIndexed { i, p ->
Box(
Modifier
.height(h)
.weight(p.aspect)
.padding(end = if (i < row.size - 1) GUTTER.dp else 0.dp)
) { MosaicTile(p) }
}
}
}
}
}
}
@Composable
fun MosaicTile(photo: Photo) {
Box(Modifier.fillMaxSize()) {
Box(
Modifier
.fillMaxSize()
.background(Brush.linearGradient(photo.gradient))
)
if (photo.faves != null) {
Box(
Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.fillMaxHeight(0.4f)
.background(Brush.verticalGradient(listOf(Color.Transparent, FlickrColors.Scrim)))
)
Row(
Modifier.align(Alignment.BottomStart).padding(8.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(Icons.Filled.Star, null, tint = Color.White, modifier = Modifier.size(13.dp))
Text("${photo.faves}", style = FlickrText.FaveCount, color = Color.White)
}
}
}
}
Favorite Star (the like primitive)
import androidx.compose.animation.core.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.outlined.StarBorder
import androidx.compose.runtime.*
import androidx.compose.ui.draw.scale
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
@Composable
fun FavoriteStar(initial: Boolean = false, count: Int) {
var faved by remember { mutableStateOf(initial) }
val haptics = LocalHapticFeedback.current
val scale = remember { Animatable(1f) }
LaunchedEffect(faved) {
if (faved) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress) // soft impact analog
scale.animateTo(1.25f, spring(dampingRatio = 0.6f, stiffness = Spring.StiffnessMediumLow))
scale.animateTo(1.0f, spring(dampingRatio = 0.6f, stiffness = Spring.StiffnessMediumLow))
}
}
Icon(
imageVector = if (faved) Icons.Filled.Star else Icons.Outlined.StarBorder,
contentDescription = if (faved) "Remove favorite" else "Favorite",
tint = if (faved) FlickrColors.Pink else FlickrColors.TextPrimary,
modifier = Modifier
.size(44.dp)
.padding(11.dp)
.scale(scale.value)
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { faved = !faved },
)
}
Photo Detail Page
@Composable
fun PhotoDetail(photo: Photo) {
Column(
Modifier
.fillMaxSize()
.background(FlickrColors.Canvas)
.verticalScroll(rememberScrollState())
) {
Box(
Modifier
.fillMaxWidth()
.aspectRatio(photo.aspect)
.background(Brush.linearGradient(photo.gradient))
)
Text("Aurora over Vestrahorn", style = FlickrText.PhotoTitle, color = FlickrColors.TextPrimary,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp))
Row(
Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, top = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(Modifier.size(30.dp).clip(CircleShape).background(Brush.linearGradient(listOf(FlickrColors.Pink, FlickrColors.Blue))))
Column(Modifier.weight(1f)) {
Text("Sofia Marent", style = FlickrText.Meta.copy(fontWeight = FontWeight.SemiBold), color = FlickrColors.TextPrimary)
Text("312 faves · 2d ago", style = FlickrText.Caption, color = FlickrColors.TextSecondary)
}
FollowPill()
}
PhotoActionBar()
HorizontalDivider(color = FlickrColors.Divider, thickness = 0.5.dp)
Text("Shot on a 30-second exposure just after the storm cleared the ridge.",
style = FlickrText.Body, color = FlickrColors.TextPrimary, modifier = Modifier.padding(16.dp))
ExifTable()
}
}
@Composable
fun FollowPill() {
var following by remember { mutableStateOf(false) }
val shape = RoundedCornerShape(999.dp)
Box(
Modifier
.clip(shape)
.then(if (following) Modifier.border(1.dp, FlickrColors.TextSecondary, shape) else Modifier.background(FlickrColors.Pink))
.clickable { following = !following }
.padding(horizontal = 18.dp, vertical = 8.dp)
) {
Text(if (following) "Following" else "Follow",
style = FlickrText.Button.copy(fontSize = 13.sp),
color = if (following) FlickrColors.TextSecondary else Color.White)
}
}
Photo Action Bar
import androidx.compose.material.icons.outlined.*
@Composable
fun PhotoActionBar() {
var faved by remember { mutableStateOf(false) }
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(22.dp),
) {
ActionItem(if (faved) Icons.Filled.Star else Icons.Outlined.StarBorder, "Fave",
if (faved) FlickrColors.Pink else FlickrColors.TextSecondary) { faved = !faved }
ActionItem(Icons.Outlined.ChatBubbleOutline, "Comment", FlickrColors.TextSecondary) {}
ActionItem(Icons.Outlined.IosShare, "Share", FlickrColors.TextSecondary) {}
ActionItem(Icons.Outlined.LibraryAdd, "Add", FlickrColors.TextSecondary) {}
}
}
@Composable
private fun ActionItem(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, tint: Color, onClick: () -> Unit) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onClick() },
) {
Icon(icon, label, tint = tint, modifier = Modifier.size(18.dp))
Text(label, style = FlickrText.Caption, color = tint)
}
}
EXIF / Camera Detail Table
@Composable
fun ExifTable() {
data class Row(val k: String, val v: String, val tappable: Boolean)
val rows = listOf(
Row("Camera", "Sony ILCE-7M4", true),
Row("Lens", "FE 35mm F1.4 GM", true),
Row("Exposure", "1/250 sec", false),
Row("Aperture", "f/2.8", false),
Row("Focal length", "35 mm", false),
Row("ISO", "100", false),
Row("Date taken", "Jan 14, 2026", false),
)
Column(
Modifier
.padding(16.dp)
.clip(RoundedCornerShape(12.dp))
.background(FlickrColors.Surface1)
) {
Text("CAMERA DETAILS",
style = FlickrText.Tab.copy(fontWeight = FontWeight.Bold, letterSpacing = 0.8.sp),
color = FlickrColors.TextTertiary,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 10.dp))
rows.forEachIndexed { i, r ->
Row(
Modifier
.fillMaxWidth()
.then(if (r.tappable) Modifier.clickable { /* search this gear */ } else Modifier)
.padding(horizontal = 16.dp, vertical = 7.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(r.k, style = FlickrText.ExifKey, color = FlickrColors.TextSecondary)
Text(r.v, style = FlickrText.ExifValue, color = FlickrColors.TextPrimary)
}
if (i < rows.size - 1) HorizontalDivider(color = FlickrColors.Divider, thickness = 0.5.dp, modifier = Modifier.padding(start = 16.dp))
}
}
}
4. Navigation
Flickr has minimal chrome: a bottom tab strip (Feed / Search / Camera / Notify / You) with an emphasized center camera. There is no tint pill — active is just white.
@Composable
fun FlickrBottomBar(selected: Int, onSelect: (Int) -> Unit) {
NavigationBar(
containerColor = FlickrColors.Canvas.copy(alpha = 0.94f),
tonalElevation = 0.dp,
) {
val items = listOf(
"Feed" to Icons.Filled.Home,
"Search" to Icons.Filled.Search,
"Camera" to Icons.Filled.PhotoCamera, // center, emphasized
"Notify" to Icons.Filled.Notifications,
"You" to Icons.Filled.AccountCircle,
)
items.forEachIndexed { i, (label, icon) ->
val isCamera = i == 2
NavigationBarItem(
selected = selected == i,
onClick = { onSelect(i) },
icon = {
Icon(icon, contentDescription = label,
tint = if (isCamera) FlickrColors.Pink else LocalContentColor.current,
modifier = Modifier.size(if (isCamera) 27.dp else 23.dp))
},
label = if (isCamera) null else { { Text(label, style = FlickrText.Tab) } },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = FlickrColors.TextPrimary,
selectedTextColor = FlickrColors.TextPrimary,
unselectedIconColor = FlickrColors.TextTertiary,
unselectedTextColor = FlickrColors.TextTertiary,
indicatorColor = Color.Transparent, // no Material pill — Flickr has none
),
)
}
}
}
The top app bar uses a LargeTopAppBar whose collapsed title is FlickrText.NavTitle (Proza Libre 22sp). Photo detail uses a small top bar with a back arrow and an overflow (Report, Download, Copy link).
5. Motion
Flickr motion is photographic — quiet, never competing with the image.
| Moment | Compose recipe |
|---|---|
| Fave star tap | Animatable animateTo(1.25f) then 1.0f with spring(dampingRatio = 0.6f); soft haptic; optional #FF0084 particle overlay |
| Grid -> detail | shared-element via androidx.compose.animation SharedTransitionLayout / sharedBounds, 320ms ease-out expand of the tile into the full-width image |
| Lightbox open | photo scale/offset from grid position to full screen tween(300, easing = FastOutSlowIn); backdrop animates to Scrim (0x73000000) |
| New justified rows | AnimatedVisibility fadeIn(tween(200)) as rows pack during scroll |
| Photo detail parallax | image Modifier.offset { IntOffset(0, (scrollState.value * 0.5f).toInt()) } |
| Tab change | instant content swap (gallery feel — no cross-fade) |
| Upload progress | determinate CircularProgressIndicator tinted Pink; success -> Success checkmark scaleIn(tween(200)) |
// Fave burst — the canonical Flickr motion
LaunchedEffect(faved) {
if (faved) {
scale.animateTo(1.25f, spring(dampingRatio = 0.6f, stiffness = Spring.StiffnessMediumLow))
scale.animateTo(1.0f, spring(dampingRatio = 0.6f, stiffness = Spring.StiffnessMediumLow))
}
}
Haptics: LocalHapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) for the soft fave impact; HapticFeedbackConstants.CONFIRM (via LocalView) for add-to-album; a light CLOCK_TICK for tab change. Upload completion is silent visually except the green checkmark.
6. Icons
The closest first-party set is androidx.compose.material:material-icons-extended. The favorite primitive is Icons.Filled.Star / Icons.Outlined.StarBorder — a five-point star, never a heart.
| Purpose | SF Symbol (iOS) | Material Icon (Compose) |
|---|---|---|
| Feed (tab) | house / house.fill |
Icons.Filled.Home |
| Search (tab) | magnifyingglass |
Icons.Filled.Search |
| Camera (tab, center) | camera |
Icons.Filled.PhotoCamera (tint #FF0084) |
| Notify (tab) | bell / bell.fill |
Icons.Filled.Notifications |
| You (tab) | person.crop.circle |
Icons.Filled.AccountCircle |
| Favorite (inactive) | star |
Icons.Outlined.StarBorder |
| Favorite (active) | star.fill |
Icons.Filled.Star (#FF0084) |
| Comment | bubble.right |
Icons.Outlined.ChatBubbleOutline |
| Share | square.and.arrow.up |
Icons.Outlined.IosShare |
| Add to album | rectangle.stack.badge.plus |
Icons.Outlined.LibraryAdd |
| Back | chevron.left |
Icons.Filled.ArrowBack |
| Overflow | ellipsis |
Icons.Filled.MoreHoriz |
| Download | arrow.down.circle |
Icons.Outlined.FileDownload |
| Pro badge | star.circle.fill |
Icons.Filled.Stars (#FFB200) |
7. Minimum SDK & Accessibility Notes
- minSdk 24 (Compose floor 21;
SharedTransitionLayoutneeds a recent Compose animation artifact).compileSdk 34+, Compose BOM2024.09+, Kotlin2.0+. Coil2.6+for photos. - Edge-to-edge: call
enableEdgeToEdge(); the near-black canvas wants light-content system bars. The photo grid is full-bleed (draw under the status bar); text content keeps 16dp insets; the tab bar respects the navigation bar inset. - Font scaling:
sphonors the user’s font scale — keep it on photo titles, body, comments, metadata. Pin layout-sensitive text (EXIF values, fave-count overlays, 10sp tab labels) by deriving fromdporCompositionLocalProvider(LocalDensity provides Density(density, fontScale = 1f))so the justified grid math stays stable. - TalkBack: label tiles “Photo by {author}, {n} favorites”; the fave control as a toggle (
Modifier.semantics { role = Role.Switch; stateDescription = if (faved) "Favorited" else "Not favorited" }); EXIF rows “{key}: {value}”; expose Camera/Lens “see more shot with this gear” viaModifier.semantics { customActions = ... }. - Touch targets: Material guidance is 48.dp — the 22dp fave star and 18dp action icons get 48dp hit areas via padding; grid tiles are large enough; EXIF Camera/Lens rows are full-row tappable.
- Contrast:
#FFFFFFon#0C0D0Eis maximal;#B0B3B8secondary passes AA on canvas; the fave-count overlay always sits on theScrimso it stays legible over any photo. - Reduce motion: when
Settings.Global.ANIMATOR_DURATION_SCALE == 0, disable the fave spring/burst and the grid->detail shared transition — substitute aCrossfade; keep selection/favorited state. - Dark mode: Flickr is dark-first; the light scheme flips canvas to
#FFFFFF, text to#1C1F23, EXIF container to#F5F6F7. Photos never dim — only chrome and EXIF tables invert. Do not enabledynamicColorScheme()— the twin Pink/Blue brand must hold regardless of wallpaper.