coderef

Apple Wallet

Apple Wallet is a vault rendered as a stage. The canvas is true black (#000000) — not Apple's usual dark gray — because the screen needs to disappear so the cards can do the entire performance.
DownloadRaw
Apple Wallet

Build with the Apple Wallet look.

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

Palette

At a glance

Theme
dark
Typefaces
SF Pro Text, SF Pro Display, SF Mono
Sections
8
Length
4,196 words
Platforms
Android, Expo, SwiftUI, DESIGN.md

Add it to your project

Then prompt your agent

Design System · Apple Wallet

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

Apple Wallet (iOS) — Jetpack Compose Implementation Guide

Companion to DESIGN.md — the framework-neutral spec. This file ports Wallet’s visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, paste-ready @Composables, Modifiers, the signature card-stack physics, and haptics.

Why a Compose guide for an iOS-referenced app? The DESIGN.md tokens are platform-neutral. This file keeps the visual identity (Wallet’s true-black canvas, 80dp card-peek stack, 10dp universal radius, titanium Apple Card) while making everything idiomatic Android — a Box Z-stack instead of matchedGeometryEffect, SharedTransitionLayout for the expand, translucent Surface instead of .regularMaterial, sp/dp instead of pt. Wallet has no tab bar, so there is no NavigationBar here.

Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ for issuer card artwork / merchant logos. No color extraction, so no androidx.palette.

1. Color Tokens

// ui/theme/WalletColors.kt
import androidx.compose.ui.graphics.Color

object WalletColors {
    // Canvas & Surfaces
    val Canvas    = Color(0xFF000000) // true black — NOT system #1C1C1E, so cards float
    val Surface1  = Color(0xFF1C1C1E) // expanded-card supplementary section
    val Surface2  = Color(0xFF2C2C2E) // pressed states, inset rows, scrub track
    val Glass     = Color(0x1FFFFFFF) // rgba(255,255,255,0.12) — "+" button, toolbar buttons
    val Hairline  = Color(0xFF262629) // separator lines in detail sheets

    // Text
    val TextPrimary   = Color(0xFFFFFFFF)
    val TextSecondary = Color(0xFFA0A0A5)
    val TextTertiary  = Color(0xFF636368)

    // Apple Card titanium (the signature object)
    val TitaniumHi  = Color(0xFFE8E8EB) // top-left of 135° gradient
    val TitaniumMid = Color(0xFFA8A8AD) // center
    val TitaniumLo  = Color(0xFF3D3D3F) // bottom-right
    val LogoWhite   = Color(0xFFFFFFFF) //  glyph
    val ChipGold    = Color(0xFFC7AC73) // embossed chip
    val DailyCash   = Color(0xFFFF3B30) // Daily Cash $ chip

    // Semantic (HIG dark-mode tints)
    val SystemBlue  = Color(0xFF0A84FF) // "See All Transactions" links
    val Success     = Color(0xFF30D158) // payment successful
    val Warning     = Color(0xFFFF9F0A) // low transit balance
    val Error       = Color(0xFFFF453A) // declined card

    // Common card brand hints (issuer-controlled, recognizable)
    val ChaseBlue   = Color(0xFF1A2D4F)
    val AmexSilver  = Color(0xFFA7B0B7)
    val VisaNavy    = Color(0xFF1A1F71)
    val MastercardR = Color(0xFFEB001B)
    val MastercardO = Color(0xFFFF5F00)
}

Wire it into a Material 3 darkColorScheme. Wallet is dark mode — it ignores Light Mode entirely because the cards depend on the true-black canvas — so do not provide a light scheme.

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

private val WalletScheme = darkColorScheme(
    primary        = WalletColors.SystemBlue,  // HIG link blue is the only "accent"
    onPrimary      = WalletColors.TextPrimary,
    background     = WalletColors.Canvas,       // true black, not surfaceDim
    onBackground   = WalletColors.TextPrimary,
    surface        = WalletColors.Surface1,
    onSurface      = WalletColors.TextPrimary,
    surfaceVariant = WalletColors.Surface2,
    outline        = WalletColors.Hairline,
    error          = WalletColors.Error,
)

@Composable
fun WalletTheme(content: @Composable () -> Unit) =
    MaterialTheme(colorScheme = WalletScheme, typography = WalletTypography, content = content)

2. Typography

Wallet’s frame is pure SF Pro (Display ≥20pt, Text ≤17pt) — Apple’s documented switchover, applied rigidly. SF Pro is proprietary; drop the TTFs in res/font/ (lowercase, snake_case). Roboto is the closest free Android substitute. The masked card number is the only SF Mono usage — fall back to Roboto Mono / system monospace.

// ui/theme/WalletType.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 SFProDisplay = FontFamily(
    Font(R.font.sf_pro_display_regular,  FontWeight.Normal),   // 400
    Font(R.font.sf_pro_display_medium,   FontWeight.Medium),   // 500
    Font(R.font.sf_pro_display_semibold, FontWeight.SemiBold), // 600
    Font(R.font.sf_pro_display_bold,     FontWeight.Bold),     // 700
)
val SFProText = FontFamily(
    Font(R.font.sf_pro_text_regular,  FontWeight.Normal),
    Font(R.font.sf_pro_text_medium,   FontWeight.Medium),
    Font(R.font.sf_pro_text_semibold, FontWeight.SemiBold),
)
val SFMono = FontFamily(Font(R.font.sf_mono_medium, FontWeight.Medium)) // card last4 only

