Goodreads (iOS) — Jetpack Compose Implementation Guide
Companion to DESIGN.md — the framework-neutral spec. This file ports Goodreads’ visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, paste-ready @Composables, the book detail + star rating, 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 (Goodreads’ warm tan paper, literary brown chrome, amber five-star rating, cover-first shelves) while making everything idiomatic Android —
NavigationBarinstead of a UITabBar,ModalBottomSheetinstead of a SwiftUI sheet,sp/dpinstead ofpt.
Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ for cover art and avatars. No color extraction — Goodreads’ palette is a fixed warm set, so Palette is not needed. Goodreads is light-mode-first (paper); a full dark scheme is provided.
1. Color Tokens
// ui/theme/GoodreadsColors.kt
import androidx.compose.ui.graphics.Color
object GoodreadsColors {
// Brand & interactive
val Brown = Color(0xFF382110)
val BrownDeep = Color(0xFF58371F)
val Tan = Color(0xFFF4F1EA)
val ShelfGreen = Color(0xFF409D69)
val ShelfGreenPressed = Color(0xFF348355)
val LinkTeal = Color(0xFF00635D)
val LinkTealDark = Color(0xFF6FB3AD)
// Rating
val Amber = Color(0xFFE9A100) // load-bearing — five-star rating, light AND dark
val StarRail = Color(0xFFD8D2C4)
val StarRailDark = Color(0xFF4A3F2C)
// Canvas & surfaces (light)
val Canvas = Color(0xFFF4F1EA) // warm tan paper — NOT pure white
val CardWhite = Color(0xFFFFFFFF)
val SurfaceSubtle = Color(0xFFEBE6DA)
val Divider = Color(0xFFDDD6C7)
// Canvas & surfaces (dark)
val DarkCanvas = Color(0xFF161310) // warm ink-brown — NOT true black
val DarkSurface1 = Color(0xFF211C16)
val DarkSurface2 = Color(0xFF2C261D)
val DarkDivider = Color(0xFF3A3226)
val BrownOnDark = Color(0xFFC9883D) // brown reads muddy on dark → tan-gold accent
// Text
val TextPrimary = Color(0xFF382110) // warm brown-black — NOT pure black
val TextSecondary = Color(0xFF6B5E47)
val TextTertiary = Color(0xFF988B6F)
val DarkTextPrimary = Color(0xFFEDE6D8)
val DarkTextSecondary = Color(0xFFB3A88F)
// Semantic
val Error = Color(0xFFD9534F)
}
Wire it into both schemes. Goodreads is light-first (paper); the dark scheme uses the signature #161310 ink-brown, 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
private val GoodreadsLight = lightColorScheme(
primary = GoodreadsColors.Brown,
onPrimary = GoodreadsColors.Tan,
secondary = GoodreadsColors.ShelfGreen,
onSecondary = Color(0xFFFFFFFF),
background = GoodreadsColors.Canvas,
onBackground = GoodreadsColors.TextPrimary,
surface = GoodreadsColors.CardWhite,
onSurface = GoodreadsColors.TextPrimary,
surfaceVariant = GoodreadsColors.SurfaceSubtle,
outline = GoodreadsColors.Divider,
error = GoodreadsColors.Error,
)
private val GoodreadsDark = darkColorScheme(
primary = GoodreadsColors.BrownOnDark,
onPrimary = GoodreadsColors.DarkCanvas,
secondary = GoodreadsColors.ShelfGreen,
onSecondary = Color(0xFFFFFFFF),
background = GoodreadsColors.DarkCanvas,
onBackground = GoodreadsColors.DarkTextPrimary,
surface = GoodreadsColors.DarkSurface1,
onSurface = GoodreadsColors.DarkTextPrimary,
surfaceVariant = GoodreadsColors.DarkSurface2,
outline = GoodreadsColors.DarkDivider,
error = GoodreadsColors.Error,
)
@Composable
fun GoodreadsTheme(
dark: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) = MaterialTheme(
colorScheme = if (dark) GoodreadsDark else GoodreadsLight,
typography = GoodreadsTypography,
content = content,
)
2. Typography (Material 3)
Goodreads pairs Merriweather (editorial: titles, synopses, reviews) with Lato (UI chrome). Drop the TTFs in res/font/. Serif for what the user reads; sans for what the user acts on. Body/review line-height ≈ 1.6.
// ui/theme/GoodreadsType.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 Merriweather = FontFamily(
Font(R.font.merriweather_regular, FontWeight.Normal),
Font(R.font.merriweather_bold, FontWeight.Bold),
Font(R.font.merriweather_black, FontWeight.Black),
)
val Lato = FontFamily(
Font(R.font.lato_regular, FontWeight.Normal),
Font(R.font.lato_bold, FontWeight.Bold),
Font(R.font.lato_black, FontWeight.Black),
)
// Named ramp — mirrors DESIGN.md §3 exactly (pt → sp 1:1)
object GoodreadsText {
val ScreenTitle = TextStyle(Merriweather, fontWeight = FontWeight.Black, fontSize = 30.sp, lineHeight = 36.sp, letterSpacing = (-0.3).sp)
val BookTitle = TextStyle(Merriweather, fontWeight = FontWeight.Black, fontSize = 23.sp, lineHeight = 28.sp, letterSpacing = (-0.2).sp)
val Section = TextStyle(Merriweather, fontWeight = FontWeight.Bold, fontSize = 19.sp, lineHeight = 25.sp)
val Subsection = TextStyle(Merriweather, fontWeight = FontWeight.Bold, fontSize = 17.sp, lineHeight = 23.sp)
val Body = TextStyle(Merriweather, fontWeight = FontWeight.Normal, fontSize = 15.sp, lineHeight = 24.sp) // ~1.6
val Review = TextStyle(Merriweather, fontWeight = FontWeight.Normal, fontSize = 13.sp, lineHeight = 20.sp)
val Button = TextStyle(Lato, fontWeight = FontWeight.Black, fontSize = 17.sp, lineHeight = 17.sp, letterSpacing = 0.1.sp)
val Label = TextStyle(Lato, fontWeight = FontWeight.Bold, fontSize = 14.sp, lineHeight = 18.sp)
val Meta = TextStyle(Lato, fontWeight = FontWeight.Normal, fontSize = 13.sp, lineHeight = 18.sp)
val Link = TextStyle(Lato, fontWeight = FontWeight.Bold, fontSize = 13.sp, lineHeight = 18.sp)
val Caption = TextStyle(Lato, fontWeight = FontWeight.Normal, fontSize = 12.sp, lineHeight = 16.sp)
val StarCaption = TextStyle(Lato, fontWeight = FontWeight.Bold, fontSize = 12.sp, lineHeight = 14.sp)
val Overline = TextStyle(Lato, fontWeight = FontWeight.Bold, fontSize = 11.sp, lineHeight = 13.sp, letterSpacing = 0.6.sp)
val Tab = TextStyle(Lato, fontWeight = FontWeight.Bold, fontSize = 10.sp, lineHeight = 12.sp, letterSpacing = 0.1.sp)
}
// Map onto Material 3 slots so stock components inherit the brand voice
val GoodreadsTypography = Typography(
headlineLarge = GoodreadsText.ScreenTitle,
headlineMedium = GoodreadsText.BookTitle,
titleMedium = GoodreadsText.Section,
bodyMedium = GoodreadsText.Body,
labelSmall = GoodreadsText.Tab,
)
3. Signature Components
Book Cover
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
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.unit.dp
import coil.compose.AsyncImage
@Composable
fun BookCover(imageUrl: String?, width: Int = 104, dark: Boolean = false) {
val w = width.dp
val h = (width * 1.52f).dp
Box(
Modifier
.size(w, h)
.shadow(if (dark) 8.dp else 5.dp, RoundedCornerShape(3.dp),
spotColor = Color.Black.copy(alpha = if (dark) 0.55f else 0.18f))
.clip(RoundedCornerShape(3.dp))
) {
AsyncImage(
model = imageUrl,
contentDescription = null,
modifier = Modifier.matchParentSize()
.background(Brush.linearGradient(listOf(GoodreadsColors.BrownDeep, GoodreadsColors.Brown))),
contentScale = ContentScale.Crop,
)
Box(Modifier.fillMaxHeight().width(6.dp).background(Color.Black.copy(alpha = 0.35f)))
}
}
Star Rating (display, fractional)
import androidx.compose.foundation.Canvas
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.Icon
import androidx.compose.ui.draw.clipToBounds
@Composable
fun StarRating(value: Float, size: Int = 15, dark: Boolean = false) {
val rail = if (dark) GoodreadsColors.StarRailDark else GoodreadsColors.StarRail
Row(horizontalArrangement = Arrangement.spacedBy(1.dp)) {
repeat(5) { i ->
val fill = (value - i).coerceIn(0f, 1f)
Box(Modifier.size(size.dp)) {
Icon(Icons.Filled.Star, null, tint = rail, modifier = Modifier.matchParentSize())
Box(Modifier.fillMaxHeight().fillMaxWidth(fill).clipToBounds()) {
Icon(Icons.Filled.Star, null, tint = GoodreadsColors.Amber,
modifier = Modifier.size(size.dp))
}
}
}
}
}
Interactive “Rate this Book”
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.runtime.*
import androidx.compose.ui.draw.scale
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.style.TextAlign
@Composable
fun RateThisBook(dark: Boolean = false, onRate: (Int) -> Unit = {}) {
var rating by remember { mutableIntStateOf(0) }
val haptics = LocalHapticFeedback.current
Column(
Modifier
.padding(horizontal = 16.dp)
.clip(RoundedCornerShape(6.dp))
.background(if (dark) GoodreadsColors.DarkSurface1 else GoodreadsColors.CardWhite)
.padding(14.dp)
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text("RATE THIS BOOK", style = GoodreadsText.Overline,
color = if (dark) GoodreadsColors.DarkTextSecondary else GoodreadsColors.TextSecondary,
textAlign = TextAlign.Center)
Spacer(Modifier.height(10.dp))
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
(1..5).forEach { i ->
val s by animateFloatAsState(if (i == rating) 1.2f else 1f,
spring(dampingRatio = 0.55f, stiffness = Spring.StiffnessMedium), label = "starPop")
Icon(
Icons.Filled.Star, null,
tint = if (i <= rating) GoodreadsColors.Amber
else if (dark) GoodreadsColors.StarRailDark else GoodreadsColors.StarRail,
modifier = Modifier.size(28.dp).scale(if (i == rating) s else 1f).clickable {
rating = i
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
onRate(i)
},
)
}
}
}
}
Want-to-Read CTA Block
import androidx.compose.foundation.border
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
@Composable
fun ShelfCTA() {
var shelf by remember { mutableStateOf<String?>(null) }
val haptics = LocalHapticFeedback.current
Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = { shelf = shelf ?: "Want to Read"; haptics.performHapticFeedback(HapticFeedbackType.LongPress) },
shape = RoundedCornerShape(4.dp),
colors = ButtonDefaults.buttonColors(containerColor = GoodreadsColors.ShelfGreen),
modifier = Modifier.fillMaxWidth().height(44.dp),
) {
if (shelf != null) { Icon(Icons.Filled.Check, null, tint = Color.White, modifier = Modifier.size(16.dp)); Spacer(Modifier.width(8.dp)) }
Text(shelf ?: "Want to Read", style = GoodreadsText.Button, color = Color.White)
}
Row(
Modifier.fillMaxWidth().border(1.dp, GoodreadsColors.Divider, RoundedCornerShape(4.dp))
.clip(RoundedCornerShape(4.dp)),
) {
listOf("★ Rate", "Review", "Shelve ▾").forEachIndexed { i, seg ->
Box(Modifier.weight(1f).height(38.dp), contentAlignment = Alignment.Center) {
Text(seg, style = GoodreadsText.StarCaption,
color = if (seg == "★ Rate") GoodreadsColors.Amber else GoodreadsColors.TextSecondary)
}
if (i < 2) Box(Modifier.fillMaxHeight().width(1.dp).background(GoodreadsColors.Divider))
}
}
}
}
Shelf Status Pill
@Composable
fun ShelfPill(kind: String) { // "read" | "current" | "want"
data class P(val bg: Color, val fg: Color, val border: Color?)
val p = when (kind) {
"read" -> P(GoodreadsColors.Brown, GoodreadsColors.Tan, null)
"current" -> P(GoodreadsColors.ShelfGreen.copy(alpha = 0.18f), GoodreadsColors.ShelfGreen, GoodreadsColors.ShelfGreen.copy(alpha = 0.4f))
else -> P(GoodreadsColors.SurfaceSubtle, GoodreadsColors.TextSecondary, GoodreadsColors.Divider)
}
val label = when (kind) { "read" -> "Read"; "current" -> "Currently Reading"; else -> "Want to Read" }
Box(
Modifier
.clip(RoundedCornerShape(999.dp))
.background(p.bg)
.then(if (p.border != null) Modifier.border(1.dp, p.border, RoundedCornerShape(999.dp)) else Modifier)
.padding(horizontal = 14.dp, vertical = 7.dp),
) { Text(label, style = GoodreadsText.Link, color = p.fg) }
}
4. Navigation
Goodreads has a 5-tab bottom strip and a serif large title that collapses on scroll. On Android, model the strip as a NavigationBar (no tint pill — active is glyph fill + color) and the detail screen with a LargeTopAppBar.
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults
@Composable
fun GoodreadsBottomBar(selected: Int, onSelect: (Int) -> Unit, dark: Boolean = false) {
NavigationBar(
containerColor = if (dark) GoodreadsColors.DarkCanvas else GoodreadsColors.Canvas,
tonalElevation = 0.dp,
) {
val items = listOf(
"Home" to Icons.Filled.Home,
"My Books" to Icons.Filled.MenuBook,
"Browse" to Icons.Filled.Search,
"Updates" to Icons.Filled.Notifications,
"Profile" to Icons.Filled.AccountCircle,
)
items.forEachIndexed { i, (label, icon) ->
NavigationBarItem(
selected = selected == i,
onClick = { onSelect(i) },
icon = { Icon(icon, contentDescription = label, modifier = Modifier.size(23.dp)) },
label = { Text(label, style = GoodreadsText.Tab) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = if (dark) GoodreadsColors.BrownOnDark else GoodreadsColors.Brown,
selectedTextColor = if (dark) GoodreadsColors.BrownOnDark else GoodreadsColors.Brown,
unselectedIconColor = if (dark) GoodreadsColors.DarkTextSecondary else GoodreadsColors.TextTertiary,
unselectedTextColor = if (dark) GoodreadsColors.DarkTextSecondary else GoodreadsColors.TextTertiary,
indicatorColor = Color.Transparent, // no Material pill — Goodreads has none
),
)
}
}
}
The shelf picker is a ModalBottomSheet (12.dp top radius): exclusive shelves as a radio group (Read / Currently Reading / Want to Read), custom shelves as a checkbox list below a Divider, a green “Done” Button pinned at the bottom.
5. Motion
Goodreads motion is gentle and library-quiet — 180–300ms.
| Moment | Compose recipe |
|---|---|
| Shelve action | green Button animateFloatAsState scale 1→0.96→1 spring(0.6); success haptic |
| Star commit | per-star animateFloatAsState 1→1.2→1 spring(dampingRatio=0.55f), staggered fill; HapticFeedbackType.LongPress |
| “…more” expander | AnimatedVisibility(expandVertically(tween(220)) + fadeIn()) |
| Cover → detail | shared-element via Modifier.sharedElement (Compose 1.7) or scale tween(300) |
| Reading Challenge ring | Canvas arc sweep animateFloatAsState(target, tween(700)) on first composition |
| Pull to refresh | PullToRefreshContainer with brown indicator |
| Tab switch | instant; selected glyph crossfades tween(120) |
// Star bounce — the canonical Goodreads rating motion
val pop by animateFloatAsState(
targetValue = if (i == rating) 1.2f else 1f,
animationSpec = spring(dampingRatio = 0.55f, stiffness = Spring.StiffnessMedium),
label = "starPop",
)
Haptics: prefer LocalHapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) for the medium impact on star commit and the soft confirm on shelve. Progress auto-save is silent — show a “Saved” Snackbar only on a manual progress update or an error.
6. Icons
Goodreads’ iconography is conventional; the closest first-party set is androidx.compose.material:material-icons-extended. The five-star rating uses Icons.Filled.Star (clip a copy for fractional fill); avatars fall back to initials on a brown gradient.
| Purpose | SF Symbol (iOS) | Material Icon (Compose) |
|---|---|---|
| Home (tab) | house / house.fill |
Icons.Filled.Home |
| My Books (tab) | books.vertical |
Icons.Filled.MenuBook |
| Browse (tab) | magnifyingglass |
Icons.Filled.Search |
| Updates (tab) | bell / bell.fill |
Icons.Filled.Notifications |
| Profile (tab) | person.crop.circle |
Icons.Filled.AccountCircle |
| Star (rating) | star.fill |
Icons.Filled.Star |
| Half star | star.leadinghalf.filled |
Icons.AutoMirrored.Filled.StarHalf |
| Back | chevron.left |
Icons.AutoMirrored.Filled.ArrowBack |
| Share | square.and.arrow.up |
Icons.Filled.IosShare |
| Add to network | person.2 |
Icons.Filled.GroupAdd |
| Shelve chevron | chevron.down |
Icons.Filled.ExpandMore |
| Want to Read check | checkmark |
Icons.Filled.Check |
| Progress update | arrow.triangle.2.circlepath |
Icons.Filled.Sync |
| Like (review) | heart / heart.fill |
Icons.Filled.FavoriteBorder / Icons.Filled.Favorite |
| Comment | text.bubble |
Icons.AutoMirrored.Filled.Comment |
| Scan ISBN | barcode.viewfinder |
Icons.Filled.QrCodeScanner |
7. Minimum SDK & Accessibility Notes
- minSdk 24 (Compose floor is 21; modern motion comfortable at 24).
compileSdk 34+, Compose BOM2024.09+, Kotlin2.0+. - Edge-to-edge: call
enableEdgeToEdge(); the tan light canvas wants dark-content system bars (light-content in dark mode). The reviews feed and cover grids are long — useLazyVerticalGrid/LazyColumn. - Font scaling:
sphonors the user’s font scale — keep it on screen titles, book titles, body, review prose (reading content must scale). Pin layout-sensitive text (10sp tab labels, overlines, star captions, shelf pills) by deriving fromdporCompositionLocalProvider(LocalDensity provides Density(density, fontScale = 1f)). - Reading content: never collapse the 1.6 line-height on Body/Review — long reviews must stay comfortable at large font scales.
- TalkBack: announce ratings as “Rated 4 of 5 stars, 3,284,991 ratings”; the interactive rate row uses
Modifier.semantics { stateDescription = "$rating of 5 stars"; role = Role.... }and a custom set action; give everyBookCoveracontentDescriptionof “{title} by {author}”. - Touch targets: Material guidance is 48.dp. Give the 28.dp interactive stars a 48.dp hit area via padding; sidebar/list rows are full-row tappable; primary CTA ≥ 44.dp.
- Contrast:
#382110on#F4F1EApasses WCAG AA. Amber#E9A100is a graphical rating; always pair it with the numeric average so meaning never rests on color alone — never grey-fill a fractional star (clip the amber instead). - Reduce motion: when
Settings.Global.ANIMATOR_DURATION_SCALE == 0, disable the star pop and the ring sweep — substitute a plainCrossfade; keep the fill state (it conveys the rating). - Dark mode: invert via the
Dark*palette —#161310, NOT true black;#382110text becomes#EDE6D8; brand brown becomes tan-gold#C9883Dfor active tabs; link teal lightens to#6FB3AD. Cover shadows deepen to 0.55 alpha so jackets still detach. Do not enable Material YoudynamicColorScheme()— Goodreads’ paper-and-ink identity and the fixed amber rating must hold regardless of wallpaper.