neiam
/feedpug
feedpub
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
15512d0 · Gabriel Morell · 2026-09-14 04:29
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