// Named ramp — mirrors DESIGN.md §3 exactly (pt → sp 1:1, same weights/tracking)
object WalletText {
    val Title          = TextStyle(SFProDisplay, fontWeight = FontWeight.Bold,     fontSize = 34.sp, lineHeight = 37.sp, letterSpacing = 0.36.sp)
    val SheetTitle     = TextStyle(SFProDisplay, fontWeight = FontWeight.Bold,     fontSize = 28.sp, lineHeight = 32.sp, letterSpacing = 0.35.sp)
    val SectionHeader  = TextStyle(SFProText,    fontWeight = FontWeight.SemiBold, fontSize = 13.sp, lineHeight = 16.sp, letterSpacing = (-0.08).sp) // UPPERCASE
    val CardIssuer     = TextStyle(SFProDisplay, fontWeight = FontWeight.SemiBold, fontSize = 17.sp, lineHeight = 19.sp)
    val CardLast4      = TextStyle(SFMono,       fontWeight = FontWeight.Medium,   fontSize = 17.sp, lineHeight = 17.sp, letterSpacing = 0.5.sp)
    val CardHolder     = TextStyle(SFProText,    fontWeight = FontWeight.Medium,   fontSize = 13.sp, lineHeight = 13.sp, letterSpacing = 0.3.sp)
    val BalanceHero    = TextStyle(SFProDisplay, fontWeight = FontWeight.Bold,     fontSize = 40.sp, lineHeight = 40.sp, letterSpacing = 0.4.sp)
    val Body           = TextStyle(SFProText,    fontWeight = FontWeight.Normal,   fontSize = 17.sp, lineHeight = 22.sp, letterSpacing = (-0.4).sp)
    val Footnote       = TextStyle(SFProText,    fontWeight = FontWeight.Normal,   fontSize = 13.sp, lineHeight = 17.sp, letterSpacing = (-0.08).sp)
    val Caption        = TextStyle(SFProText,    fontWeight = FontWeight.Normal,   fontSize = 11.sp, lineHeight = 13.sp, letterSpacing = 0.07.sp)
    val TxnAmount      = TextStyle(SFProText,    fontWeight = FontWeight.Medium,   fontSize = 17.sp, lineHeight = 17.sp)
    val DailyCash      = TextStyle(SFProDisplay, fontWeight = FontWeight.Bold,     fontSize = 22.sp, lineHeight = 22.sp)
    val Action         = TextStyle(SFProText,    fontWeight = FontWeight.SemiBold, fontSize = 17.sp, lineHeight = 17.sp, letterSpacing = (-0.4).sp)
}

// Map onto Material 3 slots so stock components inherit the system look
val WalletTypography = Typography(
    headlineLarge  = WalletText.Title,
    headlineSmall  = WalletText.SheetTitle,
    titleMedium    = WalletText.CardIssuer,
    bodyMedium     = WalletText.Body,
    labelSmall     = WalletText.Caption,
)

