Wise (iOS) — Jetpack Compose Implementation Guide
Companion to DESIGN.md — the framework-neutral spec. This file ports Wise’s visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, paste-ready @Composables, Modifiers, 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 (Wise’s bright canvas, forest structural ink, bright-green action, the fee-transparency card, the multi-currency stack) while making everything idiomatic Android —
NavigationBarinstead of a UITabBar,sp/dpinstead ofpt,AnimatedContentfor the number roll-up.
Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ for images.
1. Color Tokens
// ui/theme/WiseColors.kt
import androidx.compose.ui.graphics.Color
object WiseColors {
// Canvas & Surfaces
val Canvas = Color(0xFFFFFFFF)
val Surface = Color(0xFFF7F7F7)
val SurfaceSunken = Color(0xFFEFEFEF)
val Divider = Color(0xFFE5E5E5)
val Border = Color(0xFFD2D2D2)
// Text
val TextPrimary = Color(0xFF0E0F0C)
val TextSecondary = Color(0xFF6B6F66)
val TextTertiary = Color(0xFF9A9D95)
// Brand
val Bright = Color(0xFF9FE870)
val BrightPressed = Color(0xFF8AD45C)
val BrightTint = Color(0xFFEAF9DC)
val Forest = Color(0xFF163300)
val ForestHover = Color(0xFF0E2200)
// Semantic
val Success = Color(0xFF2F8F4E)
val Pending = Color(0xFFB5781E)
val Error = Color(0xFFD4332B)
}
Wire it into a Material 3 lightColorScheme. Wise is light-first; provide a dark scheme only if you target system dark mode (keep Bright unchanged with Forest content).
// ui/theme/Theme.kt
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.MaterialTheme
private val WiseScheme = lightColorScheme(
primary = WiseColors.Bright,
onPrimary = WiseColors.Forest, // forest on bright green, never white
primaryContainer = WiseColors.BrightTint,
secondary = WiseColors.Forest,
onSecondary = Color.White,
background = WiseColors.Canvas,
onBackground = WiseColors.TextPrimary,
surface = WiseColors.Canvas,
onSurface = WiseColors.TextPrimary,
surfaceVariant = WiseColors.Surface,
outline = WiseColors.Border,
outlineVariant = WiseColors.Divider,
error = WiseColors.Error,
)
@Composable
fun WiseTheme(content: @Composable () -> Unit) =
MaterialTheme(colorScheme = WiseScheme, typography = WiseTypography, content = content)
2. Typography
Wise Sans is proprietary. Drop the TTFs in res/font/ (lowercase, snake_case) and build a FontFamily. Fall back to the system font (Roboto); Inter is the closest substitute if bundled.
// ui/theme/WiseType.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 WiseSans = FontFamily(
Font(R.font.wise_sans_regular, FontWeight.Normal), // 400
Font(R.font.wise_sans_semibold, FontWeight.SemiBold), // 600
Font(R.font.wise_sans_extrabold, FontWeight.ExtraBold), // 800
)
private const val TNUM = "tnum"
// Named ramp — mirrors DESIGN.md §3 exactly (pt → sp 1:1)
object WiseText {
val Balance = TextStyle(WiseSans, fontWeight = FontWeight.ExtraBold, fontSize = 40.sp, lineHeight = 42.sp, letterSpacing = (-0.6).sp, fontFeatureSettings = TNUM)
val TitleLarge = TextStyle(WiseSans, fontWeight = FontWeight.ExtraBold, fontSize = 32.sp, lineHeight = 35.sp, letterSpacing = (-0.5).sp)
val Section = TextStyle(WiseSans, fontWeight = FontWeight.ExtraBold, fontSize = 22.sp, lineHeight = 26.sp, letterSpacing = (-0.3).sp)
val Currency = TextStyle(WiseSans, fontWeight = FontWeight.ExtraBold, fontSize = 22.sp, lineHeight = 24.sp, letterSpacing = (-0.2).sp, fontFeatureSettings = TNUM)
val Subsection = TextStyle(WiseSans, fontWeight = FontWeight.SemiBold, fontSize = 18.sp, lineHeight = 23.sp, letterSpacing = (-0.1).sp)
val Amount = TextStyle(WiseSans, fontWeight = FontWeight.SemiBold, fontSize = 16.sp, lineHeight = 19.sp, fontFeatureSettings = TNUM)
val Title = TextStyle(WiseSans, fontWeight = FontWeight.SemiBold, fontSize = 16.sp, lineHeight = 21.sp)
val Body = TextStyle(WiseSans, fontWeight = FontWeight.Normal, fontSize = 15.sp, lineHeight = 22.sp)
val Button = TextStyle(WiseSans, fontWeight = FontWeight.ExtraBold, fontSize = 16.sp, lineHeight = 20.sp)
val Meta = TextStyle(WiseSans, fontWeight = FontWeight.Normal, fontSize = 13.sp, lineHeight = 17.sp)
val LabelUpper = TextStyle(WiseSans, fontWeight = FontWeight.ExtraBold, fontSize = 11.sp, lineHeight = 13.sp, letterSpacing = 0.6.sp)
val Tab = TextStyle(WiseSans, fontWeight = FontWeight.SemiBold, fontSize = 11.sp, lineHeight = 13.sp, letterSpacing = 0.1.sp)
val Caption = TextStyle(WiseSans, fontWeight = FontWeight.Normal, fontSize = 11.sp, lineHeight = 14.sp)
}
// Map onto Material 3 slots so stock components inherit the brand
val WiseTypography = Typography(
headlineLarge = WiseText.TitleLarge,
headlineSmall = WiseText.Section,
titleMedium = WiseText.Title,
bodyMedium = WiseText.Body,
labelSmall = WiseText.Tab,
)
3. Signature Components
Primary CTA (Bright Green)
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.ui.draw.scale
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
@Composable
fun WisePrimaryButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.98f else 1f, label = "ctaScale")
val haptics = LocalHapticFeedback.current
Box(
modifier = modifier
.scale(scale)
.clip(RoundedCornerShape(16.dp))
.background(if (pressed) WiseColors.BrightPressed else WiseColors.Bright)
.heightIn(min = 52.dp)
.clickable(interaction, indication = null) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress) // ~iOS light impact
onClick()
},
contentAlignment = Alignment.Center,
) {
Text(text, style = WiseText.Button, color = WiseColors.Forest) // forest, never white
}
}
@Composable
fun WiseForestButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
Box(
modifier
.clip(RoundedCornerShape(16.dp))
.background(if (pressed) WiseColors.ForestHover else WiseColors.Forest)
.heightIn(min = 52.dp)
.clickable(interaction, indication = null, onClick = onClick),
contentAlignment = Alignment.Center,
) { Text(text, style = WiseText.Button, color = Color.White) }
}
Forest Account Hero (number roll-up)
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
@Composable
fun ForestAccountHero(total: String, modifier: Modifier = Modifier) {
Column(
modifier
.clip(RoundedCornerShape(20.dp))
.background(WiseColors.ForestHover)
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text("TOTAL BALANCE", style = WiseText.LabelUpper, color = WiseColors.Bright)
AnimatedContent(
targetState = total,
transitionSpec = {
(slideInVertically { it / 2 } togetherWith slideOutVertically { -it / 2 })
},
label = "balanceRoll",
) { value ->
Text(value, style = WiseText.Balance, color = Color.White)
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.Add, contentDescription = null, tint = WiseColors.Bright, modifier = Modifier.size(16.dp))
Text("Add money", style = WiseText.Title, color = WiseColors.Bright)
}
}
}
Currency Balance Row
@Composable
fun CurrencyRow(
flag: String,
code: String,
name: String,
balance: String,
modifier: Modifier = Modifier,
) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
Column {
Row(
modifier
.fillMaxWidth()
.background(if (pressed) WiseColors.Surface else WiseColors.Canvas)
.clickable(interaction, indication = null) {}
.height(64.dp)
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
Modifier.size(32.dp).clip(CircleShape).background(WiseColors.Surface),
contentAlignment = Alignment.Center,
) { Text(flag, fontSize = 18.sp) }
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(code, style = WiseText.Title, color = WiseColors.TextPrimary)
Text(name, style = WiseText.Meta, color = WiseColors.TextSecondary)
}
Text(balance, style = WiseText.Currency, color = WiseColors.TextPrimary)
}
Divider(color = WiseColors.Divider, thickness = 1.dp)
}
}
Fee-Transparency Card (signature)
data class FeeLine(val label: String, val value: String, val emphasized: Boolean = false)
@Composable
fun FeeBreakdownCard(lines: List<FeeLine>, modifier: Modifier = Modifier) {
Column(
modifier
.clip(RoundedCornerShape(16.dp))
.background(WiseColors.Canvas)
.border(1.dp, WiseColors.Divider, RoundedCornerShape(16.dp))
.padding(20.dp),
) {
lines.forEachIndexed { i, line ->
Row(
Modifier
.fillMaxWidth()
.then(if (line.emphasized) Modifier.clip(RoundedCornerShape(10.dp)).background(WiseColors.BrightTint) else Modifier)
.padding(horizontal = if (line.emphasized) 12.dp else 0.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
line.label,
style = if (line.emphasized) WiseText.Title else WiseText.Body,
color = if (line.emphasized) WiseColors.Forest else WiseColors.TextSecondary,
)
Text(
line.value,
style = if (line.emphasized) WiseText.Subsection else WiseText.Amount,
color = if (line.emphasized) WiseColors.Forest else WiseColors.TextPrimary,
)
}
if (i < lines.lastIndex && !line.emphasized) {
Divider(color = WiseColors.Divider, thickness = 1.dp)
}
}
}
}
Send-Money Stepper (signature)
@Composable
fun SendStepper(steps: List<String>, current: Int, modifier: Modifier = Modifier) {
Row(modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
steps.forEachIndexed { i, _ ->
val done = i < current
val active = i == current
Box(
Modifier
.size(28.dp)
.clip(CircleShape)
.background(if (done) WiseColors.Bright else if (active) WiseColors.Forest else WiseColors.Divider),
contentAlignment = Alignment.Center,
) {
if (done) {
Icon(Icons.Filled.Check, contentDescription = null, tint = WiseColors.Forest, modifier = Modifier.size(14.dp))
} else {
Text(
"${i + 1}",
style = WiseText.Title.copy(fontSize = 13.sp),
color = if (active) Color.White else WiseColors.TextSecondary,
)
}
}
if (i < steps.lastIndex) {
Box(
Modifier
.weight(1f)
.height(2.dp)
.background(if (done) WiseColors.Forest else WiseColors.Divider),
)
}
}
}
}
4. Number Roll-Up Helper
// Animate a numeric balance with AnimatedContent + vertical slide; ~500ms.
import androidx.compose.animation.core.tween
@Composable
fun RollingBalance(value: String) {
AnimatedContent(
targetState = value,
transitionSpec = {
slideInVertically(tween(500)) { it } togetherWith slideOutVertically(tween(500)) { -it }
},
label = "rollingBalance",
) { Text(it, style = WiseText.Balance, color = Color.White) }
}
5. Bottom Navigation (Tab Bar)
Use Material 3 NavigationBar. Wise’s iOS tab bar is opaque white with a hairline top border. Active tint is forest; optionally mark the active tab with a bright-green dot.
@Composable
fun WiseBottomBar(selected: Int, onSelect: (Int) -> Unit) {
NavigationBar(
containerColor = WiseColors.Canvas,
tonalElevation = 0.dp,
) {
val items = listOf(
"Home" to Icons.Filled.Home,
"Card" to Icons.Filled.CreditCard,
"Recipients" to Icons.Filled.People,
"Payments" to Icons.Filled.SwapHoriz,
"Account" to Icons.Filled.AccountCircle,
)
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 = WiseText.Tab) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = WiseColors.Forest,
selectedTextColor = WiseColors.Forest,
unselectedIconColor = WiseColors.TextSecondary,
unselectedTextColor = WiseColors.TextSecondary,
indicatorColor = WiseColors.BrightTint,
),
)
}
}
}
Place a hairline Divider(color = WiseColors.Divider, thickness = 0.5.dp) directly above the bar; the send flow’s sticky “Continue” sits in the Scaffold bottomBar slot above it.
6. Motion
| Moment | Compose recipe |
|---|---|
| Number roll-up | AnimatedContent + slideInVertically/slideOutVertically tween(500) on the balance string |
| CTA press | animateFloatAsState scale 1→0.98 tween(150) + BrightPressed fill; HapticFeedbackType.LongPress |
| Stepper advance | animate dot background + rail color with animateColorAsState(tween(200)) on current |
| Rate ticker pulse | rememberInfiniteTransition driving a small dot’s alpha 0.3↔1, tween(1000) reverse |
| Fee card reveal | stagger rows with AnimatedVisibility + fadeIn + per-row delayMillis = i * 40 |
| Segmented thumb | animateDpAsState pill offset, tween(220) ease-in-out |
Haptics: prefer LocalHapticFeedback. For a softer iOS-like confirmation use LocalView.current.performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK) (API 30+) or a Vibrator VibrationEffect.createOneShot(10, …).
7. Icons
Closest first-party set is androidx.compose.material:material-icons-extended. For exact parity, export Wise glyphs as vector drawables and load via ImageVector.vectorResource(...).
| Purpose | SF Symbol (iOS) | Material Icon (Compose) |
|---|---|---|
| Home (tab) | house.fill |
Icons.Filled.Home |
| Card (tab) | creditcard.fill |
Icons.Filled.CreditCard |
| Recipients (tab) | person.2.fill |
Icons.Filled.People |
| Payments (tab) | arrow.left.arrow.right |
Icons.Filled.SwapHoriz |
| Account (tab) | person.crop.circle.fill |
Icons.Filled.AccountCircle |
| Send | paperplane.fill |
Icons.Filled.Send |
| Add money | plus |
Icons.Filled.Add |
| Request | arrow.down.left |
Icons.Filled.SouthWest |
| Convert | arrow.left.arrow.right.circle |
Icons.Filled.SwapHorizontalCircle |
| Search | magnifyingglass |
Icons.Filled.Search |
| Notifications | bell.fill |
Icons.Filled.Notifications |
| Statement | doc.text |
Icons.Filled.Description |
| Step complete | checkmark |
Icons.Filled.Check |
| Money in | arrow.down.left |
Icons.Filled.SouthWest |
| Rate up / down | arrow.up / arrow.down |
Icons.Filled.ArrowUpward / ArrowDownward |
8. Minimum SDK & Accessibility Notes
- minSdk 24 (Compose floor is 21;
AnimatedContentand modern motion are comfortable at 24).compileSdk 34+, Compose BOM2024.09+, Kotlin2.0+. - Edge-to-edge: call
enableEdgeToEdge(); the bright canvas wants dark-content system bars (WindowCompat). ApplyScaffoldinsets so the send flow’s sticky bar clears gesture nav. - Font scaling:
sphonors the user’s scale — keep it on titles, body, amounts. Pin the hero total, tab labels, uppercase labels, and currency-row figures with a fixedDensitywrapper for column alignment. - TalkBack: announce the total with currency; read each fee line as “label, value” and mark the “Recipient gets” row with
Modifier.semantics { heading() }; announce stepper state (“Step 2 of 4, Amount”). - Touch targets: Material minimum is 48.dp. The 52.dp CTA is clear; give the 24.dp tab glyphs and 28.dp stepper dots a 48.dp hit area via padding.
- Contrast: forest
#163300on bright green#9FE870passes WCAG AAA — never white on bright green.#6B6F66on white passes AA at 14sp+; bump 11sp labels toward#5A5E55for strict compliance. - Reduced motion: honor
Settings.Global.ANIMATOR_DURATION_SCALE == 0by setting the balance instantly and crossfading the stepper instead of sliding. - Dynamic color: do not enable Material You
dynamicLightColorScheme()— Wise’s brand requires the fixed forest#163300and bright green#9FE870regardless of wallpaper.