neiam
/waxx
waxx
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
Android: the real light/dark palettes, and a System theme that follows the OS
a1f8336 · Gabriel Morell · 2026-09-14 04:28
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