Wallet puts numerals everywhere money appears. Pin tabular figures on every balance/amount/last4 with TextStyle(fontFeatureSettings = "tnum") so dollar columns align — apply it to BalanceHero, TxnAmount, DailyCash, CardLast4.

3. Signature Components

Apple Card (the signature object)

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
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.draw.shadow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp

private val CardRadius = RoundedCornerShape(10.dp) // Wallet's universal radius — every card type

@Composable
fun AppleCardFace(
    cardholder: String,
    dailyCashBalance: String,
    modifier: Modifier = Modifier,
) {
    Box(
        modifier = modifier
            .fillMaxWidth()
            .height(220.dp)
            .shadow(24.dp, CardRadius, spotColor = Color.Black.copy(alpha = 0.5f)) // heavy — black canvas
            .clip(CardRadius)
            .background(
                Brush.linearGradient(            // 135° titanium fade
                    colors = listOf(WalletColors.TitaniumHi, WalletColors.TitaniumMid, WalletColors.TitaniumLo),
                    start = Offset(0f, 0f),
                    end = Offset(Float.POSITIVE_INFINITY, Float.POSITIVE_INFINITY),
                )
            ),
    ) {
        // 0.5dp inner top highlight — paper-thin glass edge
        Box(
            Modifier.fillMaxWidth().height(0.5.dp).align(Alignment.TopCenter)
                .background(Color.White.copy(alpha = 0.08f))
        )
        Column(Modifier.fillMaxSize().padding(16.dp)) {
            Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) {
                Box(Modifier.size(26.dp, 20.dp).clip(RoundedCornerShape(4.dp)).background(WalletColors.ChipGold))
                Spacer(Modifier.weight(1f))
                Text("", color = WalletColors.LogoWhite, style = WalletText.CardIssuer) //  glyph
            }
            Spacer(Modifier.weight(1f))
            Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Bottom) {
                Text(cardholder.uppercase(), style = WalletText.CardHolder, color = Color(0xFF1A1A1A))
                Spacer(Modifier.weight(1f))
                Box(
                    Modifier.size(28.dp).clip(androidx.compose.foundation.shape.CircleShape)
                        .background(WalletColors.DailyCash),
                    contentAlignment = Alignment.Center,
                ) { Text("$", color = Color.White, style = WalletText.CardIssuer) }
            }
        }
    }
}

Standard Credit/Debit Card

@Composable
fun CreditCardFace(
    issuer: String,
    last4: String,
    cardholder: String,
    networkLogoRes: Int,            // R.drawable.visa / mastercard / amex
    background: Brush,              // issuer-controlled, e.g. Chase Sapphire blue gradient
    modifier: Modifier = Modifier,
) {
    Box(
        modifier = modifier
            .fillMaxWidth()
            .height(220.dp)
            .shadow(24.dp, CardRadius, spotColor = Color.Black.copy(alpha = 0.5f))
            .clip(CardRadius)
            .background(background),
    ) {
        Box(
            Modifier.fillMaxWidth().height(0.5.dp).align(Alignment.TopCenter)
                .background(Color.White.copy(alpha = 0.08f))
        )
        Column(Modifier.fillMaxSize().padding(16.dp)) {
            Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) {
                Text(issuer, style = WalletText.CardIssuer, color = Color.White)
                Spacer(Modifier.weight(1f))
                androidx.compose.foundation.Image(
                    painter = androidx.compose.ui.res.painterResource(networkLogoRes),
                    contentDescription = null,
                    modifier = Modifier.height(22.dp),
                )
            }
            Spacer(Modifier.weight(1f))
            Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Bottom) {
                Text(cardholder.uppercase(), style = WalletText.CardHolder, color = Color.White.copy(alpha = 0.9f))
                Spacer(Modifier.weight(1f))
                Text("•••• $last4", style = WalletText.CardLast4, color = Color.White)
            }
        }
    }
}

Card Stack (the home screen — the hero interaction)

A vertical Z-stack: each card offsets index * 80.dp down so only an 80dp peek of each shows. Tap → that card animates to full size while the rest tuck down; supplementary content cross-fades in at +200ms. There is no recycler — a typical user has 4–8 cards, so a plain Column/Box in a verticalScroll is correct.

import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.fadeIn
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.*
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback

private const val PEEK = 80 // dp — Wallet's sacred stack interval

