Android: the real light/dark palettes, and a System theme that follows the OS

a1f8336 · Gabriel Morell · 2026-09-14 04:28

14 files +111 -38
Message
{commit_body(@commit)}

Files changed

modified android/app/src/main/java/org/neiam/waxx/app/MainActivity.kt
+13 −7
@@ -37,10 +37,10 @@ import org.neiam.waxx.app.ui.CardSheet
37 37 import org.neiam.waxx.app.ui.HistoryScreen
38 38 import org.neiam.waxx.app.ui.TemplateEditorScreen
39 39 import org.neiam.waxx.app.ui.TemplatesListScreen
40 −import org.neiam.waxx.app.ui.theme.ALL_THEMES
41 40 import org.neiam.waxx.app.ui.theme.AppTheme
42 41 import org.neiam.waxx.app.ui.theme.WaxxTheme
43 −import org.neiam.waxx.app.ui.theme.appThemeByKey
42 +import org.neiam.waxx.app.ui.theme.SYSTEM_THEME_KEY
43 +import org.neiam.waxx.app.ui.theme.appThemeForKey
44 44
45 45 /**
46 46 * Single-activity host. Routes:
@@ -61,18 +61,22 @@ class MainActivity : ComponentActivity() {
61 61 val initialDeepLink = parseIntent(intent)
62 62
63 63 setContent {
64 − var theme by remember {
65 − mutableStateOf(themeStore.themeKey?.let(::appThemeByKey) ?: ALL_THEMES.first())
64 + // Nothing stored means follow the OS, same default as the web,
65 + // where an empty phx:theme is "system".
66 + var themeKey by remember {
67 + mutableStateOf(themeStore.themeKey ?: SYSTEM_THEME_KEY)
66 68 }
69 + val theme = appThemeForKey(themeKey)
67 70 WaxxTheme(theme = theme) {
68 71 Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
69 72 WaxxApp(
70 73 tokens = tokens,
71 74 initialDeepLink = initialDeepLink,
72 75 currentTheme = theme,
76 + currentThemeKey = themeKey,
73 77 onPickTheme = { picked ->
74 − theme = picked
75 − themeStore.themeKey = picked.key
78 + themeKey = picked
79 + themeStore.themeKey = picked
76 80 },
77 81 )
78 82 }
@@ -99,7 +103,8 @@ fun WaxxApp(
99 103 tokens: TokenStore,
100 104 initialDeepLink: DeepLink?,
101 105 currentTheme: AppTheme,
102 − onPickTheme: (AppTheme) -> Unit,
106 + currentThemeKey: String,
107 + onPickTheme: (String) -> Unit,
103 108 ) {
104 109 val nav = rememberNavController()
105 110 val startDestination = remember(tokens, initialDeepLink) {
@@ -141,6 +146,7 @@ fun WaxxApp(
141 146 BoardsListScreen(
142 147 tokens = tokens,
143 148 currentTheme = currentTheme,
149 + currentThemeKey = currentThemeKey,
144 150 onPickTheme = onPickTheme,
145 151 onOpenBoard = { boardId -> nav.navigate("board/$boardId") },
146 152 onOpenAppInvites = { nav.navigate("app_invites") },
modified android/app/src/main/java/org/neiam/waxx/app/ui/BoardsScreen.kt
+38 −8
@@ -55,6 +55,9 @@ import org.neiam.waxx.app.data.TemplateSummary
55 55 import org.neiam.waxx.app.data.TokenStore
56 56 import org.neiam.waxx.app.data.WaxxClient
57 57 import org.neiam.waxx.app.ui.theme.ALL_THEMES
58 +import org.neiam.waxx.app.ui.theme.SYSTEM_THEME_KEY
59 +import org.neiam.waxx.app.ui.theme.appThemeForKey
60 +import org.neiam.waxx.app.ui.theme.themeLabel
58 61 import org.neiam.waxx.app.ui.theme.AppTheme
59 62
60 63 @OptIn(ExperimentalMaterial3Api::class)
@@ -62,7 +65,8 @@ import org.neiam.waxx.app.ui.theme.AppTheme
62 65 fun BoardsListScreen(
63 66 tokens: TokenStore,
64 67 currentTheme: AppTheme,
65 − onPickTheme: (AppTheme) -> Unit,
68 + currentThemeKey: String,
69 + onPickTheme: (String) -> Unit,
66 70 onOpenBoard: (String) -> Unit,
67 71 onOpenAppInvites: () -> Unit,
68 72 onOpenTemplates: () -> Unit,
@@ -98,7 +102,7 @@ fun BoardsListScreen(
98 102 }
99 103 DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
100 104 DropdownMenuItem(
101 − text = { Text("Theme: ${currentTheme.name}") },
105 + text = { Text("Theme: ${themeLabel(currentThemeKey, currentTheme)}") },
102 106 onClick = {
103 107 menuOpen = false
104 108 themePickerOpen = true
@@ -165,7 +169,7 @@ fun BoardsListScreen(
165 169
166 170 if (themePickerOpen) {
167 171 ThemePickerDialog(
168 − current = currentTheme,
172 + current = currentThemeKey,
169 173 onDismiss = { themePickerOpen = false },
170 174 onPick = { picked ->
171 175 themePickerOpen = false
@@ -319,26 +323,52 @@ private sealed interface BoardsState {
319 323 // Theme picker also lives in this file so the dropdown wiring is local.
320 324 @Composable
321 325 private fun ThemePickerDialog(
322 − current: AppTheme,
326 + current: String,
323 327 onDismiss: () -> Unit,
324 − onPick: (AppTheme) -> Unit,
328 + onPick: (String) -> Unit,
325 329 ) {
326 330 AlertDialog(
327 331 onDismissRequest = onDismiss,
328 332 title = { Text("Theme") },
329 333 text = {
330 334 Column {
335 + // System first, as in the web picker. Its swatches show the
336 + // half of the pair the OS is currently asking for, so the row
337 + // previews what picking it would actually do.
338 + val systemTheme = appThemeForKey(SYSTEM_THEME_KEY)
339 + Row(
340 + verticalAlignment = Alignment.CenterVertically,
341 + modifier = Modifier
342 + .fillMaxWidth()
343 + .clickable { onPick(SYSTEM_THEME_KEY) }
344 + .padding(vertical = 4.dp),
345 + ) {
346 + RadioButton(
347 + selected = current == SYSTEM_THEME_KEY,
348 + onClick = { onPick(SYSTEM_THEME_KEY) },
349 + )
350 + Spacer(Modifier.width(8.dp))
351 + Column(modifier = Modifier.weight(1f)) {
352 + Text("System")
353 + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
354 + Swatch(systemTheme.bg)
355 + Swatch(systemTheme.cardBg)
356 + Swatch(systemTheme.primary)
357 + Swatch(systemTheme.accent)
358 + }
359 + }
360 + }
331 361 ALL_THEMES.forEach { theme ->
332 362 Row(
333 363 verticalAlignment = Alignment.CenterVertically,
334 364 modifier = Modifier
335 365 .fillMaxWidth()
336 − .clickable { onPick(theme) }
366 + .clickable { onPick(theme.key) }
337 367 .padding(vertical = 4.dp),
338 368 ) {
339 369 RadioButton(
340 − selected = theme.key == current.key,
341 − onClick = { onPick(theme) },
370 + selected = theme.key == current,
371 + onClick = { onPick(theme.key) },
342 372 )
343 373 Spacer(Modifier.width(8.dp))
344 374 Column(modifier = Modifier.weight(1f)) {
modified android/app/src/main/java/org/neiam/waxx/app/ui/theme/AppTheme.kt
+60 −23
@@ -1,8 +1,10 @@
1 1 package org.neiam.waxx.app.ui.theme
2 2
3 3 import android.graphics.Color as AndroidColor
4 +import androidx.compose.foundation.isSystemInDarkTheme
4 5 import androidx.compose.material3.darkColorScheme
5 6 import androidx.compose.material3.lightColorScheme
7 +import androidx.compose.runtime.Composable
6 8 import androidx.compose.runtime.compositionLocalOf
7 9 import androidx.compose.ui.graphics.Color
8 10 import kotlin.math.cos
@@ -98,6 +100,10 @@ private fun oklchTheme(
98 100 contentL: Float, contentC: Float, contentH: Float,
99 101 primaryL: Float, primaryC: Float, primaryH: Float,
100 102 dimL: Float, dimC: Float, dimH: Float,
103 + // The six neiam themes all share daisyUI's yellow accent, so it defaults
104 + // to that; light and dark carry their own and pass it explicitly.
105 + accentL: Float = 0.96f, accentC: Float = 0.058f, accentH: Float = 96f,
106 + isLight: Boolean = false,
101 107 ) = AppTheme(
102 108 name = name, key = key,
103 109 bg = oklchToColor(bgL, bgC, bgH),
@@ -105,32 +111,26 @@ private fun oklchTheme(
105 111 content = oklchToColor(contentL, contentC, contentH),
106 112 primary = oklchToColor(primaryL, primaryC, primaryH),
107 113 dim = oklchToColor(dimL, dimC, dimH),
108 − accent = oklchToColor(0.96f, 0.058f, 96f),
114 + accent = oklchToColor(accentL, accentC, accentH),
109 115 liveGreen = oklchToColor(0.75f, 0.15f, 145f),
116 + isLight = isLight,
110 117 )
111 118
112 −/** Plain Material 3 light defaults — escape hatch when the OKLCh palettes
113 − * feel like overkill. Hex values come straight from M3's baseline. */
114 −private object MaterialLight {
115 − val BG = Color(0xFFFFFBFE)
116 − val CARD = Color(0xFFF7F2FA)
117 − val CONTENT = Color(0xFF1D1B20)
118 − val PRIMARY = Color(0xFF6750A4)
119 − val DIM = Color(0xFF49454F)
120 − val ACCENT = Color(0xFF7D5260)
121 − val LIVE_GREEN = Color(0xFF006E26)
122 −}
123 −
124 119 val ALL_THEMES: List<AppTheme> = listOf(
125 − AppTheme(
126 − name = "Light", key = "light",
127 − bg = MaterialLight.BG,
128 − cardBg = MaterialLight.CARD,
129 − content = MaterialLight.CONTENT,
130 − primary = MaterialLight.PRIMARY,
131 − dim = MaterialLight.DIM,
132 − accent = MaterialLight.ACCENT,
133 − liveGreen = MaterialLight.LIVE_GREEN,
120 + // light and dark below are the daisyUI themes of the same names in
121 + // assets/css/app.css, token for token: bg is --color-base-100, cardBg is
122 + // --color-base-200, content is --color-base-content, and primary/accent
123 + // are theirs. `dim` has no web token -- it paints Material's outline and
124 + // onSurfaceVariant -- so it is the 60% blend of content over bg, which is
125 + // what the web writes as text-base-content/60.
126 + oklchTheme(
127 + "Light", "light",
128 + 0.98f, 0.008f, 24f, // base-100
129 + 0.96f, 0.014f, 24f, // base-200
130 + 0.25f, 0.050f, 24f, // base-content
131 + 0.64f, 0.076f, 19f, // primary
132 + 0.54f, 0.033f, 24f, // dim
133 + 0.0f, 0.0f, 0f, // accent: light's is pure black
134 134 isLight = true,
135 135 ),
136 136 // Mirrors the daisyUI `blueprint` theme in assets/css/app.css —
@@ -144,10 +144,47 @@ val ALL_THEMES: List<AppTheme> = listOf(
144 144 oklchTheme("Sky", "sky", 0.14f, 0.055f, 243f, 0.18f, 0.040f, 243f, 0.90f, 0.035f, 243f, 0.75f, 0.139f, 233f, 0.42f, 0.050f, 243f),
145 145 oklchTheme("Clays", "clays", 0.13f, 0.065f, 46f, 0.17f, 0.050f, 46f, 0.90f, 0.035f, 46f, 0.67f, 0.157f, 58f, 0.42f, 0.060f, 46f),
146 146 oklchTheme("Stones", "stones", 0.12f, 0.005f, 34f, 0.16f, 0.003f, 34f, 0.90f, 0.035f, 34f, 0.55f, 0.023f, 264f, 0.36f, 0.005f, 34f),
147 − oklchTheme("Dark", "dark", 0.14f, 0.015f, 240f, 0.18f, 0.010f, 240f, 0.90f, 0.035f, 240f, 0.70f, 0.100f, 85f, 0.40f, 0.015f, 240f),
147 + oklchTheme(
148 + "Dark", "dark",
149 + 0.3033f, 0.016f, 252.42f, // base-100
150 + 0.2526f, 0.014f, 253.1f, // base-200
151 + 0.97807f, 0.029f, 256.847f, // base-content
152 + 0.64f, 0.076f, 19f, // primary
153 + 0.71f, 0.024f, 255f, // dim
154 + 0.64f, 0.135f, 15f, // accent: the rose that replaced the violet
155 + ),
148 156 )
149 157
150 158 fun appThemeByKey(key: String): AppTheme =
151 159 ALL_THEMES.firstOrNull { it.key == key } ?: ALL_THEMES.first()
152 160
161 +/**
162 + * Mirrors the web picker's "System" entry. Not a palette of its own: a
163 + * request to follow the OS, stored under the same name the web stores.
164 + */
165 +const val SYSTEM_THEME_KEY = "system"
166 +
167 +// The pair "system" resolves to -- the two themes actually named light and
168 +// dark, the same pairing Waxx.Themes.system_light/0 and system_dark/0 name
169 +// on the web. Only these two are picked automatically; the rest are explicit.
170 +private const val SYSTEM_LIGHT_KEY = "light"
171 +private const val SYSTEM_DARK_KEY = "dark"
172 +
173 +/**
174 + * Resolve a stored theme key to a palette. A null key -- nothing chosen yet
175 + * -- or [SYSTEM_THEME_KEY] follows the OS, and re-resolves when the OS flips
176 + * because isSystemInDarkTheme() is read during composition.
177 + */
178 +@Composable
179 +fun appThemeForKey(key: String?): AppTheme =
180 + if (key == null || key == SYSTEM_THEME_KEY) {
181 + appThemeByKey(if (isSystemInDarkTheme()) SYSTEM_DARK_KEY else SYSTEM_LIGHT_KEY)
182 + } else {
183 + appThemeByKey(key)
184 + }
185 +
186 +/** What the menu shows: "System" while following, else the palette's name. */
187 +fun themeLabel(key: String?, resolved: AppTheme): String =
188 + if (key == null || key == SYSTEM_THEME_KEY) "System" else resolved.name
189 +
153 190 val LocalAppTheme = compositionLocalOf { ALL_THEMES.first() }

Parents: c6db9a8