Android: repaint light/dark to match the web, and follow the OS

acf6e23 · Gabriel Morell · 2026-09-14 04:29

14 files +108 -24
Message
{commit_body(@commit)}

Files changed

modified android/app/src/main/java/org/neiam/gitgud/app/MainActivity.kt
+15 −7
@@ -29,10 +29,10 @@ import org.neiam.gitgud.app.ui.RepoListScreen
29 29 import org.neiam.gitgud.app.ui.RepoScreen
30 30 import org.neiam.gitgud.app.ui.RunScreen
31 31 import org.neiam.gitgud.app.ui.WikiPageScreen
32 −import org.neiam.gitgud.app.ui.theme.ALL_THEMES
33 32 import org.neiam.gitgud.app.ui.theme.AppTheme
34 33 import org.neiam.gitgud.app.ui.theme.GitGudTheme
35 −import org.neiam.gitgud.app.ui.theme.appThemeByKey
34 +import org.neiam.gitgud.app.ui.theme.SYSTEM_THEME_KEY
35 +import org.neiam.gitgud.app.ui.theme.appThemeForKey
36 36
37 37 /**
38 38 * Single-activity host.
@@ -53,9 +53,14 @@ class MainActivity : ComponentActivity() {
53 53 val initialPair = parseIntent(intent)
54 54
55 55 setContent {
56 − var theme by remember {
57 − mutableStateOf(prefs.themeKey?.let(::appThemeByKey) ?: ALL_THEMES.first())
56 + // Nothing stored means follow the OS, the same default as the
57 + // web, where an absent phx:theme is "system". The key is what
58 + // lives in state -- resolving inside composition is what lets an
59 + // OS light/dark flip recompose into the other half of the pair.
60 + var themeKey by remember {
61 + mutableStateOf(prefs.themeKey ?: SYSTEM_THEME_KEY)
58 62 }
63 + val theme = appThemeForKey(themeKey)
59 64 GitGudTheme(theme = theme) {
60 65 Surface(
61 66 modifier = Modifier.fillMaxSize(),
@@ -66,9 +71,10 @@ class MainActivity : ComponentActivity() {
66 71 prefs = prefs,
67 72 initialPair = initialPair,
68 73 currentTheme = theme,
74 + currentThemeKey = themeKey,
69 75 onPickTheme = { picked ->
70 − theme = picked
71 − prefs.themeKey = picked.key
76 + themeKey = picked
77 + prefs.themeKey = picked
72 78 },
73 79 )
74 80 }
@@ -88,7 +94,8 @@ fun GitGudApp(
88 94 prefs: PrefsStore,
89 95 initialPair: PairPayload?,
90 96 currentTheme: AppTheme,
91 − onPickTheme: (AppTheme) -> Unit,
97 + currentThemeKey: String,
98 + onPickTheme: (String) -> Unit,
92 99 ) {
93 100 val nav = rememberNavController()
94 101 val start = remember(tokens, initialPair) {
@@ -112,6 +119,7 @@ fun GitGudApp(
112 119 tokens = tokens,
113 120 prefs = prefs,
114 121 currentTheme = currentTheme,
122 + currentThemeKey = currentThemeKey,
115 123 onPickTheme = onPickTheme,
116 124 onOpenRepo = { owner, name -> nav.navigate("repo/$owner/$name") },
117 125 onUnpair = {
modified android/app/src/main/java/org/neiam/gitgud/app/ui/RepoListScreen.kt
+37 −7
@@ -58,6 +58,9 @@ import org.neiam.gitgud.app.data.Repository
58 58 import org.neiam.gitgud.app.data.ReposResponse
59 59 import org.neiam.gitgud.app.data.TokenStore
60 60 import org.neiam.gitgud.app.ui.theme.ALL_THEMES
61 +import org.neiam.gitgud.app.ui.theme.SYSTEM_THEME_KEY
62 +import org.neiam.gitgud.app.ui.theme.appThemeForKey
63 +import org.neiam.gitgud.app.ui.theme.themeLabel
61 64 import org.neiam.gitgud.app.ui.theme.AppTheme
62 65
63 66 /**
@@ -76,7 +79,8 @@ fun RepoListScreen(
76 79 tokens: TokenStore,
77 80 prefs: PrefsStore,
78 81 currentTheme: AppTheme,
79 − onPickTheme: (AppTheme) -> Unit,
82 + currentThemeKey: String,
83 + onPickTheme: (String) -> Unit,
80 84 onOpenRepo: (owner: String, name: String) -> Unit,
81 85 onUnpair: () -> Unit,
82 86 ) {
@@ -123,7 +127,7 @@ fun RepoListScreen(
123 127 },
124 128 )
125 129 DropdownMenuItem(
126 − text = { Text("Theme: ${currentTheme.name}") },
130 + text = { Text("Theme: ${themeLabel(currentThemeKey, currentTheme)}") },
127 131 onClick = {
128 132 menuOpen = false
129 133 themePickerOpen = true
@@ -196,7 +200,7 @@ fun RepoListScreen(
196 200
197 201 if (themePickerOpen) {
198 202 ThemePickerDialog(
199 − current = currentTheme,
203 + current = currentThemeKey,
200 204 onDismiss = { themePickerOpen = false },
201 205 onPick = {
202 206 themePickerOpen = false
@@ -334,24 +338,50 @@ private fun SortPickerDialog(
334 338
335 339 @Composable
336 340 private fun ThemePickerDialog(
337 − current: AppTheme,
341 + current: String,
338 342 onDismiss: () -> Unit,
339 − onPick: (AppTheme) -> Unit,
343 + onPick: (String) -> Unit,
340 344 ) {
341 345 AlertDialog(
342 346 onDismissRequest = onDismiss,
343 347 title = { Text("Theme") },
344 348 text = {
345 349 Column {
350 + // System first, as in the web picker. Its swatches show the
351 + // half of the pair the OS is currently asking for, so the row
352 + // previews what picking it would actually do.
353 + val systemTheme = appThemeForKey(SYSTEM_THEME_KEY)
354 + Row(
355 + verticalAlignment = Alignment.CenterVertically,
356 + modifier = Modifier
357 + .fillMaxWidth()
358 + .clickable { onPick(SYSTEM_THEME_KEY) }
359 + .padding(vertical = 4.dp),
360 + ) {
361 + RadioButton(
362 + selected = current == SYSTEM_THEME_KEY,
363 + onClick = { onPick(SYSTEM_THEME_KEY) },
364 + )
365 + Spacer(Modifier.width(8.dp))
366 + Column(modifier = Modifier.weight(1f)) {
367 + Text("System")
368 + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
369 + Swatch(systemTheme.bg)
370 + Swatch(systemTheme.cardBg)
371 + Swatch(systemTheme.primary)
372 + Swatch(systemTheme.accent)
373 + }
374 + }
375 + }
346 376 ALL_THEMES.forEach { theme ->
347 377 Row(
348 378 verticalAlignment = Alignment.CenterVertically,
349 379 modifier = Modifier
350 380 .fillMaxWidth()
351 − .clickable { onPick(theme) }
381 + .clickable { onPick(theme.key) }
352 382 .padding(vertical = 4.dp),
353 383 ) {
354 − RadioButton(selected = theme.key == current.key, onClick = { onPick(theme) })
384 + RadioButton(selected = theme.key == current, onClick = { onPick(theme.key) })
355 385 Spacer(Modifier.width(8.dp))
356 386 Column(modifier = Modifier.weight(1f)) {
357 387 Text(theme.name)
modified android/app/src/main/java/org/neiam/gitgud/app/ui/theme/AppTheme.kt
+56 −10
@@ -1,8 +1,10 @@
1 1 package org.neiam.gitgud.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
@@ -115,6 +117,7 @@ private fun oklchTheme(
115 117 primaryL: Float, primaryC: Float, primaryH: Float,
116 118 dimL: Float, dimC: Float, dimH: Float,
117 119 accentL: Float = 0.96f, accentC: Float = 0.058f, accentH: Float = 96f,
120 + isLight: Boolean = false,
118 121 ) = AppTheme(
119 122 name = name, key = key,
120 123 bg = oklchToColor(bgL, bgC, bgH),
@@ -126,6 +129,7 @@ private fun oklchTheme(
126 129 success = SUCCESS,
127 130 danger = DANGER,
128 131 pending = PENDING,
132 + isLight = isLight,
129 133 )
130 134
131 135 /**
@@ -135,16 +139,29 @@ private fun oklchTheme(
135 139 * `primary` → primary, `secondary` → dim, `accent` → accent.
136 140 */
137 141 val ALL_THEMES: List<AppTheme> = listOf(
138 − oklchTheme("Dark", "dark", 0.3033f, 0.016f, 252.42f, 0.2526f, 0.014f, 253.1f, 0.978f, 0.029f, 256.847f, 0.58f, 0.233f, 277.117f, 0.58f, 0.233f, 277.117f, 0.60f, 0.25f, 292.717f),
139 − AppTheme(
140 − name = "Light", key = "light",
141 − bg = oklchToColor(0.98f, 0f, 0f),
142 − cardBg = oklchToColor(0.96f, 0.001f, 286.375f),
143 − content = oklchToColor(0.21f, 0.006f, 285.885f),
144 − primary = oklchToColor(0.70f, 0.213f, 47.604f),
145 − dim = oklchToColor(0.55f, 0.027f, 264.364f),
146 − accent = oklchToColor(0.30f, 0f, 0f),
147 − success = SUCCESS, danger = DANGER, pending = PENDING,
142 + oklchTheme(
143 + "Dark", "dark",
144 + 0.3033f, 0.016f, 252.42f, // base-100
145 + 0.2526f, 0.014f, 253.1f, // base-200
146 + 0.97807f, 0.029f, 256.847f, // base-content
147 + 0.64f, 0.076f, 19f, // primary
148 + 0.71f, 0.024f, 255f, // dim
149 + 0.64f, 0.135f, 15f, // accent: the rose that replaced the violet
150 + ),
151 + // light and dark below are the daisyUI themes of the same names in
152 + // assets/css/app.css, token for token: bg is --color-base-100, cardBg is
153 + // --color-base-200, content is --color-base-content, and primary/accent
154 + // are theirs. `dim` has no web token -- it paints Material's outline and
155 + // onSurfaceVariant -- so it is the 60% blend of content over bg, which is
156 + // what the web writes as text-base-content/60.
157 + oklchTheme(
158 + "Light", "light",
159 + 0.98f, 0.008f, 24f, // base-100
160 + 0.96f, 0.014f, 24f, // base-200
161 + 0.25f, 0.050f, 24f, // base-content
162 + 0.64f, 0.076f, 19f, // primary
163 + 0.54f, 0.033f, 24f, // dim
164 + 0.0f, 0.0f, 0f, // accent: light's is pure black
148 165 isLight = true,
149 166 ),
150 167 oklchTheme("Her", "her", 0.35f, 0.104f, 24f, 0.30f, 0.104f, 24f, 0.98f, 0.050f, 24f, 0.64f, 0.076f, 19f, 0.78f, 0.049f, 18f),
@@ -159,4 +176,33 @@ val ALL_THEMES: List<AppTheme> = listOf(
159 176 fun appThemeByKey(key: String): AppTheme =
160 177 ALL_THEMES.firstOrNull { it.key == key } ?: ALL_THEMES.first()
161 178
179 +/**
180 + * Mirrors the web picker's "System" entry. Not a palette of its own: a
181 + * request to follow the OS, stored under the same name the web stores.
182 + */
183 +const val SYSTEM_THEME_KEY = "system"
184 +
185 +// The pair "system" resolves to -- the two themes actually named light and
186 +// dark, the same pairing the web bootstrap resolves to. Only these two are
187 +// picked automatically; every other theme is an explicit choice.
188 +private const val SYSTEM_LIGHT_KEY = "light"
189 +private const val SYSTEM_DARK_KEY = "dark"
190 +
191 +/**
192 + * Resolve a stored theme key to a palette. A null key -- nothing chosen yet
193 + * -- or [SYSTEM_THEME_KEY] follows the OS, and re-resolves when the OS flips
194 + * because isSystemInDarkTheme() is read during composition.
195 + */
196 +@Composable
197 +fun appThemeForKey(key: String?): AppTheme =
198 + if (key == null || key == SYSTEM_THEME_KEY) {
199 + appThemeByKey(if (isSystemInDarkTheme()) SYSTEM_DARK_KEY else SYSTEM_LIGHT_KEY)
200 + } else {
201 + appThemeByKey(key)
202 + }
203 +
204 +/** What the menu shows: "System" while following, else the palette's name. */
205 +fun themeLabel(key: String?, resolved: AppTheme): String =
206 + if (key == null || key == SYSTEM_THEME_KEY) "System" else resolved.name
207 +
162 208 val LocalAppTheme = compositionLocalOf { ALL_THEMES.first() }

Parents: ff5c190