Bear (iOS) — Jetpack Compose Implementation Guide
Companion to DESIGN.md — the framework-neutral spec. This file ports Bear’s visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, the live-Markdown renderer, paste-ready @Composables, navigation, 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 (Bear’s calm charcoal canvas, the single red→orange brand gradient, inline #hashtags as the filing system, live in-place Markdown) while making everything idiomatic Android — a
ModalNavigationDrawerfor the tag sidebar, aFloatingActionButtonfor compose,sp/dpinstead ofpt.
Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ if you render embedded images. No color extraction — Bear’s palette is fixed. Bear is theme-driven; the default dark “Charcoal” scheme is provided, never true black.
1. Color Tokens
// ui/theme/BearColors.kt
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Brush
object BearColors {
// Canvas & Surfaces (Light)
val Canvas = Color(0xFFFFFFFF)
val SurfaceLight = Color(0xFFF6F6F4)
val PressedLight = Color(0xFFECECE8)
val DividerLight = Color(0xFFE6E6E2)
// Canvas & Surfaces (Dark — "Charcoal")
val CanvasDark = Color(0xFF21252B) // Bear's signature charcoal — NOT pure black
val Surface1 = Color(0xFF282C34)
val Surface2 = Color(0xFF2F343D)
val Divider = Color(0xFF353A42)
// Text
val TextPrimary = Color(0xFFE8EAED) // dark
val TextPrimaryLt = Color(0xFF2A2C33) // light — warm near-black, NOT pure black
val TextSecondary = Color(0xFF9DA3AD)
val TextTertiary = Color(0xFF6B7280)
// Brand (the single accent)
val BearRed = Color(0xFFE0566F)
val BearOrange = Color(0xFFFF8A65)
val BearRedPressed = Color(0xFFC8485E)
val Link = Color(0xFF5AAFEF)
// Tag / highlight accents
val TagBlue = Color(0xFF5AAFEF)
val TagGreen = Color(0xFF57C98B)
val TagYellow = Color(0xFFF2C14E)
val TagPurple = Color(0xFFB08CF0)
val Highlight = Color(0xFF4A431F) // dark
// Semantic
val Error = Color(0xFFF2545B)
val Success = Color(0xFF57C98B)
// The one brand gradient — icon, FAB, checkbox fill, selection
val BrandGradient = Brush.linearGradient(listOf(BearRed, BearOrange))
}
Wire it into both schemes. Bear is theme-driven; the dark scheme uses the signature #21252B charcoal, never true black.
// ui/theme/Theme.kt
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
private val BearLight = lightColorScheme(
primary = BearColors.BearOrange, // interactive glyph color
onPrimary = Color(0xFFFFFFFF),
background = BearColors.Canvas,
onBackground = BearColors.TextPrimaryLt,
surface = BearColors.SurfaceLight,
onSurface = BearColors.TextPrimaryLt,
surfaceVariant = BearColors.PressedLight,
outline = BearColors.DividerLight,
error = BearColors.Error,
)
private val BearDark = darkColorScheme(
primary = BearColors.BearOrange,
onPrimary = Color(0xFFFFFFFF),
background = BearColors.CanvasDark,
onBackground = BearColors.TextPrimary,
surface = BearColors.Surface1,
onSurface = BearColors.TextPrimary,
surfaceVariant = BearColors.Surface2,
outline = BearColors.Divider,
error = BearColors.Error,
)
@Composable
fun BearTheme(
dark: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) = MaterialTheme(
colorScheme = if (dark) BearDark else BearLight,
typography = BearTypography,
content = content,
)
2. Typography
Bear’s prose family is user-switchable: Inter (default sans), Lora (serif), JetBrains Mono (mono theme + all code) — all SIL OFL, drop the TTFs in res/font/. Respect the user’s choice globally via a CompositionLocal; fenced code always renders JetBrains Mono. Body 400, headings 700; reading rhythm, not UI rhythm.
// ui/theme/BearType.kt
import androidx.compose.material3.Typography
import androidx.compose.runtime.compositionLocalOf
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.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val Inter = FontFamily(
Font(R.font.inter_regular, FontWeight.Normal),
Font(R.font.inter_medium, FontWeight.Medium),
Font(R.font.inter_semibold, FontWeight.SemiBold),
Font(R.font.inter_bold, FontWeight.Bold),
Font(R.font.inter_extrabold, FontWeight.ExtraBold),
)
val Lora = FontFamily(
Font(R.font.lora_regular, FontWeight.Normal),
Font(R.font.lora_italic, FontWeight.Normal, FontStyle.Italic),
Font(R.font.lora_bold, FontWeight.Bold),
)
val JetBrainsMono = FontFamily(
Font(R.font.jetbrains_mono_regular, FontWeight.Normal),
Font(R.font.jetbrains_mono_medium, FontWeight.Medium),
)
enum class BearFontFamily(val family: FontFamily) {
Sans(Inter), Serif(Lora), Mono(JetBrainsMono)
}
// Provide at the app root; switch from Settings — applies to all prose
val LocalBearFont = compositionLocalOf { BearFontFamily.Sans }
// Named ramp — mirrors DESIGN.md §3 exactly (pt → sp 1:1). Default = Inter.
object BearText {
val LargeTitle = TextStyle(Inter, fontWeight = FontWeight.ExtraBold, fontSize = 32.sp, lineHeight = 38.sp, letterSpacing = (-0.6).sp)
val NoteTitle = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 24.sp, lineHeight = 30.sp, letterSpacing = (-0.3).sp)
val H2 = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 19.sp, lineHeight = 25.sp, letterSpacing = (-0.2).sp)
val H3 = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 17.sp, lineHeight = 23.sp, letterSpacing = (-0.1).sp)
val Subhead = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 17.sp, lineHeight = 23.sp)
val Body = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 25.sp)
val BodyDense = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 15.sp, lineHeight = 22.sp)
val Quote = TextStyle(Lora, fontWeight = FontWeight.Normal, fontSize = 17.sp, lineHeight = 26.sp, fontStyle = FontStyle.Italic)
val Preview = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 20.sp)
val Meta = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 20.sp)
val Tag = TextStyle(Inter, fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 20.sp)
val CodeInline = TextStyle(JetBrainsMono, fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 20.sp)
val CodeBlock = TextStyle(JetBrainsMono, fontWeight = FontWeight.Normal, fontSize = 13.sp, lineHeight = 20.sp)
val Caption = TextStyle(Inter, fontWeight = FontWeight.Medium, fontSize = 12.sp, lineHeight = 16.sp, letterSpacing = 0.1.sp)
val TabLabel = TextStyle(Inter, fontWeight = FontWeight.Medium, fontSize = 13.sp, lineHeight = 17.sp, letterSpacing = 0.1.sp)
}
// Helper: rebind a base style onto the user's chosen prose family
fun TextStyle.withBearFamily(f: BearFontFamily): TextStyle = copy(fontFamily = f.family)
// Map onto Material 3 slots so stock components inherit the brand
val BearTypography = Typography(
headlineLarge = BearText.LargeTitle,
headlineMedium = BearText.NoteTitle,
titleMedium = BearText.Subhead,
bodyMedium = BearText.Body,
labelSmall = BearText.TabLabel,
)
3. Signature Components
Live Markdown Renderer
Bear’s signature: render Markdown in-place using an AnnotatedString, dimming the syntax marker rather than hiding structure.
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
@Composable
fun MarkdownLine(raw: String) {
when {
raw.startsWith("# ") -> Text(buildAnnotatedString {
withStyle(SpanStyle(color = BearColors.TextTertiary)) { append("# ") }
append(raw.drop(2))
}, style = BearText.NoteTitle, color = BearColors.TextPrimary)
raw.startsWith("## ") -> Text(buildAnnotatedString {
withStyle(SpanStyle(color = BearColors.TextTertiary)) { append("## ") }
append(raw.drop(3))
}, style = BearText.H2, color = BearColors.TextPrimary)
raw.startsWith("> ") -> QuoteBlock(raw.drop(2))
raw.startsWith("- [") -> CheckboxRow(
initiallyChecked = raw.startsWith("- [x]"),
label = raw.replace(Regex("^- \\[.\\]\\s*"), ""),
)
else -> Text(inlineStyled(raw), style = BearText.Body, color = BearColors.TextPrimary)
}
}
/** Tints #hashtags red and [[wiki]] links blue inline. */
fun inlineStyled(s: String): AnnotatedString = buildAnnotatedString {
val token = Regex("#[\\w/]+|\\[\\[[^\\]]+\\]\\]")
var last = 0
token.findAll(s).forEach { m ->
append(s.substring(last, m.range.first))
val t = m.value
val style = if (t.startsWith("#")) SpanStyle(color = BearColors.BearRed)
else SpanStyle(color = BearColors.Link)
withStyle(style) { append(t) }
last = m.range.last + 1
}
append(s.substring(last))
}
Note Editor (Title + Body)
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.Text
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun NoteEditor(title: String, onTitleChange: (String) -> Unit, lines: List<String>) {
LazyColumn(
Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 22.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
item {
BasicTextField(
value = title, onValueChange = onTitleChange,
textStyle = BearText.NoteTitle.copy(color = BearColors.TextPrimary),
decorationBox = { inner ->
if (title.isEmpty()) Text("Title", style = BearText.NoteTitle, color = BearColors.TextTertiary)
inner()
},
)
}
items(lines) { MarkdownLine(it) }
}
}
Checkbox Row
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Icon
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.draw.clip
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.style.TextDecoration
@Composable
fun CheckboxRow(initiallyChecked: Boolean, label: String) {
var checked by remember { mutableStateOf(initiallyChecked) }
val haptics = LocalHapticFeedback.current
Row(
Modifier
.fillMaxWidth()
.clickable {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
checked = !checked
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Box(
Modifier
.size(19.dp)
.clip(RoundedCornerShape(5.dp))
.then(if (!checked) Modifier.border(2.dp, BearColors.TextTertiary, RoundedCornerShape(5.dp)) else Modifier),
contentAlignment = Alignment.Center,
) {
AnimatedVisibility(checked, enter = fadeIn(tween(150)), exit = fadeOut(tween(150))) {
Box(Modifier.matchParentSize().background(BearColors.BrandGradient), contentAlignment = Alignment.Center) {
Icon(Icons.Filled.Check, null, tint = Color(0xFFFFFFFF), modifier = Modifier.size(11.dp))
}
}
}
Text(
label,
style = BearText.Body,
color = if (checked) BearColors.TextTertiary else BearColors.TextPrimary,
textDecoration = if (checked) TextDecoration.LineThrough else TextDecoration.None,
)
}
}
Quote Block
@Composable
fun QuoteBlock(text: String) {
Row(Modifier.fillMaxWidth()) {
Box(Modifier.width(3.dp).fillMaxHeight().background(BearColors.BearOrange))
Text(
text,
style = BearText.Quote,
color = BearColors.TextSecondary,
modifier = Modifier.padding(start = 14.dp, top = 2.dp, bottom = 2.dp),
)
}
}
Code Block
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.rememberScrollState
@Composable
fun CodeBlock(language: String?, code: String) {
Column(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(BearColors.Surface1),
) {
if (language != null) {
Text(
language, style = BearText.Caption, color = BearColors.TextTertiary,
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, top = 8.dp),
)
}
Text(
code,
style = BearText.CodeBlock,
color = BearColors.TextPrimary,
modifier = Modifier.horizontalScroll(rememberScrollState()).padding(14.dp),
)
}
}
Tag Sidebar Row + Compose FAB
import androidx.compose.material.icons.filled.Tag
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.FloatingActionButton
@Composable
fun TagRow(title: String, indent: Int = 0, active: Boolean = false) {
Row(
Modifier
.fillMaxWidth()
.height(36.dp)
.background(if (active) BearColors.Surface2 else Color.Transparent)
.padding(start = (14 + indent * 18).dp, end = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(Icons.Filled.Tag, null, tint = BearColors.BearRed, modifier = Modifier.size(16.dp))
Text(title, style = BearText.Tag, color = BearColors.TextPrimary)
}
}
@Composable
fun ComposeFab(onClick: () -> Unit) {
FloatingActionButton(
onClick = onClick,
containerColor = Color.Transparent,
contentColor = Color(0xFFFFFFFF),
modifier = Modifier
.size(56.dp)
.clip(RoundedCornerShape(28.dp))
.background(BearColors.BrandGradient),
) {
Icon(Icons.Filled.Edit, contentDescription = "New note", modifier = Modifier.size(24.dp))
}
}
4. Navigation
Bear has minimal chrome: a slide-in tag sidebar and a bottom info bar (no Material tab bar). Model the sidebar as a ModalNavigationDrawer (SurfaceGray, 280.dp) and the editor’s bottom strip as a custom BottomAppBar. There is no tint pill — interactive glyphs are Bear Orange.
import androidx.compose.material3.BottomAppBar
import androidx.compose.material.icons.filled.*
@Composable
fun EditorInfoBar(words: Int, chars: Int) {
BottomAppBar(
containerColor = BearColors.Surface1.copy(alpha = 0.92f),
tonalElevation = 0.dp,
) {
Text(
"$words words · $chars chars",
style = BearText.Caption,
color = BearColors.TextTertiary,
modifier = Modifier.padding(start = 22.dp),
)
Spacer(Modifier.weight(1f))
Row(horizontalArrangement = Arrangement.spacedBy(22.dp), modifier = Modifier.padding(end = 22.dp)) {
listOf(Icons.Filled.GridOn, Icons.Filled.Visibility, Icons.Filled.IosShare, Icons.Filled.Info)
.forEach { Icon(it, null, tint = BearColors.BearOrange, modifier = Modifier.size(20.dp)) }
}
}
}
The tag sidebar (ModalDrawerSheet, 280.dp, Surface1 background) holds smart filters (Notes, Untagged, Todo, Today, Archive, Trash) as 40.dp rows, then the #hashtag tree — each tag row 36.dp with a leading 16.dp Tag glyph tinted BearRed, nested children indented 18.dp behind a disclosure chevron. Active row uses Surface2. Material’s drawerState gives the edge-swipe + 250ms open/close for free.
5. Motion
Bear motion is quiet — 120–300ms ease-out, never aggressive. The Markdown marker fade never causes a layout jump.
| Moment | Compose recipe |
|---|---|
| Checkbox toggle | AnimatedVisibility of the gradient fill fadeIn(tween(150)); label TextDecoration.LineThrough flips with it |
| Markdown marker fade | recompose AnnotatedString; animate marker SpanStyle alpha via animateColorAsState(tween(120)) — opacity only |
| Tag tap → list filter | swap list state inside Crossfade(tween(200)) |
| Nested tag disclosure | chevron animateFloatAsState 0 → 90° tween(150); children expandVertically(tween(200)) + fadeIn |
| Compose FAB | press scale animateFloatAsState → 0.92; then nav push tween(280) |
| Page navigation | Nav NavHost slide push tween(300) |
| Sidebar swipe | ModalNavigationDrawer drawerState — 1:1 drag, 50% threshold, spring(dampingRatio = 0.8f) |
| Text selection | brand-red @ ~15% overlay appears instantly (no animation) |
// Checkbox fill — the canonical Bear toggle
AnimatedVisibility(
visible = checked,
enter = fadeIn(tween(150)),
exit = fadeOut(tween(150)),
) { /* gradient box + check */ }
Haptics: prefer LocalHapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) for the soft impact on checkbox toggle, tag tap, and FAB press. Use HapticFeedbackConstants.CONFIRM for export-complete. Auto-save is silent — no motion, no haptic; only a “Saved” snackbar on manual save or error.
6. Icons
Bear ships custom glyphs; the closest first-party set is androidx.compose.material:material-icons-extended. The #hashtag glyph maps to Tag; the page-emoji concept does not exist in Bear (it is text-only Markdown).
| Purpose | SF Symbol (iOS) | Material Icon (Compose) |
|---|---|---|
| Back (“Notes”) | chevron.left |
Icons.Filled.ArrowBack |
| Info (top nav) | info.circle |
Icons.Filled.Info |
| Format / Markdown | textformat |
Icons.Filled.TextFields |
| More | ellipsis |
Icons.Filled.MoreHoriz |
| Compose FAB | square.and.pencil |
Icons.Filled.Edit |
| Checkbox (checked) | checkmark |
Icons.Filled.Check |
| Tag / #hashtag | number |
Icons.Filled.Tag |
| Smart filter — Notes | note.text |
Icons.Filled.Description |
| Smart filter — Todo | checkmark.circle |
Icons.Filled.CheckCircle |
| Smart filter — Today | calendar |
Icons.Filled.CalendarToday |
| Smart filter — Archive | archivebox |
Icons.Filled.Archive |
| Smart filter — Trash | trash |
Icons.Filled.Delete |
| Search | magnifyingglass |
Icons.Filled.Search |
| Info-bar — Table | tablecells |
Icons.Filled.GridOn |
| Info-bar — Preview | eye |
Icons.Filled.Visibility |
| Info-bar — Export | square.and.arrow.up |
Icons.Filled.IosShare |
| Disclosure (nested tag) | chevron.right |
Icons.Filled.ChevronRight (rotate 90°) |
| Pin note | pin |
Icons.Filled.PushPin |
| Link / wiki-link | link |
Icons.Filled.Link |
7. Minimum SDK & Accessibility Notes
- minSdk 24 (Compose floor is 21; modern motion is comfortable at 24).
compileSdk 34+, Compose BOM2024.09+, Kotlin2.0+. - Edge-to-edge: call
enableEdgeToEdge(); the dark “Charcoal” canvas wants light-content system bars (dark-content on the light theme). The editor must keep the keyboard accessory row visible — useModifier.imePadding()on the editorLazyColumn. - Font scaling:
sphonors the user’s font scale — keep it on note title, headings, body, quote, preview, meta, code (it stays monospace as it scales). Pin layout-sensitive text (tag tokens, 12sp info-bar caption, sidebar filter labels, 13sp tab labels) viadp-derived sizes or a fixed-fontScaleLocalDensity. - User font choice: read
LocalBearFonteverywhere for prose and applystyle.withBearFamily(...)— never hard-code Inter; Lora/JetBrains Mono must apply globally. Fenced code is always JetBrains Mono. - TalkBack: label checkbox rows with
Modifier.semantics { role = Role.Checkbox; stateDescription = if (checked) "Completed" else "Not completed" }; label #hashtag spans “Tag: {name}, double-tap to filter” viaClickableText+LinkAnnotation; mark note titles asheading(). The marker dim is visual only — expose the styled text, not the raw#/**. - Touch targets: Material guidance is 48.dp. Give the 19.dp checkbox, 16.dp tag glyph, and 14.dp disclosure chevron a 48.dp hit area via padding; sidebar rows are 36.dp tall but full-row tappable; the FAB is 56.dp.
- Contrast:
#2A2C33on#FFFFFFand#E8EAEDon#21252Bpass WCAG AA for body at 16sp;#E0566Fhashtags pass AA on canvas. Validate any custom tag color against its surface. - Reduce motion: when
Settings.Global.ANIMATOR_DURATION_SCALE == 0, disable the disclosure rotation and checkbox cross-fade — substitute an instant state change; keep the selection highlight (it conveys state). - Dark mode: invert via the
Dark*palette —#21252B, NOT true black;#2A2C33text becomes#E8EAED. Shadows are nearly invisible on dark, so floating panels add a 1dpDividerborder as the elevation cue. Do not enable Material YoudynamicColorScheme()— Bear’s calm-canvas + single-red-gradient identity must hold regardless of wallpaper. Model each shipped Bear theme as a token object; the brand gradient is the one constant across all of them.