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

15512d0 · Gabriel Morell · 2026-09-14 04:29

14 files +95 -28
Message
{commit_body(@commit)}

Files changed

modified android/app/src/main/java/org/neiam/feedpug/app/MainActivity.kt
+15 −7
@@ -20,10 +20,10 @@ import org.neiam.feedpug.app.data.TokenStore
20 20 import org.neiam.feedpug.app.ui.DetailScreen
21 21 import org.neiam.feedpug.app.ui.PairScreen
22 22 import org.neiam.feedpug.app.ui.TimelineScreen
23 −import org.neiam.feedpug.app.ui.theme.ALL_THEMES
24 23 import org.neiam.feedpug.app.ui.theme.AppTheme
25 24 import org.neiam.feedpug.app.ui.theme.FeedPugTheme
26 −import org.neiam.feedpug.app.ui.theme.appThemeByKey
25 +import org.neiam.feedpug.app.ui.theme.SYSTEM_THEME_KEY
26 +import org.neiam.feedpug.app.ui.theme.appThemeForKey
27 27
28 28 class MainActivity : ComponentActivity() {
29 29 override fun onCreate(savedInstanceState: Bundle?) {
@@ -33,18 +33,24 @@ class MainActivity : ComponentActivity() {
33 33 val deepLink = intent?.let(::extractPairPayload)
34 34
35 35 setContent {
36 − var theme by remember {
37 − mutableStateOf(themeStore.themeKey?.let(::appThemeByKey) ?: ALL_THEMES.first())
36 + // Nothing stored means follow the OS, the same default as the
37 + // web, where an absent phx:theme is "system". The key is what
38 + // lives in state -- resolving inside composition is what lets an
39 + // OS light/dark flip recompose into the other half of the pair.
40 + var themeKey by remember {
41 + mutableStateOf(themeStore.themeKey ?: SYSTEM_THEME_KEY)
38 42 }
43 + val theme = appThemeForKey(themeKey)
39 44 FeedPugTheme(theme = theme) {
40 45 Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
41 46 FeedPugApp(
42 47 tokens = tokens,
43 48 initialDeepLink = deepLink,
44 49 currentTheme = theme,
50 + currentThemeKey = themeKey,
45 51 onPickTheme = { picked ->
46 − theme = picked
47 − themeStore.themeKey = picked.key
52 + themeKey = picked
53 + themeStore.themeKey = picked
48 54 },
49 55 )
50 56 }
@@ -63,7 +69,8 @@ fun FeedPugApp(
63 69 tokens: TokenStore,
64 70 initialDeepLink: PairPayload?,
65 71 currentTheme: AppTheme,
66 − onPickTheme: (AppTheme) -> Unit,
72 + currentThemeKey: String,
73 + onPickTheme: (String) -> Unit,
67 74 ) {
68 75 val nav = rememberNavController()
69 76 val start = remember(initialDeepLink, tokens) {
@@ -89,6 +96,7 @@ fun FeedPugApp(
89 96 TimelineScreen(
90 97 tokens = tokens,
91 98 currentTheme = currentTheme,
99 + currentThemeKey = currentThemeKey,
92 100 onPickTheme = onPickTheme,
93 101 onOpenItem = { id -> nav.navigate("detail/$id") },
94 102 onUnpair = {
modified android/app/src/main/java/org/neiam/feedpug/app/ui/TimelineScreen.kt
+20 −5
@@ -25,6 +25,7 @@ import org.neiam.feedpug.app.data.Item
25 25 import org.neiam.feedpug.app.data.Slice
26 26 import org.neiam.feedpug.app.data.TokenStore
27 27 import org.neiam.feedpug.app.ui.theme.ALL_THEMES
28 +import org.neiam.feedpug.app.ui.theme.SYSTEM_THEME_KEY
28 29 import org.neiam.feedpug.app.ui.theme.AppTheme
29 30 import org.neiam.feedpug.app.ui.theme.LocalAppTheme
30 31
@@ -33,7 +34,8 @@ import org.neiam.feedpug.app.ui.theme.LocalAppTheme
33 34 fun TimelineScreen(
34 35 tokens: TokenStore,
35 36 currentTheme: AppTheme,
36 − onPickTheme: (AppTheme) -> Unit,
37 + currentThemeKey: String,
38 + onPickTheme: (String) -> Unit,
37 39 onOpenItem: (String) -> Unit,
38 40 onUnpair: () -> Unit,
39 41 ) {
@@ -140,7 +142,7 @@ fun TimelineScreen(
140 142 IconButton(onClick = { scope.launch { load(reset = true) } }) {
141 143 Icon(Icons.Default.Refresh, contentDescription = "Refresh")
142 144 }
143 − ThemeMenu(currentTheme = currentTheme, onPickTheme = onPickTheme)
145 + ThemeMenu(currentThemeKey = currentThemeKey, onPickTheme = onPickTheme)
144 146 IconButton(onClick = onUnpair) {
145 147 Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = "Unpair")
146 148 }
@@ -299,23 +301,36 @@ private fun TimelineRow(item: Item, onClick: () -> Unit) {
299 301 }
300 302
301 303 @Composable
302 −private fun ThemeMenu(currentTheme: AppTheme, onPickTheme: (AppTheme) -> Unit) {
304 +private fun ThemeMenu(currentThemeKey: String, onPickTheme: (String) -> Unit) {
303 305 var open by remember { mutableStateOf(false) }
304 306 Box {
305 307 IconButton(onClick = { open = true }) {
306 308 Icon(Icons.Default.Palette, contentDescription = "Theme")
307 309 }
308 310 DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
311 + // System first, as in the web picker.
312 + DropdownMenuItem(
313 + text = { Text("System") },
314 + leadingIcon = {
315 + if (currentThemeKey == SYSTEM_THEME_KEY) {
316 + Icon(Icons.Default.DoneAll, contentDescription = null)
317 + }
318 + },
319 + onClick = {
320 + onPickTheme(SYSTEM_THEME_KEY)
321 + open = false
322 + },
323 + )
309 324 ALL_THEMES.forEach { theme ->
310 325 DropdownMenuItem(
311 326 text = { Text(theme.name) },
312 327 leadingIcon = {
313 − if (theme.key == currentTheme.key) {
328 + if (theme.key == currentThemeKey) {
314 329 Icon(Icons.Default.DoneAll, contentDescription = null)
315 330 }
316 331 },
317 332 onClick = {
318 − onPickTheme(theme)
333 + onPickTheme(theme.key)
319 334 open = false
320 335 },
321 336 )
modified android/app/src/main/java/org/neiam/feedpug/app/ui/theme/AppTheme.kt
+60 −16
@@ -1,8 +1,10 @@
1 1 package org.neiam.feedpug.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
@@ -92,6 +94,10 @@ private fun oklchTheme(
92 94 contentL: Float, contentC: Float, contentH: Float,
93 95 primaryL: Float, primaryC: Float, primaryH: Float,
94 96 dimL: Float, dimC: Float, dimH: Float,
97 + // The six neiam themes all share daisyUI's yellow accent, so it
98 + // defaults to that; light and dark pass their own explicitly.
99 + accentL: Float = 0.96f, accentC: Float = 0.058f, accentH: Float = 96f,
100 + isLight: Boolean = false,
95 101 ) = AppTheme(
96 102 name = name, key = key,
97 103 bg = oklchToColor(bgL, bgC, bgH),
@@ -99,27 +105,28 @@ private fun oklchTheme(
99 105 content = oklchToColor(contentL, contentC, contentH),
100 106 primary = oklchToColor(primaryL, primaryC, primaryH),
101 107 dim = oklchToColor(dimL, dimC, dimH),
102 − accent = oklchToColor(0.96f, 0.058f, 96f),
108 + accent = oklchToColor(accentL, accentC, accentH),
109 + isLight = isLight,
103 110 )
104 111
105 −/** Plain Material 3 light defaults — straight from M3's baseline light theme. */
106 −private object MaterialLight {
107 − val BG = Color(0xFFFFFBFE)
108 − val CARD = Color(0xFFF7F2FA)
109 − val CONTENT = Color(0xFF1D1B20)
110 − val PRIMARY = Color(0xFF6750A4)
111 − val DIM = Color(0xFF49454F)
112 − val ACCENT = Color(0xFF7D5260)
113 −}
114 −
115 112 val ALL_THEMES: List<AppTheme> = listOf(
116 113 // FeedPug's signature theme and default — navy + cyan with a yellow accent.
117 114 // Values mirror the "blueprint" daisyUI theme in the web app's app.css.
118 115 oklchTheme("Blueprint", "blueprint", 0.28f, 0.12f, 252f, 0.24f, 0.11f, 252f, 0.91f, 0.05f, 235f, 0.68f, 0.18f, 248f, 0.55f, 0.05f, 235f),
119 − AppTheme(
120 − name = "Light", key = "light",
121 − bg = MaterialLight.BG, cardBg = MaterialLight.CARD, content = MaterialLight.CONTENT,
122 − primary = MaterialLight.PRIMARY, dim = MaterialLight.DIM, accent = MaterialLight.ACCENT,
116 + // light and dark below are the daisyUI themes of the same names in
117 + // assets/css/app.css, token for token: bg is --color-base-100, cardBg is
118 + // --color-base-200, content is --color-base-content, and primary/accent
119 + // are theirs. `dim` has no web token -- it paints Material's outline and
120 + // onSurfaceVariant -- so it is the 60% blend of content over bg, which is
121 + // what the web writes as text-base-content/60.
122 + oklchTheme(
123 + "Light", "light",
124 + 0.98f, 0.008f, 24f, // base-100
125 + 0.96f, 0.014f, 24f, // base-200
126 + 0.25f, 0.050f, 24f, // base-content
127 + 0.64f, 0.076f, 19f, // primary
128 + 0.54f, 0.033f, 24f, // dim
129 + 0.0f, 0.0f, 0f, // accent: light's is pure black
123 130 isLight = true,
124 131 ),
125 132 oklchTheme("Her", "her", 0.18f, 0.095f, 24f, 0.22f, 0.070f, 24f, 0.90f, 0.035f, 24f, 0.64f, 0.076f, 19f, 0.42f, 0.060f, 22f),
@@ -128,9 +135,46 @@ val ALL_THEMES: List<AppTheme> = listOf(
128 135 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),
129 136 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),
130 137 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),
131 − 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),
138 + oklchTheme(
139 + "Dark", "dark",
140 + 0.3033f, 0.016f, 252.42f, // base-100
141 + 0.2526f, 0.014f, 253.1f, // base-200
142 + 0.97807f, 0.029f, 256.847f, // base-content
143 + 0.64f, 0.076f, 19f, // primary
144 + 0.71f, 0.024f, 255f, // dim
145 + 0.64f, 0.135f, 15f, // accent: the rose that replaced the violet
146 + ),
132 147 )
133 148
134 149 fun appThemeByKey(key: String): AppTheme = ALL_THEMES.firstOrNull { it.key == key } ?: ALL_THEMES.first()
135 150
151 +/**
152 + * Mirrors the web picker's "System" entry. Not a palette of its own: a
153 + * request to follow the OS, stored under the same name the web stores.
154 + */
155 +const val SYSTEM_THEME_KEY = "system"
156 +
157 +// The pair "system" resolves to -- the two themes actually named light and
158 +// dark, the same pairing the web bootstrap resolves to. Only these two are
159 +// picked automatically; every other theme is an explicit choice.
160 +private const val SYSTEM_LIGHT_KEY = "light"
161 +private const val SYSTEM_DARK_KEY = "dark"
162 +
163 +/**
164 + * Resolve a stored theme key to a palette. A null key -- nothing chosen yet
165 + * -- or [SYSTEM_THEME_KEY] follows the OS, and re-resolves when the OS flips
166 + * because isSystemInDarkTheme() is read during composition.
167 + */
168 +@Composable
169 +fun appThemeForKey(key: String?): AppTheme =
170 + if (key == null || key == SYSTEM_THEME_KEY) {
171 + appThemeByKey(if (isSystemInDarkTheme()) SYSTEM_DARK_KEY else SYSTEM_LIGHT_KEY)
172 + } else {
173 + appThemeByKey(key)
174 + }
175 +
176 +/** What the menu shows: "System" while following, else the palette's name. */
177 +fun themeLabel(key: String?, resolved: AppTheme): String =
178 + if (key == null || key == SYSTEM_THEME_KEY) "System" else resolved.name
179 +
136 180 val LocalAppTheme = compositionLocalOf { ALL_THEMES.first() }

Parents: 4a65e8c