coderef

Locket

Locket's iOS app is built around a single, radical idea: the app is not the destination — the Home Screen widget is. Locket Widget lets a tiny circle of your closest friends post a live photo directly to a square widget on your phone's…
DownloadRaw
Locket

Build with the Locket look.

Palette, type and components from this DESIGN.md, applied to a sample hero.

Palette

At a glance

Theme
light
Typefaces
Poppins
Sections
9
Length
4,072 words
Platforms
Android, Expo, SwiftUI, DESIGN.md

Add it to your project

Then prompt your agent

Design System · Locket

Light and dark previews, tokens and a ready-to-use package, generated from this DESIGN.md. No AI involved: the same file always produces the same output.

Generating design system…
DownloadRaw

Locket (iOS) — Jetpack Compose Implementation Guide

Companion to DESIGN.md — the framework-neutral spec. This file ports Locket’s warm cream-gold visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, paste-ready @Composables for the capture button, square viewfinder, and friends history grid, plus the home-screen widget (Glance) — the widget IS the product.

Why a Compose guide for an iOS-referenced app? The DESIGN.md tokens are platform-neutral. This file keeps the visual identity (Locket’s warm cream-gold world, the square 40dp-corner friend photo, the big white capture button with a gold ring, the friends history grid, widget-first) while making everything idiomatic Android — NavigationBar instead of a UITabBar, Glance GlanceAppWidget instead of WidgetKit, dp/sp instead of pt.

Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, Coil 2.6+, and androidx.glance:glance-appwidget for the home-screen widget. Locket is warm-by-design — there is no dark scheme; the cozy cream-gold IS the identity.

1. Color Tokens

// ui/theme/LocketColors.kt
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color

object LocketColors {
    // Brand
    val Gold        = Color(0xFFFFB02E)
    val Amber       = Color(0xFFFF9E2C)
    val Deep        = Color(0xFFF08A1D) // on-cream accent
    val GoldPressed = Color(0xFFDC7A12)
    val OnGold      = Color(0xFF3A2400)

    // Surfaces
    val Cream    = Color(0xFFFFF7EC)
    val BgTop    = Color(0xFFFFE9CC)
    val BgBottom = Color(0xFFFFD9A8)
    val White    = Color(0xFFFFFFFF)
    val Surface  = Color(0xFFFFF1DD)
    val Divider  = Color(0xFFF2E2C9)

    // Text
    val TextPrimary   = Color(0xFF2C2014) // warm brown — NOT black
    val TextSecondary = Color(0xFF8A7A63)
    val TextTertiary  = Color(0xFFB8A88E)

    // Accent
    val Coral = Color(0xFFFF7A59)

    // Semantic
    val Success = Color(0xFF38C172)
    val Error   = Color(0xFFFF5A5A)

    // World background (full-bleed, every screen)
    val World = Brush.verticalGradient(listOf(BgTop, BgBottom))
    val AvatarGradient = Brush.linearGradient(listOf(Gold, Coral))

    // Warm honey-tinted shadow — NEVER neutral gray
    val Honey     = Color(0xFFB4781E) // rgba(180,120,30)
    val HoneyDeep = Color(0xFFA06414) // rgba(160,100,20)
}

Locket is warm-by-design — wire a single warm scheme. Never enable Material You dynamicColorScheme() and never build a dark scheme: the cream-gold identity IS the brand and must hold regardless of the system theme.

// ui/theme/Theme.kt
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.lightColorScheme

private val LocketScheme = lightColorScheme(
    primary        = LocketColors.Gold,
    onPrimary      = LocketColors.OnGold,
    background      = LocketColors.Cream,
    onBackground    = LocketColors.TextPrimary,
    surface         = LocketColors.White,
    onSurface       = LocketColors.TextPrimary,
    surfaceVariant  = LocketColors.Surface,
    outline         = LocketColors.Divider,
    error           = LocketColors.Error,
)

@Composable
fun LocketTheme(content: @Composable () -> Unit) =
    MaterialTheme(colorScheme = LocketScheme, typography = LocketTypography, content = content)

2. Typography

Locket’s UI face is Poppins (SIL OFL — drop the TTFs in res/font/), warm 600–800.

// ui/theme/LocketType.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),
)