@Composable
fun CardStack(
    cards: List<@Composable (expanded: Boolean) -> Unit>,
    modifier: Modifier = Modifier,
) {
    var expanded by remember { mutableStateOf<Int?>(null) }
    val haptics = LocalHapticFeedback.current

    Box(
        modifier
            .fillMaxSize()
            .background(WalletColors.Canvas)
            .verticalScroll(rememberScrollState()),
    ) {
        cards.forEachIndexed { idx, card ->
            val isExpanded = expanded == idx
            val collapsed = expanded == null
            if (collapsed || isExpanded) {
                // Spring: response 0.5 / damping 0.8 → bouncy but contained
                val y by animateDpAsState(
                    targetValue = if (collapsed) (idx * PEEK).dp else 0.dp,
                    animationSpec = spring(dampingRatio = 0.8f, stiffness = Spring.StiffnessMediumLow),
                    label = "cardOffset$idx",
                )
                Column(
                    Modifier
                        .padding(horizontal = 16.dp) // cards never touch the screen edge
                        .offset(y = y)
                        .clickable(
                            interactionSource = remember { MutableInteractionSource() },
                            indication = null,
                        ) {
                            haptics.performHapticFeedback(HapticFeedbackType.LongPress) // ≈ .impact(.medium)
                            expanded = if (collapsed) idx else null
                        },
                ) {
                    card(isExpanded)
                    AnimatedVisibility(visible = isExpanded, enter = fadeIn(tween(250, delayMillis = 200))) {
                        ExpandedCardDetail() // transactions + balance + toolbar
                    }
                }
            }
        }
    }
}

Wallet Home (title + add button + stack)

Wallet has no tab bar — only the large title and a 32dp circular “+”. Render directly, no Scaffold bottom bar.

@Composable
fun WalletHome(cards: List<@Composable (Boolean) -> Unit>) {
    Column(
        Modifier.fillMaxSize().background(WalletColors.Canvas)
            .windowInsetsPadding(WindowInsets.systemBars),
    ) {
        Row(
            Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
            verticalAlignment = Alignment.CenterVertically,
        ) {
            Text("Wallet", style = WalletText.Title, color = WalletColors.TextPrimary)
            Spacer(Modifier.weight(1f))
            AddCardButton(onClick = { /* open Add Card sheet */ })
        }
        CardStack(cards)
    }
}

@Composable
fun AddCardButton(onClick: () -> Unit) {
    val haptics = LocalHapticFeedback.current
    Box(
        Modifier
            .size(48.dp)                       // 44dp+ tap target around a 32dp visual
            .clickable(
                interactionSource = remember { MutableInteractionSource() },
                indication = null,
            ) { haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove); onClick() },
        contentAlignment = Alignment.Center,
    ) {
        Box(
            Modifier.size(32.dp).clip(androidx.compose.foundation.shape.CircleShape)
                .background(WalletColors.Glass),
            contentAlignment = Alignment.Center,
        ) {
            Icon(Icons.Filled.Add, contentDescription = "Add card", tint = WalletColors.TextPrimary,
                modifier = Modifier.size(16.dp))
        }
    }
}

Transaction Row

import androidx.compose.material.icons.filled.ShoppingCart

@Composable
fun TransactionRow(
    merchant: String,
    date: String,
    amount: String,
    dailyCash: String?,
    modifier: Modifier = Modifier,
) {
    Box(modifier.fillMaxWidth().background(WalletColors.Surface1)) {
        Row(
            Modifier.fillMaxWidth().height(60.dp).padding(horizontal = 16.dp),
            verticalAlignment = Alignment.CenterVertically,
            horizontalArrangement = Arrangement.spacedBy(12.dp),
        ) {
            Box(
                Modifier.size(32.dp).clip(androidx.compose.foundation.shape.CircleShape)
                    .background(WalletColors.Surface2),
                contentAlignment = Alignment.Center,
            ) {
                Icon(Icons.Filled.ShoppingCart, contentDescription = null,
                    tint = WalletColors.TextPrimary, modifier = Modifier.size(14.dp))
            }
            Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
                Text(merchant, style = WalletText.Body, color = WalletColors.TextPrimary, maxLines = 1)
                Text(date, style = WalletText.Footnote, color = WalletColors.TextSecondary, maxLines = 1)
            }
            Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(2.dp)) {
                Text(amount, style = WalletText.TxnAmount, color = WalletColors.TextPrimary)
                if (dailyCash != null) {
                    Text("Daily Cash $dailyCash", style = WalletText.Caption, color = WalletColors.DailyCash)
                }
            }
        }
        Box(
            Modifier.align(Alignment.BottomStart).padding(start = 16.dp)
                .fillMaxWidth().height(0.5.dp).background(WalletColors.Hairline)
        )
    }
}

