neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
Android: repaint light/dark to match the web, and follow the OS
acf6e23 · Gabriel Morell · 2026-09-14 04:29
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