Jira (iOS) — Jetpack Compose Implementation Guide
Companion to DESIGN.md — the framework-neutral spec. This file ports Jira’s visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, paste-ready @Composables, the board + drag, 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 (Atlassian’s neutral grays, the single Jira Blue action color, semantic status lozenges, the issue card, the horizontal board) while making everything idiomatic Android —
NavigationBarinstead of a UITabBar,DropdownMenuinstead of an iOS popover,LazyRow/LazyColumnfor the board,sp/dpinstead ofpt.
Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ for avatars. No Material You dynamic color — Jira’s brand blue and semantic palette are fixed; a full dark scheme is provided.
1. Color Tokens
// ui/theme/JiraColors.kt
import androidx.compose.ui.graphics.Color
object JiraColors {
// Brand (interactive)
val Blue = Color(0xFF0052CC)
val BlueBold = Color(0xFF1868DB) // interactive blue on dark
val BluePressed = Color(0xFF09326C)
val Link = Color(0xFF0C66E4)
val LinkDark = Color(0xFF579DFF)
val InkNavy = Color(0xFF172B4D)
// Canvas & Surfaces (Light)
val Canvas = Color(0xFFFFFFFF)
val Sunken = Color(0xFFF7F8F9)
val SurfaceHover = Color(0xFFF1F2F4)
val Divider = Color(0x24091E42) // ≈14% navy
val Border = Color(0x0F091E42) // ≈6% navy
// Canvas & Surfaces (Dark) — Atlassian neutral, NOT pure black
val DarkCanvas = Color(0xFF1D2125)
val DarkSurface1 = Color(0xFF22272B)
val DarkSurface2 = Color(0xFF2C333A)
val DarkSurface3 = Color(0xFF38414A)
val DarkDivider = Color(0xFF38414A)
// Text
val TextPrimary = Color(0xFF172B4D) // Atlassian Navy — NOT pure black
val TextSubtle = Color(0xFF44546F)
val TextSubtlest = Color(0xFF626F86)
val DarkTextPrimary = Color(0xFFC7D1DB)
val DarkTextSubtle = Color(0xFF9FADBC)
val DarkTextSubtlest = Color(0xFF738496)
// Issue-type accents
val StoryGreen = Color(0xFF1F845A)
val BugRed = Color(0xFFC9372C)
val TaskBlue = Color(0xFF1868DB)
val EpicPurple = Color(0xFF8270DB)
val SubtaskGray = Color(0xFF5E6C84)
// Semantic status — light fill/text
val NeutralBg = Color(0xFFDCDFE4); val NeutralTx = Color(0xFF44546F)
val InfoBg = Color(0xFFE9F2FF); val InfoTx = Color(0xFF0055CC)
val SuccessBg = Color(0xFFDCFFF1); val SuccessTx = Color(0xFF216E4E)
val WarnBg = Color(0xFFFFF7D6); val WarnTx = Color(0xFF7F5F01)
val DangerBg = Color(0xFFFFECEB); val DangerTx = Color(0xFFAE2A19)
val EpicBg = Color(0xFFF3F0FF); val EpicTx = Color(0xFF5E4DB2)
// Semantic status — dark fill/text
val NeutralBgD = Color(0xFF38414A); val NeutralTxD = Color(0xFF9FADBC)
val InfoBgD = Color(0xFF092957); val InfoTxD = Color(0xFF8FB8F6)
val SuccessBgD = Color(0xFF1C3329); val SuccessTxD = Color(0xFF7EE2B8)
val DangerBgD = Color(0xFF5D1F1A); val DangerTxD = Color(0xFFFD9891)
// Avatar / label palette
val AvPurple = Color(0xFF5E4DB2)
val AvTeal = Color(0xFF1D7F8C)
val AvMagenta = Color(0xFF943D73)
}
Wire it into both schemes. Jira is light-first; the dark scheme uses Atlassian’s #1D2125 charcoal, never true black, and brightens interactive blue.
// ui/theme/Theme.kt
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
private val JiraLight = lightColorScheme(
primary = JiraColors.Blue,
onPrimary = Color.White,
background = JiraColors.Sunken,
onBackground = JiraColors.TextPrimary,
surface = JiraColors.Canvas,
onSurface = JiraColors.TextPrimary,
surfaceVariant = JiraColors.SurfaceHover,
outline = JiraColors.Divider,
error = JiraColors.DangerTx,
)
private val JiraDark = darkColorScheme(
primary = JiraColors.BlueBold,
onPrimary = Color.White,
background = JiraColors.DarkCanvas,
onBackground = JiraColors.DarkTextPrimary,
surface = JiraColors.DarkSurface1,
onSurface = JiraColors.DarkTextPrimary,
surfaceVariant = JiraColors.DarkSurface2,
outline = JiraColors.DarkDivider,
error = JiraColors.DangerTxD,
)
@Composable
fun JiraTheme(
dark: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) = MaterialTheme(
colorScheme = if (dark) JiraDark else JiraLight,
typography = JiraTypography,
content = content,
)
2. Typography
Jira’s family is Atlassian Sans (+ Atlassian Mono for code). Drop the TTFs in res/font/, or substitute Inter (SIL OFL). Body 400, card summaries 500, field/subsection labels 600, titles & lozenges 700. Tabular figures via TextStyle(fontFeatureSettings = "tnum").
// ui/theme/JiraType.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 AtlassianSans = FontFamily(
Font(R.font.atlassian_sans_regular, FontWeight.Normal),
Font(R.font.atlassian_sans_medium, FontWeight.Medium),
Font(R.font.atlassian_sans_semibold, FontWeight.SemiBold),
Font(R.font.atlassian_sans_bold, FontWeight.Bold),
)
val AtlassianMono = FontFamily(Font(R.font.atlassian_mono_regular, FontWeight.Normal))
private const val TNUM = "tnum"
object JiraText {
val ScreenTitle = TextStyle(AtlassianSans, fontWeight = FontWeight.Bold, fontSize = 30.sp, lineHeight = 36.sp, letterSpacing = (-0.4).sp)
val BoardTitle = TextStyle(AtlassianSans, fontWeight = FontWeight.Bold, fontSize = 22.sp, lineHeight = 28.sp, letterSpacing = (-0.3).sp)
val IssueTitle = TextStyle(AtlassianSans, fontWeight = FontWeight.SemiBold, fontSize = 20.sp, lineHeight = 26.sp, letterSpacing = (-0.2).sp)
val Subsection = TextStyle(AtlassianSans, fontWeight = FontWeight.SemiBold, fontSize = 16.sp, lineHeight = 22.sp)
val Body = TextStyle(AtlassianSans, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 24.sp)
val CardSummary = TextStyle(AtlassianSans, fontWeight = FontWeight.Medium, fontSize = 15.sp, lineHeight = 21.sp)
val Meta = TextStyle(AtlassianSans, fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 19.sp)
val FieldLabel = TextStyle(AtlassianSans, fontWeight = FontWeight.SemiBold, fontSize = 12.sp, lineHeight = 16.sp, letterSpacing = 0.4.sp)
val ColumnHead = TextStyle(AtlassianSans, fontWeight = FontWeight.Bold, fontSize = 12.sp, lineHeight = 16.sp, letterSpacing = 0.6.sp)
val Lozenge = TextStyle(AtlassianSans, fontWeight = FontWeight.Bold, fontSize = 11.sp, lineHeight = 12.sp, letterSpacing = 0.4.sp)
val IssueKey = TextStyle(AtlassianSans, fontWeight = FontWeight.Bold, fontSize = 11.sp, lineHeight = 12.sp, letterSpacing = 0.2.sp, fontFeatureSettings = TNUM)
val Tab = TextStyle(AtlassianSans, fontWeight = FontWeight.Medium, fontSize = 10.sp, lineHeight = 11.sp, letterSpacing = 0.1.sp)
val Code = TextStyle(AtlassianMono, fontWeight = FontWeight.Normal, fontSize = 13.sp, lineHeight = 20.sp)
}
val JiraTypography = Typography(
headlineLarge = JiraText.ScreenTitle,
headlineMedium = JiraText.BoardTitle,
titleMedium = JiraText.Subsection,
bodyMedium = JiraText.Body,
labelSmall = JiraText.Tab,
)
3. Signature Components
Status Lozenge
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
enum class JiraStatus(val bg: Color, val tx: Color, val label: String) {
ToDo(JiraColors.NeutralBg, JiraColors.NeutralTx, "TO DO"),
InProgress(JiraColors.InfoBg, JiraColors.InfoTx, "IN PROGRESS"),
Done(JiraColors.SuccessBg, JiraColors.SuccessTx, "DONE"),
Blocked(JiraColors.DangerBg, JiraColors.DangerTx, "BLOCKED"),
Epic(JiraColors.EpicBg, JiraColors.EpicTx, "EPIC"),
}
@Composable
fun StatusLozenge(status: JiraStatus, modifier: Modifier = Modifier) {
Box(
modifier
.clip(RoundedCornerShape(3.dp))
.background(status.bg)
.padding(horizontal = 8.dp, vertical = 4.dp)
) {
Text(status.label, style = JiraText.Lozenge, color = status.tx)
}
}
Issue-Type Icon
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.Icon
import androidx.compose.ui.graphics.vector.ImageVector
enum class IssueType(val color: Color, val icon: ImageVector) {
Story(JiraColors.StoryGreen, Icons.Filled.Bookmark),
Bug(JiraColors.BugRed, Icons.Filled.BugReport),
Task(JiraColors.TaskBlue, Icons.Filled.Check),
Epic(JiraColors.EpicPurple, Icons.Filled.Bolt),
Subtask(JiraColors.SubtaskGray, Icons.Filled.SubdirectoryArrowRight),
}
@Composable
fun IssueTypeIcon(type: IssueType, size: Int = 16) {
Box(
Modifier.size(size.dp).clip(RoundedCornerShape(3.dp)).background(type.color),
contentAlignment = Alignment.Center,
) {
Icon(type.icon, contentDescription = type.name, tint = Color.White, modifier = Modifier.size((size * 0.62f).dp))
}
}
Issue Card
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.shape.CircleShape
data class CardLabel(val text: String, val bg: Color, val fg: Color)
@Composable
fun IssueCard(
type: IssueType,
issueKey: String,
summary: String,
labels: List<CardLabel> = emptyList(),
points: Int,
initials: String,
avatarColor: Color,
modifier: Modifier = Modifier,
) {
Column(
modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surface)
.border(1.dp, JiraColors.Border, RoundedCornerShape(8.dp))
.padding(12.dp)
) {
Text(summary, style = JiraText.CardSummary, color = MaterialTheme.colorScheme.onSurface, maxLines = 2)
if (labels.isNotEmpty()) {
FlowRow(Modifier.padding(top = 10.dp), horizontalArrangement = Arrangement.spacedBy(5.dp)) {
labels.forEach { l ->
Box(Modifier.clip(RoundedCornerShape(3.dp)).background(l.bg).padding(horizontal = 7.dp, vertical = 2.dp)) {
Text(l.text.uppercase(), style = JiraText.Lozenge.copy(fontSize = 10.sp), color = l.fg)
}
}
}
}
Row(
Modifier.fillMaxWidth().padding(top = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
IssueTypeIcon(type)
Text(issueKey, style = JiraText.IssueKey, color = JiraColors.TextSubtlest)
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Box(
Modifier.defaultMinSize(minWidth = 18.dp, minHeight = 18.dp)
.clip(CircleShape).background(Color(0xFFDDE1E6)).padding(horizontal = 5.dp),
contentAlignment = Alignment.Center,
) { Text("$points", style = JiraText.IssueKey, color = JiraColors.TextSubtle) }
Box(Modifier.size(22.dp).clip(CircleShape).background(avatarColor), contentAlignment = Alignment.Center) {
Text(initials, style = JiraText.Lozenge, color = Color.White)
}
}
}
}
}
Board Column
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@Composable
fun BoardColumn(name: String, count: Int, cards: List<IssueCardModel>, modifier: Modifier = Modifier) {
Column(modifier.width(248.dp)) {
Row(
Modifier.padding(bottom = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(name.uppercase(), style = JiraText.ColumnHead, color = JiraColors.TextSubtlest)
Box(Modifier.clip(CircleShape).background(JiraColors.SurfaceHover).padding(horizontal = 7.dp, vertical = 1.dp)) {
Text("$count", style = JiraText.IssueKey, color = JiraColors.TextSubtlest)
}
}
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(cards) { c -> IssueCard(c.type, c.key, c.summary, c.labels, c.points, c.initials, c.avatarColor) }
}
}
}
Field Row (issue detail)
import androidx.compose.material3.HorizontalDivider
@Composable
fun FieldRow(label: String, value: @Composable () -> Unit) {
Column {
Row(
Modifier.fillMaxWidth().defaultMinSize(minHeight = 44.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(label.uppercase(), style = JiraText.FieldLabel, color = JiraColors.TextSubtle, modifier = Modifier.width(110.dp))
Box(Modifier.weight(1f)) { value() }
}
HorizontalDivider(color = JiraColors.Divider, thickness = 1.dp)
}
}
4. Navigation
Jira has minimal chrome: a 5-tab bottom strip and a top breadcrumb/filter row. On Android, the strip is a NavigationBar; the status-transition picker is a DropdownMenu; the board is a horizontally scrolling LazyRow of LazyColumns. No tint pill — active is Jira Blue.
@Composable
fun JiraBottomBar(selected: Int, onSelect: (Int) -> Unit) {
NavigationBar(containerColor = MaterialTheme.colorScheme.surface, tonalElevation = 0.dp) {
val items = listOf(
"Boards" to Icons.Filled.GridView,
"Backlog" to Icons.Filled.FormatListBulleted,
"Search" to Icons.Filled.Search,
"Notifications" to Icons.Filled.Notifications,
"You" 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 = JiraText.Tab) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = JiraColors.Blue,
selectedTextColor = JiraColors.Blue,
unselectedIconColor = JiraColors.TextSubtlest,
unselectedTextColor = JiraColors.TextSubtlest,
indicatorColor = Color.Transparent, // no Material pill — Jira has none
),
)
}
}
}
The board lives in a LazyRow of BoardColumns on a Sunken/DarkCanvas background. For drag-to-transition, use Modifier.pointerInput { detectDragGesturesAfterLongPress { ... } } lifting the dragged card (graphicsLayer { scaleX = 1.02f; rotationZ = 2f }), shifting neighbors via animateItemPlacement(tween(200)), and on drop firing a medium haptic + status update.
5. Motion
Jira motion is functional Atlassian ease-out — 150–300ms, never decorative. Shadows are navy-tinted on light; elevation on dark is a surface step.
| Moment | Compose recipe |
|---|---|
| Card drag | long-press → graphicsLayer { scaleX/Y = 1.02f; rotationZ = 2f }; neighbors animateItemPlacement(tween(200)); drop snap tween(150) + medium haptic |
| Status dropdown | DropdownMenu default fade; on select, lozenge color animateColorAsState(tween(200)) |
| Screen push | Nav3/NavHost slide push tween(300) |
| Section collapse (backlog) | chevron animateFloatAsState 0→90° tween(150); rows expandVertically(tween(200)) + fadeIn |
| Sprint progress segments | each segment width animateDpAsState(tween(400)) on load |
| Filter pill toggle | instant tint + tween(100) scale 0.97 press feedback |
| Pull-to-refresh | PullToRefreshContainer, 250ms settle |
// Status lozenge color cross-fade on transition — the canonical Jira motion
val bg by animateColorAsState(status.bg, tween(200), label = "lozengeBg")
val tx by animateColorAsState(status.tx, tween(200), label = "lozengeTx")
Haptics: LocalHapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) for the medium impact on card drop and status change; HapticFeedbackConstants.CLOCK_TICK for the light filter-toggle / section-collapse tick. Auto-save is silent — only show a Snackbar on explicit save or error.
6. Icons
Jira’s issue-type glyphs map to filled Material icons inside a rounded color container. Add androidx.compose.material:material-icons-extended for the full set.
| Purpose | SF Symbol (iOS) | Material Icon (Compose) |
|---|---|---|
| Boards (tab) | square.grid.2x2 |
Icons.Filled.GridView |
| Backlog (tab) | list.bullet.indent |
Icons.Filled.FormatListBulleted |
| Search (tab) | magnifyingglass |
Icons.Filled.Search |
| Notifications (tab) | bell |
Icons.Filled.Notifications |
| You (tab) | person.crop.circle |
Icons.Filled.AccountCircle |
| Story type | bookmark.fill |
Icons.Filled.Bookmark |
| Bug type | ladybug.fill |
Icons.Filled.BugReport |
| Task type | checkmark |
Icons.Filled.Check |
| Epic type | bolt.fill |
Icons.Filled.Bolt |
| Sub-task type | arrow.turn.down.right |
Icons.Filled.SubdirectoryArrowRight |
| Status dropdown | chevron.down |
Icons.Filled.ArrowDropDown |
| Priority Highest | chevron.up.2 |
Icons.Filled.KeyboardDoubleArrowUp |
| Priority Medium | equal |
Icons.Filled.DragHandle |
| Filter | line.3.horizontal.decrease.circle |
Icons.Filled.FilterList |
| Overflow | ellipsis |
Icons.Filled.MoreHoriz |
| Watch | eye |
Icons.Filled.Visibility |
| Add / create | plus |
Icons.Filled.Add |
| Flag | flag.fill |
Icons.Filled.Flag |
| Comment | bubble.left |
Icons.Filled.ChatBubbleOutline |
7. Minimum SDK & Accessibility Notes
- minSdk 24 (Compose floor 21;
DropdownMenu, drag, and modern motion are comfortable at 24).compileSdk 34+, Compose BOM2024.09+, Kotlin2.0+. - Edge-to-edge: call
enableEdgeToEdge(); light canvas wants dark-content system bars (light-content in dark mode). The top breadcrumb respects the camera cutout; the comment composer / inline edit useModifier.imePadding(). - Font scaling:
sphonors the user scale — keep it on titles, body, description, comments, and code (stays monospace). Pin layout-sensitive text (status lozenges, label chips, issue keys, 12sp column headers, 10sp tab labels) so the fixed-width board columns don’t break — derive fromdpor wrap inCompositionLocalProvider(LocalDensity provides Density(density, fontScale = 1f)). - Tabular figures: keep
fontFeatureSettings = "tnum"on issue keys, points, and counts so columns and totals align. - TalkBack: label issue cards “{type}, {key}, {summary}, {points} points, assigned to {name}”; the status lozenge dropdown “Status: {value}, dropdown”; expose card move via
Modifier.semantics { customActions = listOf(CustomAccessibilityAction("Move to column") { … }) }so drag isn’t required. - Touch targets: Material guidance is 48.dp — give the lozenge dropdown, overflow, and icon buttons a 48.dp hit area; issue cards are full-card tappable (≥64.dp tall); field rows full-row 44.dp+.
- Contrast:
#172B4Don#FFFFFFand#C7D1DBon#1D2125pass WCAG AA; every semantic lozenge pair (e.g.#216E4Eon#DCFFF1) is AA-matched — validate any custom pair. Never convey status by color alone: the lozenge text label and the distinct issue-type icon shapes (square / circle / check / lightning) carry it. - Reduce motion: when
Settings.Global.ANIMATOR_DURATION_SCALE == 0, disable the drag tilt and section slide — substituteCrossfade; keep the lozenge color change instant. - Dark mode: invert via the
Dark*palette —#1D2125, NOT true black;#172B4Dtext →#C7D1DB; interactive blue →#1868DB. Shadows are nearly invisible on dark, so signal elevation via the surface step (#22272B→#2C333A→#38414A) plus a 1.dpDarkDividerborder on cards, dropdowns, and sheets. Do not enabledynamicColorScheme()— Jira’s brand blue and semantic palette must hold regardless of wallpaper.