Action Toolbar (bottom of an expanded card)

iOS uses .regularMaterial; Android has no first-class live blur, so use a 92%-opaque true-black Surface. Three 44dp circular glass buttons.

import androidx.compose.material.icons.filled.CreditCard
import androidx.compose.material.icons.filled.MoreHoriz
import androidx.compose.material.icons.automirrored.filled.ListAlt
import androidx.compose.material3.Surface
import androidx.compose.ui.graphics.vector.ImageVector

@Composable
fun ActionToolbar() {
    Surface(color = WalletColors.Canvas.copy(alpha = 0.92f)) { // ≈ .regularMaterial over #000
        Box {
            Box(Modifier.fillMaxWidth().height(0.5.dp).background(WalletColors.Hairline))
            Row(
                Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 16.dp),
                horizontalArrangement = Arrangement.spacedBy(24.dp, Alignment.CenterHorizontally),
                verticalAlignment = Alignment.CenterVertically,
            ) {
                ToolbarButton(Icons.Filled.CreditCard, "Card details")
                ToolbarButton(Icons.AutoMirrored.Filled.ListAlt, "Transactions")
                ToolbarButton(Icons.Filled.MoreHoriz, "More")
            }
        }
    }
}

@Composable
private fun ToolbarButton(icon: ImageVector, label: String) {
    val haptics = LocalHapticFeedback.current
    Box(
        Modifier
            .size(44.dp)
            .clip(androidx.compose.foundation.shape.CircleShape)
            .background(WalletColors.Glass)
            .clickable(
                interactionSource = remember { MutableInteractionSource() },
                indication = null,
            ) { haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove) }, // ≈ .selection
        contentAlignment = Alignment.Center,
    ) { Icon(icon, contentDescription = label, tint = WalletColors.TextPrimary, modifier = Modifier.size(18.dp)) }
}

Pay Now Button (Apple Card payment flow)

@Composable
fun PayNowButton(onPayNow: () -> Unit, modifier: Modifier = Modifier) {
    val interaction = remember { MutableInteractionSource() }
    val pressed by androidx.compose.foundation.interaction.collectIsPressedAsState(interaction).let { it }
    val haptics = LocalHapticFeedback.current
    Box(
        modifier
            .fillMaxWidth()
            .padding(horizontal = 16.dp)
            .height(54.dp)
            .clip(RoundedCornerShape(14.dp))
            .background(if (pressed) Color(0xFFE5E5E5) else Color.White)
            .clickable(interaction, indication = null) {
                haptics.performHapticFeedback(HapticFeedbackType.LongPress)
                onPayNow()
            },
        contentAlignment = Alignment.Center,
    ) { Text("Pay Now", style = WalletText.Action, color = WalletColors.Canvas) }
}

4. Apple Pay Invocation (the most distinctive interaction)

Wallet has no color extraction; its signature dynamic moment is the Apple Pay handoff: the card slams to center, the screen turns true black, a biometric glyph appears, then a success check with .notificationOccurred(.success). On Android, the OS double-press is replaced by your in-app trigger; SharedTransitionLayout (Compose 1.7+) carries the card from the stack into the overlay.

import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Fingerprint
import kotlinx.coroutines.delay