object LocketText {
    val Display   = TextStyle(Poppins, fontWeight = FontWeight.ExtraBold, fontSize = 32.sp, lineHeight = 37.sp, letterSpacing = (-0.4).sp)
    val Screen    = 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 Sender    = TextStyle(Poppins, fontWeight = FontWeight.Bold,      fontSize = 18.sp, lineHeight = 24.sp)
    val Caption   = TextStyle(Poppins, fontWeight = FontWeight.SemiBold,  fontSize = 16.sp, lineHeight = 22.sp)
    val Body      = TextStyle(Poppins, fontWeight = FontWeight.Medium,    fontSize = 15.sp, lineHeight = 22.sp)
    val BigNum    = TextStyle(Poppins, fontWeight = FontWeight.ExtraBold, fontSize = 28.sp, lineHeight = 28.sp, letterSpacing = (-0.5).sp)
    val Meta      = TextStyle(Poppins, fontWeight = FontWeight.Normal,    fontSize = 13.sp, lineHeight = 17.sp)
    val Label     = TextStyle(Poppins, fontWeight = FontWeight.Bold,      fontSize = 12.sp, lineHeight = 12.sp, letterSpacing = 0.4.sp)
    val Button    = TextStyle(Poppins, fontWeight = FontWeight.Bold,      fontSize = 16.sp, lineHeight = 16.sp, letterSpacing = 0.2.sp)
    val Pill      = TextStyle(Poppins, fontWeight = FontWeight.Bold,      fontSize = 14.sp, lineHeight = 14.sp)
    val Tab       = TextStyle(Poppins, fontWeight = FontWeight.Bold,      fontSize = 10.sp, lineHeight = 10.sp, letterSpacing = 0.1.sp)
}

val LocketTypography = Typography(
    headlineLarge = LocketText.Display,
    headlineMedium = LocketText.Screen,
    titleLarge    = LocketText.Section,
    titleMedium   = LocketText.Sender,
    bodyMedium    = LocketText.Body,
    labelSmall    = LocketText.Tab,
)

3. Signature Components

World Background

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier

@Composable
fun LocketWorld(content: @Composable () -> Unit) {
    Box(Modifier.fillMaxSize().background(LocketColors.World)) { content() }
}

Capture Button (the signature interaction)

import androidx.compose.animation.core.*
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Surface
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.draw.alpha
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 androidx.compose.ui.unit.dp

@Composable
fun CaptureButton(onCapture: () -> Unit) {
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    val scale by animateFloatAsState(if (pressed) 0.92f else 1f,
        spring(dampingRatio = Spring.DampingRatioMediumBouncy), label = "capScale")
    val pulse = remember { Animatable(0f) }
    val haptics = LocalHapticFeedback.current
    val scope = rememberCoroutineScope()

    Box(contentAlignment = Alignment.Center) {
        // expanding gold ring pulse
        Box(
            Modifier
                .size(84.dp)
                .scale(1f + pulse.value * 0.6f)
                .alpha(1f - pulse.value)
                .border(4.dp, LocketColors.Gold, CircleShape)
        )
        Box(
            Modifier
                .size(84.dp).scale(scale)
                .shadow(10.dp, CircleShape, spotColor = LocketColors.Honey, ambientColor = LocketColors.Honey)
                .clip(CircleShape).background(LocketColors.White)
                .border(5.dp, LocketColors.White.copy(alpha = 0.55f), CircleShape)
                .clickable(interactionSource = interaction, indication = null) {
                    haptics.performHapticFeedback(HapticFeedbackType.LongPress)
                    scope.launch {
                        pulse.snapTo(0f)
                        pulse.animateTo(1f, tween(250))
                        pulse.snapTo(0f)
                    }
                    onCapture()
                },
            contentAlignment = Alignment.Center,
        ) {
            Box(Modifier.size(66.dp).border(4.dp, LocketColors.Gold, CircleShape))
        }
    }
}

Square Viewfinder / Friend Photo

import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
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 coil.compose.AsyncImage

