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
BoxZ-stack instead ofmatchedGeometryEffect,SharedTransitionLayoutfor the expand, translucentSurfaceinstead of.regularMaterial,sp/dpinstead ofpt. Wallet has no tab bar, so there is noNavigationBarhere.
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 +
SharedTransitionLayoutcomfortable at 24).compileSdk 34+, Compose BOM2024.09+, Kotlin2.0+. - Edge-to-edge: call
enableEdgeToEdge()in theActivity; the true-black canvas wantsWindowCompatlight-content (white) system-bar icons. ApplyModifier.windowInsetsPadding(WindowInsets.systemBars)so the title clears the status bar and the last card keeps 24dp clearance above the gesture-nav indicator. - Font scaling:
sphonors 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 inCompositionLocalProvider(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
#E8E8EBhighlight but is borderline over the#3D3D3Fshadow end — paint a 30%-black scrim behind any text positioned over the dark end of the gradient.#A0A0A5on#1C1C1Epasses AA at 14sp+; validate the 11sp caption / Daily Cash labels with a contrast checker and bump toward#B0B0B5if targeting strict compliance. - Dynamic color: do not enable Material You
dynamicDarkColorScheme()— Wallet’s identity requires the fixed#000000canvas 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.