@Composable
fun ApplePayOverlay(cardArtworkUrl: String, onAuthenticated: () -> Unit) {
    var authenticated by remember { mutableStateOf(false) }
    val haptics = LocalHapticFeedback.current

    LaunchedEffect(authenticated) {
        if (authenticated) {
            haptics.performHapticFeedback(HapticFeedbackType.LongPress) // ≈ .notification(.success)
            delay(700)
            onAuthenticated()
        }
    }

    Box(
        Modifier.fillMaxSize().background(WalletColors.Canvas), // screen turns true black
        contentAlignment = Alignment.TopCenter,
    ) {
        Column(
            Modifier.fillMaxSize().padding(top = 64.dp),
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.spacedBy(32.dp),
        ) {
            coil.compose.AsyncImage(
                model = cardArtworkUrl,
                contentDescription = "Apple Card",
                modifier = Modifier
                    .fillMaxWidth(0.92f)
                    .height(220.dp)
                    .shadow(24.dp, CardRadius, spotColor = Color.Black.copy(alpha = 0.5f))
                    .clip(CardRadius),
            )
            if (!authenticated) {
                Column(
                    horizontalAlignment = Alignment.CenterHorizontally,
                    verticalArrangement = Arrangement.spacedBy(12.dp),
                ) {
                    Icon(Icons.Filled.Fingerprint, contentDescription = null,
                        tint = WalletColors.TextPrimary, modifier = Modifier.size(64.dp))
                    Text("Confirm with biometrics", style = WalletText.Body, color = WalletColors.TextPrimary)
                }
                LaunchedEffect(Unit) { delay(800); authenticated = true } // demo: BiometricPrompt in prod
            } else {
                Icon(Icons.Filled.CheckCircle, contentDescription = "Confirmed",
                    tint = WalletColors.Success, modifier = Modifier.size(64.dp))
            }
            Spacer(Modifier.weight(1f))
            Text("Hold near reader", style = WalletText.SheetTitle, color = WalletColors.TextPrimary,
                modifier = Modifier.padding(bottom = 48.dp))
        }
    }
}

In production, replace the demo delay with androidx.biometric.BiometricPrompt; keep the #000000 background and WalletColors.Success check so it reads as the system Apple Pay sheet.

5. Navigation

Wallet has no navigation chrome — no NavigationBar, no rail, no TopAppBar. The entire app is the card stack and its expanded detail; the only persistent affordances are the “Wallet” title and the 32dp “+” button (see §3 WalletHome). Do not add a Scaffold bottomBar.

Detail sheets (Card Details, Settings) rise as Material 3 ModalBottomSheet with a 14dp top radius (matching iOS sheet corners) over the true-black canvas. Use a Surface(color = WalletColors.Surface1) for grouped lists, with Hairline separators inset 16dp from the leading edge. Where iOS uses .regularMaterial, use a translucent Surface (WalletColors.Canvas.copy(alpha = 0.92f)) — Android has no live blur.

@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
@Composable
fun CardDetailSheet(onDismiss: () -> Unit, content: @Composable () -> Unit) {
    androidx.compose.material3.ModalBottomSheet(
        onDismissRequest = onDismiss,
        containerColor = WalletColors.Surface1,
        shape = RoundedCornerShape(topStart = 14.dp, topEnd = 14.dp),
    ) { content() }
}

6. Motion

Moment Compose recipe
Card tap → expand animateDpAsState peek-offset → 0 with spring(dampingRatio = 0.8f, stiffness = StiffnessMediumLow) (≈ response 0.5); HapticFeedbackType.LongPress (.impact medium)
Card collapse same spring reversed; release haptic HapticFeedbackType.TextHandleMove (≈ .impact soft)
Supplementary content reveal AnimatedVisibility + fadeIn(tween(250, delayMillis = 200)) below the expanded card
Daily Cash chip pulse rememberInfiniteTransition scale 1.0 → 1.04 → 1.0 over 1200ms, only on the Apple Card face
Transit balance update Animatable scrub old → new over 800ms with tween(easing = FastOutSlowInEasing)
Boarding-pass flip animateFloatAsState rotationY 0 → 180 over 600ms, graphicsLayer { rotationY = … }
Apple Pay success HapticFeedbackType.LongPress then cross-fade to the green check
// Daily Cash chip breathing pulse — Apple Card only
@Composable
fun DailyCashChip(amount: String) {
    val t = rememberInfiniteTransition(label = "dailyCash")
    val scale by t.animateFloat(
        initialValue = 1f, targetValue = 1.04f,
        animationSpec = infiniteRepeatable(tween(1200), RepeatMode.Reverse),
        label = "pulse",
    )
    Box(
        Modifier
            .size(28.dp)
            .graphicsLayer { scaleX = scale; scaleY = scale }
            .clip(androidx.compose.foundation.shape.CircleShape)
            .background(WalletColors.DailyCash),
        contentAlignment = Alignment.Center,
    ) { Text("$", color = Color.White, style = WalletText.CardIssuer) }
}