@Composable
fun FriendPhoto(imageUrl: String?, sender: String, caption: String?) {
    Box(
        Modifier
            .fillMaxWidth().aspectRatio(1f)
            .shadow(24.dp, RoundedCornerShape(40.dp), spotColor = LocketColors.HoneyDeep, ambientColor = LocketColors.HoneyDeep)
            .clip(RoundedCornerShape(40.dp)),
    ) {
        if (imageUrl != null) {
            AsyncImage(model = imageUrl, contentDescription = "Photo from $sender",
                modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
        } else {
            Box(Modifier.fillMaxSize().background(
                Brush.linearGradient(listOf(Color(0xFFF5B96B), Color(0xFFC56A22)))))
        }

        // sender chip (top-left, warm frosted — emulate blur with a translucent warm scrim)
        Row(
            Modifier
                .align(Alignment.TopStart).padding(16.dp)
                .clip(RoundedCornerShape(50))
                .background(Color(0xFF2C2014).copy(alpha = 0.32f))
                .padding(start = 6.dp, end = 14.dp, top = 6.dp, bottom = 6.dp),
            verticalAlignment = Alignment.CenterVertically,
            horizontalArrangement = Arrangement.spacedBy(8.dp),
        ) {
            Box(Modifier.size(24.dp).clip(CircleShape).background(LocketColors.AvatarGradient))
            Text("from $sender", style = LocketText.Pill, color = Color.White)
        }

        if (caption != null) {
            Text(
                caption, style = LocketText.Caption, color = Color.White,
                modifier = Modifier
                    .align(Alignment.BottomCenter).padding(bottom = 18.dp)
                    .clip(RoundedCornerShape(16.dp))
                    .background(Color(0xFF2C2014).copy(alpha = 0.36f))
                    .padding(horizontal = 16.dp, vertical = 8.dp),
            )
        }
    }
}

Friends Photo-History Grid

import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items

@Composable
fun HistoryGrid(tints: List<Brush>) {
    LazyVerticalGrid(
        columns = GridCells.Fixed(4),
        horizontalArrangement = Arrangement.spacedBy(8.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp),
    ) {
        items(tints) { brush ->
            Box(
                Modifier.aspectRatio(1f).clip(RoundedCornerShape(14.dp)).background(brush),
            ) {
                Box(
                    Modifier
                        .align(Alignment.BottomStart).padding(4.dp)
                        .size(14.dp).clip(CircleShape)
                        .background(LocketColors.TextPrimary.copy(alpha = 0.4f))
                        .border(1.5.dp, Color.White, CircleShape)
                )
            }
        }
    }
}

Friends Pill + Primary Button

@Composable
fun FriendsPill(count: Int) {
    Row(
        Modifier
            .shadow(6.dp, RoundedCornerShape(50), spotColor = LocketColors.Honey, ambientColor = LocketColors.Honey)
            .clip(RoundedCornerShape(50)).background(LocketColors.White)
            .padding(start = 10.dp, end = 16.dp, top = 8.dp, bottom = 8.dp),
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.spacedBy(8.dp),
    ) {
        Row {
            repeat(3) { i ->
                Box(
                    Modifier
                        .offset(x = if (i == 0) 0.dp else (-8 * i).dp)
                        .size(22.dp).clip(CircleShape)
                        .background(LocketColors.AvatarGradient)
                        .border(2.dp, Color.White, CircleShape)
                )
            }
        }
        Text("$count friends", style = LocketText.Pill, color = LocketColors.TextPrimary)
    }
}

@Composable
fun LocketPrimaryButton(title: String, onClick: () -> Unit) {
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    Box(
        Modifier
            .shadow(8.dp, RoundedCornerShape(50), spotColor = LocketColors.Honey, ambientColor = LocketColors.Honey)
            .clip(RoundedCornerShape(50))
            .background(if (pressed) LocketColors.GoldPressed else LocketColors.Gold)
            .clickable(interactionSource = interaction, indication = null) { onClick() }
            .padding(vertical = 14.dp, horizontal = 28.dp),
    ) {
        Text(title, style = LocketText.Button, color = LocketColors.OnGold)
    }
}

4. Camera Screen

The home screen is the square viewfinder + capture row + a 4-up history strip. Bind the viewfinder to CameraX PreviewView via AndroidView, but keep the square 40dp-rounded clip and all chrome in Compose.

@Composable
fun CameraScreen(historyTints: List<Brush>) {
    LocketWorld {
        Column(
            Modifier.fillMaxSize().padding(horizontal = 18.dp),
            verticalArrangement = Arrangement.spacedBy(8.dp),
        ) {
            Spacer(Modifier.height(8.dp))
            FriendPhoto(imageUrl = null, sender = "Ava", caption = "golden hour ✨")
            Row(
                Modifier.fillMaxWidth().padding(vertical = 14.dp),
                horizontalArrangement = Arrangement.SpaceAround,
                verticalAlignment = Alignment.CenterVertically,
            ) {
                SideControl(Icons.Filled.Cameraswitch)
                CaptureButton(onCapture = { /* capture + send to friends */ })
                SideControl(Icons.Filled.FlashOn)
            }
            Text("FROM YOUR FRIENDS", style = LocketText.Label, color = LocketColors.TextSecondary)
            HistoryGrid(historyTints.take(4))
        }
    }
}

@Composable
fun SideControl(icon: androidx.compose.ui.graphics.vector.ImageVector) {
    Box(
        Modifier.size(50.dp).clip(CircleShape).background(Color.White.copy(alpha = 0.6f)),
        contentAlignment = Alignment.Center,
    ) { Icon(icon, null, tint = LocketColors.TextPrimary, modifier = Modifier.size(22.dp)) }
}

5. The Widget (the real product surface — Glance)

The home-screen widget IS Locket. On Android use Jetpack Glance (GlanceAppWidget). Update it when a friend posts (push → LocketWidget().updateAll(context)); store the latest photo + sender in DataStore/an app-internal file.

import androidx.glance.GlanceModifier
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.cornerRadius
import androidx.glance.appwidget.provideContent
import androidx.glance.background
import androidx.glance.layout.*
import androidx.glance.text.Text
import androidx.glance.text.TextStyle as GlanceTextStyle

class LocketWidget : GlanceAppWidget() {
    override suspend fun provideGlance(context: Context, id: GlanceId) {
        provideContent {
            // load latest { sender, bitmap } from storage
            Box(
                GlanceModifier.fillMaxSize().cornerRadius(24.dp)
                    .background(androidx.glance.color.ColorProvider(LocketColors.Cream)),
                contentAlignment = Alignment.BottomStart,
            ) {
                // Image(provider = ImageProvider(bitmap), ...) full-bleed
                Text(
                    "from Ava",
                    style = GlanceTextStyle(color = androidx.glance.color.ColorProvider(Color.White)),
                    modifier = GlanceModifier.padding(8.dp),
                )
            }
        }
    }
}

Treat the “Add Locket to your Home Screen” step (AppWidgetManager.requestPinAppWidget) as the core onboarding activation — render an in-app 116dp rounded-24 widget mock with a sample photo + “from {name}” chip first.

6. Navigation

Locket keeps chrome minimal: a transparent top bar (icon button + friends pill + icon button) and a transparent 3-slot bottom strip over the warm world. Active is deep amber; no Material tint pill.

@Composable
fun LocketBottomBar(selected: Int, onSelect: (Int) -> Unit) {
    NavigationBar(
        containerColor = Color.Transparent,
        tonalElevation = 0.dp,
    ) {
        val items = listOf(
            "Camera"  to Icons.Filled.PhotoCamera,
            "History" to Icons.Filled.GridView,
            "You"     to Icons.Filled.Person,
        )
        items.forEachIndexed { i, (label, icon) ->
            NavigationBarItem(
                selected = selected == i,
                onClick = { onSelect(i) },
                icon = { Icon(icon, contentDescription = label, modifier = Modifier.size(24.dp)) },
                label = { Text(label, style = LocketText.Tab) },
                colors = NavigationBarItemDefaults.colors(
                    selectedIconColor   = LocketColors.Deep,
                    selectedTextColor   = LocketColors.Deep,
                    unselectedIconColor = LocketColors.TextTertiary,
                    unselectedTextColor = LocketColors.TextTertiary,
                    indicatorColor      = Color.Transparent, // no Material pill — Locket has none
                ),
            )
        }
    }
}

7. Motion

Locket motion is warm and gentle — soft springs, no harsh bounces. The capture is the one tactile peak.

Moment Compose recipe
Capture press animateFloatAsState scale → 0.92 spring(MediumBouncy); gold ring Animatable 0→1 tween(250) then snap 0
Send photo AnimatedVisibility exit slideOutVertically { -it } + fadeOut(320)
Incoming photo Crossfade(tween(280)) + gentle scale 1.03 → 1.0
History open shared-element-style scale + fade spring(dampingRatio = 0.7) 300ms
Reaction emoji offset up 40.dp + fadeOut over 500ms; coral heart pulse
Friends pill update avatars stagger scaleIn pop
Tab switch warm Crossfade(tween(220)); active eases to Deep
// Capture ring pulse — the signature
val pulse = remember { Animatable(0f) }
LaunchedEffect(captureTrigger) {
    pulse.snapTo(0f); pulse.animateTo(1f, tween(250)); pulse.snapTo(0f)
}

Haptics: LocalHapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) for the satisfying shutter on capture; HapticFeedbackConstants.CLOCK_TICK for reactions; HapticFeedbackConstants.CONFIRM on “Sent”. Keep them gentle and warm.