For the mini-bar → expanded card transition prefer SharedTransitionLayout + Modifier.sharedElement() on the card artwork (Compose 1.7+). Haptics: prefer LocalHapticFeedback; for richer control use LocalView.current.performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK) (API 30+) or a Vibrator VibrationEffect.createOneShot(10, ...) to approximate iOS’s .soft impact. Honor Reduce Motion: when Settings.Global.ANIMATOR_DURATION_SCALE == 0f, skip the spring bounce and use a 0.25s linear cross-fade for the card expand.

7. Icons

Wallet uses a handful of SF Symbols; the closest first-party set is androidx.compose.material:material-icons-extended. The Apple logo glyph and brand network marks (Visa/Mastercard/AmEx) have no Material equivalent — ship them as vector drawables and load via ImageVector.vectorResource(R.drawable.…).

Purpose SF Symbol (iOS) Material Icon (Compose)
Add card “+” plus Icons.Filled.Add
Apple logo / applelogo custom vector drawable (no Material glyph)
Card details creditcard.fill Icons.Filled.CreditCard
Transactions list list.bullet.rectangle Icons.AutoMirrored.Filled.ListAlt
More menu ellipsis Icons.Filled.MoreHoriz
Disclosure chevron.right Icons.AutoMirrored.Filled.KeyboardArrowRight
Biometric confirm faceid Icons.Filled.Fingerprint (or custom Face Unlock glyph)
Success check checkmark.circle.fill Icons.Filled.CheckCircle
Transit NFC waves wave.3.right Icons.Filled.Contactless
Merchant default cart.fill / bag.fill Icons.Filled.ShoppingCart
Card network marks (brand assets) custom vector drawables (Visa/Mastercard/AmEx)

8. Minimum SDK & Accessibility Notes

  • minSdk 24 (Compose floor is 21; modern motion + SharedTransitionLayout comfortable at 24). compileSdk 34+, Compose BOM 2024.09+, Kotlin 2.0+.
  • Edge-to-edge: call enableEdgeToEdge() in the Activity; the true-black canvas wants WindowCompat light-content (white) system-bar icons. Apply Modifier.windowInsetsPadding(WindowInsets.systemBars) so the title clears the status bar and the last card keeps 24dp clearance above the gesture-nav indicator.
  • Font scaling: sp honors the user’s font scale automatically — keep it on the Wallet title, detail body, transaction rows. The card face is artwork, not text — pin issuer name, last4, holder name, and balance hero so the card never reflows: wrap card composables in CompositionLocalProvider(LocalDensity provides Density(LocalDensity.current.density, fontScale = 1f)).
  • TalkBack: each card is one accessibility element — Modifier.semantics(mergeDescendants = true) { contentDescription = "Apple Card, ending in 4521, \$1,284.20 balance, collapsed" }; update to “expanded” on tap. Mark the toolbar glyphs and “+” as separate buttons with their own labels.
  • Touch targets: Material guidance is 48.dp minimum. The 80dp card peek and 54dp Pay Now clear it; the 32dp “+” and 18dp toolbar glyphs are wrapped in 44–48dp hit areas via padding (see §3).
  • Contrast: white on the titanium gradient passes WCAG AA at the #E8E8EB highlight but is borderline over the #3D3D3F shadow end — paint a 30%-black scrim behind any text positioned over the dark end of the gradient. #A0A0A5 on #1C1C1E passes AA at 14sp+; validate the 11sp caption / Daily Cash labels with a contrast checker and bump toward #B0B0B5 if targeting strict compliance.
  • Dynamic color: do not enable Material You dynamicDarkColorScheme() — Wallet’s identity requires the fixed #000000 canvas and HIG system-blue links regardless of wallpaper. (Material You is right for Google/Material-first apps; Wallet is a strong-brand exception.) Wallet also ignores Light Mode entirely — stay on the dark scheme even when the system is Light, because the floating-card effect depends on true black.
All mobile designs →

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