8. Icons

The closest first-party set is androidx.compose.material:material-icons-extended. Emoji reactions (❤️ 😂 🔥 😮) render as Text.

Purpose SF Symbol (iOS) Material Icon (Compose)
Camera (tab) camera / camera.fill Icons.Filled.PhotoCamera
History (tab) square.grid.2x2 Icons.Filled.GridView
You (tab) person / person.fill Icons.Filled.Person
Flip camera arrow.triangle.2.circlepath.camera Icons.Filled.Cameraswitch
Flash / effects bolt / sparkles Icons.Filled.FlashOn / Icons.Filled.AutoAwesome
Add friend person.badge.plus Icons.Filled.PersonAdd
Messages / chat bubble.left.fill Icons.Filled.ChatBubble
Send paperplane.fill Icons.Filled.Send
Reaction heart heart.fill Icons.Filled.Favorite (tint Coral)
See all chevron.right Icons.Filled.ChevronRight
Add widget plus.square.on.square Icons.Filled.Widgets
Settings gearshape.fill Icons.Filled.Settings
Close xmark Icons.Filled.Close

9. Minimum SDK & Accessibility Notes

  • minSdk 24 (Compose floor 21; Glance widget + CameraX comfortable at 24). compileSdk 34+, Compose BOM 2024.09+, Kotlin 2.0+, androidx.glance:glance-appwidget.
  • Edge-to-edge: call enableEdgeToEdge(); the warm cream world is light → dark-content system bars. Keep the system nav bar transparent so the warm gradient extends; the capture button must clear gesture-nav insets.
  • Warm-by-design: build ONLY the warm scheme; do not add a dark scheme and do not enable Material You dynamicColorScheme() — the cream-gold identity IS the brand and must hold regardless of system theme (including the Glance widget background, which stays #FFF7EC).
  • Camera: use CameraX in an AndroidView; the viewfinder is always SQUARE (1:1) clipped to a 40dp radius — never rectangular; request CAMERA permission only at first capture.
  • Font scaling: sp honors the user’s scale on sender names, captions, body. Pin layout-critical text (tab labels, uppercase labels, the capture-button chrome, history-cell dots) — derive from dp or fix fontScale = 1f for those nodes.
  • TalkBack: label the viewfinder “Photo from {sender}{, caption}”; the capture button “Take a photo, sends to your {n} friends”; history cells “Photo from {sender}, {time}”; the Glance widget exposes contentDescription with sender + “Locket photo”.
  • Touch targets: the capture button is 84.dp (generous — it’s the primary action); give 24.dp tab icons a 48.dp hit; side controls are 50.dp.
  • Contrast: #2C2014 on #FFF7EC passes WCAG AA; #3A2400 on #FFB02E passes; white on the dark warm chips passes — use #F08A1D (not #FFB02E) for small accent text on cream.
  • Reduce motion: when ANIMATOR_DURATION_SCALE == 0, replace the capture ring pulse + send slide with a quick Crossfade; keep it warm and gentle.
  • Privacy: photos go only to a tiny confirmed circle; never auto-capture; no public feed — keep the intimate model intact. The widget needs background data update on push only.
All mobile designs →

Brand names describe the visual style only; coderef is not affiliated with or endorsed by any brand listed. Licenses