neiam
/apollos_crib
It's Apollo's Crib
public · Issues · Pulls 1 · Labels · Forks · Compare · Actions failure · Badges · Packages
⭐
Log in to mark this repository.
Android: a real light/dark pair, and a System theme that follows the OS
874c837 · Gabriel Morell · 2026-09-14 04:29
Message
{commit_body(@commit)}
Files changed
modified
clients/android/app/src/main/java/io/neiam/apolloscrib/data/Settings.kt
+6
−4
@@ -1,5 +1,6 @@
| 1 | 1 | package io.neiam.apolloscrib.data |
| 2 | 2 | |
| 3 | +import io.neiam.apolloscrib.ui.theme.SYSTEM_THEME_KEY | |
| 3 | 4 | import android.content.Context |
| 4 | 5 | import android.content.SharedPreferences |
| 5 | 6 | import androidx.core.content.edit |
@@ -83,12 +84,13 @@ class Settings(context: Context) {
| 83 | 84 | set(value) = prefs.edit { putInt(KEY_RICH_CARDS, value.coerceIn(0, MAX_RICH_CARDS)) } |
| 84 | 85 | |
| 85 | 86 | /** |
| 86 | − * Which of the ported palettes the app's own screens use. Not a | |
| 87 | − * light/dark switch: these are the same named themes the Scribus routes | |
| 88 | − * render under, and the user picks one. | |
| 87 | + * Which of the ported palettes the app's own screens use. Mostly named | |
| 88 | + * themes the Scribus routes also render under, which the user picks; the | |
| 89 | + * exception is "system", the default, which follows the OS between the | |
| 90 | + * two palettes named light and dark the way the web frontends do. | |
| 89 | 91 | */ |
| 90 | 92 | var themeKey: String |
| 91 | − get() = prefs.getString(KEY_THEME, "after-dark").orEmpty() | |
| 93 | + get() = prefs.getString(KEY_THEME, SYSTEM_THEME_KEY).orEmpty() | |
| 92 | 94 | set(value) = prefs.edit { putString(KEY_THEME, value) } |
| 93 | 95 | |
| 94 | 96 | /** |
modified
clients/android/app/src/main/java/io/neiam/apolloscrib/feed/OverlayPanel.kt
+2
−2
@@ -23,7 +23,7 @@ import io.neiam.apolloscrib.data.Settings
| 23 | 23 | import io.neiam.apolloscrib.data.VisionStore |
| 24 | 24 | import io.neiam.apolloscrib.ui.BoardScreen |
| 25 | 25 | import io.neiam.apolloscrib.ui.theme.CribTheme |
| 26 | −import io.neiam.apolloscrib.ui.theme.appThemeByKey | |
| 26 | +import io.neiam.apolloscrib.ui.theme.appThemeForKey | |
| 27 | 27 | import kotlin.math.abs |
| 28 | 28 | |
| 29 | 29 | /** |
@@ -68,7 +68,7 @@ class OverlayPanel(
| 68 | 68 | val content = ComposeView(context).apply { |
| 69 | 69 | setContent { |
| 70 | 70 | val settings = Settings(context) |
| 71 | − CribTheme(theme = appThemeByKey(settings.themeKey)) { | |
| 71 | + CribTheme(theme = appThemeForKey(settings.themeKey)) { | |
| 72 | 72 | Surface(modifier = Modifier.fillMaxSize()) { |
| 73 | 73 | Scaffold { padding -> |
| 74 | 74 | BoardScreen( |
modified
clients/android/app/src/main/java/io/neiam/apolloscrib/ui/MainActivity.kt
+16
−2
@@ -50,7 +50,8 @@ import io.neiam.apolloscrib.widget.RichBoardWidget
| 50 | 50 | import io.neiam.apolloscrib.ui.theme.ALL_THEMES |
| 51 | 51 | import io.neiam.apolloscrib.ui.theme.CribTheme |
| 52 | 52 | import io.neiam.apolloscrib.ui.theme.LocalAppTheme |
| 53 | −import io.neiam.apolloscrib.ui.theme.appThemeByKey | |
| 53 | +import io.neiam.apolloscrib.ui.theme.SYSTEM_THEME_KEY | |
| 54 | +import io.neiam.apolloscrib.ui.theme.appThemeForKey | |
| 54 | 55 | import io.neiam.apolloscrib.data.VisionStore |
| 55 | 56 | import android.content.Intent |
| 56 | 57 | import android.widget.Toast |
@@ -89,7 +90,7 @@ class MainActivity : ComponentActivity() {
| 89 | 90 | // board is what the app is for; the form is a page you go back to. |
| 90 | 91 | var editing by remember(paired.value) { mutableStateOf(!settings.isConfigured) } |
| 91 | 92 | |
| 92 | − CribTheme(theme = appThemeByKey(themeKey)) { | |
| 93 | + CribTheme(theme = appThemeForKey(themeKey)) { | |
| 93 | 94 | Scaffold { padding -> |
| 94 | 95 | val modifier = Modifier.fillMaxSize().padding(padding) |
| 95 | 96 | if (editing) { |
@@ -383,6 +384,19 @@ private fun ConnectionScreen(
| 383 | 384 | modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), |
| 384 | 385 | horizontalArrangement = Arrangement.spacedBy(8.dp) |
| 385 | 386 | ) { |
| 387 | + // System first, as in the web picker. | |
| 388 | + AssistChip( | |
| 389 | + onClick = { onThemeChange(SYSTEM_THEME_KEY) }, | |
| 390 | + label = { Text("System") }, | |
| 391 | + colors = if (themeKey == SYSTEM_THEME_KEY) { | |
| 392 | + AssistChipDefaults.assistChipColors( | |
| 393 | + containerColor = MaterialTheme.colorScheme.primary, | |
| 394 | + labelColor = MaterialTheme.colorScheme.onPrimary | |
| 395 | + ) | |
| 396 | + } else { | |
| 397 | + AssistChipDefaults.assistChipColors() | |
| 398 | + } | |
| 399 | + ) | |
| 386 | 400 | ALL_THEMES.forEach { theme -> |
| 387 | 401 | AssistChip( |
| 388 | 402 | onClick = { onThemeChange(theme.key) }, |
modified
clients/android/app/src/main/java/io/neiam/apolloscrib/ui/theme/AppTheme.kt
+76
−24
@@ -1,8 +1,12 @@
| 1 | 1 | package io.neiam.apolloscrib.ui.theme |
| 2 | 2 | |
| 3 | +import android.content.Context | |
| 4 | +import android.content.res.Configuration | |
| 3 | 5 | import android.graphics.Color as AndroidColor |
| 6 | +import androidx.compose.foundation.isSystemInDarkTheme | |
| 4 | 7 | import androidx.compose.material3.darkColorScheme |
| 5 | 8 | import androidx.compose.material3.lightColorScheme |
| 9 | +import androidx.compose.runtime.Composable | |
| 6 | 10 | import androidx.compose.runtime.compositionLocalOf |
| 7 | 11 | import androidx.compose.ui.graphics.Color |
| 8 | 12 | import kotlin.math.cos |
@@ -102,6 +106,10 @@ private fun oklchTheme(
| 102 | 106 | contentL: Float, contentC: Float, contentH: Float, |
| 103 | 107 | primaryL: Float, primaryC: Float, primaryH: Float, |
| 104 | 108 | dimL: Float, dimC: Float, dimH: Float, |
| 109 | + // The six neiam themes all share daisyUI's yellow accent, so it | |
| 110 | + // defaults to that; light and dark pass their own explicitly. | |
| 111 | + accentL: Float = 0.96f, accentC: Float = 0.058f, accentH: Float = 96f, | |
| 112 | + isLight: Boolean = false, | |
| 105 | 113 | ) = AppTheme( |
| 106 | 114 | name = name, key = key, |
| 107 | 115 | bg = oklchToColor(bgL, bgC, bgH), |
@@ -109,33 +117,28 @@ private fun oklchTheme(
| 109 | 117 | content = oklchToColor(contentL, contentC, contentH), |
| 110 | 118 | primary = oklchToColor(primaryL, primaryC, primaryH), |
| 111 | 119 | dim = oklchToColor(dimL, dimC, dimH), |
| 112 | − accent = oklchToColor(0.96f, 0.058f, 96f), | |
| 120 | + accent = oklchToColor(accentL, accentC, accentH), | |
| 113 | 121 | liveGreen = oklchToColor(0.75f, 0.15f, 145f), |
| 122 | + isLight = isLight, | |
| 114 | 123 | ) |
| 115 | 124 | |
| 116 | −/** Plain Material 3 light defaults — handy escape hatch when the OKLCh | |
| 117 | − * palettes feel like overkill. Hex values come straight from M3's baseline | |
| 118 | − * light theme so it looks at home alongside Android stock components. */ | |
| 119 | −private object MaterialLight { | |
| 120 | − val BG = Color(0xFFFFFBFE) // surface | |
| 121 | − val CARD = Color(0xFFF7F2FA) // surfaceContainer | |
| 122 | − val CONTENT = Color(0xFF1D1B20) // onSurface | |
| 123 | − val PRIMARY = Color(0xFF6750A4) // primary | |
| 124 | − val DIM = Color(0xFF49454F) // onSurfaceVariant | |
| 125 | − val ACCENT = Color(0xFF7D5260) // tertiary | |
| 126 | − val LIVE_GREEN = Color(0xFF006E26) | |
| 127 | −} | |
| 128 | − | |
| 129 | 125 | val ALL_THEMES: List<AppTheme> = listOf( |
| 130 | − AppTheme( | |
| 131 | − name = "Light", key = "light", | |
| 132 | − bg = MaterialLight.BG, | |
| 133 | − cardBg = MaterialLight.CARD, | |
| 134 | − content = MaterialLight.CONTENT, | |
| 135 | − primary = MaterialLight.PRIMARY, | |
| 136 | − dim = MaterialLight.DIM, | |
| 137 | − accent = MaterialLight.ACCENT, | |
| 138 | − liveGreen = MaterialLight.LIVE_GREEN, | |
| 126 | + // light and dark are the pair the sibling apps' web frontends declare in | |
| 127 | + // daisyUI (bigtrippr, waxx, feedpug, gitgud): bg is --color-base-100, | |
| 128 | + // cardBg is --color-base-200, content is --color-base-content, and | |
| 129 | + // primary/accent are theirs. apollos-crib's own Scribus routes have no | |
| 130 | + // light/dark of their own, so these come from that shared set. `dim` has | |
| 131 | + // no web token -- it paints Material's outline and onSurfaceVariant -- so | |
| 132 | + // it is the 60% blend of content over bg, which is what the web writes as | |
| 133 | + // text-base-content/60. | |
| 134 | + oklchTheme( | |
| 135 | + "Light", "light", | |
| 136 | + 0.98f, 0.008f, 24f, // base-100 | |
| 137 | + 0.96f, 0.014f, 24f, // base-200 | |
| 138 | + 0.25f, 0.050f, 24f, // base-content | |
| 139 | + 0.64f, 0.076f, 19f, // primary | |
| 140 | + 0.54f, 0.033f, 24f, // dim | |
| 141 | + 0.0f, 0.0f, 0f, // accent: light's is pure black | |
| 139 | 142 | isLight = true, |
| 140 | 143 | ), |
| 141 | 144 | 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), |
@@ -144,9 +147,58 @@ val ALL_THEMES: List<AppTheme> = listOf(
| 144 | 147 | 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 | 148 | 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 | 149 | 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), | |
| 150 | + oklchTheme( | |
| 151 | + "Dark", "dark", | |
| 152 | + 0.3033f, 0.016f, 252.42f, // base-100 | |
| 153 | + 0.2526f, 0.014f, 253.1f, // base-200 | |
| 154 | + 0.97807f, 0.029f, 256.847f, // base-content | |
| 155 | + 0.64f, 0.076f, 19f, // primary | |
| 156 | + 0.71f, 0.024f, 255f, // dim | |
| 157 | + 0.64f, 0.135f, 15f, // accent | |
| 158 | + ), | |
| 148 | 159 | ) |
| 149 | 160 | |
| 150 | 161 | fun appThemeByKey(key: String): AppTheme = ALL_THEMES.firstOrNull { it.key == key } ?: ALL_THEMES.first() |
| 151 | 162 | |
| 163 | +/** | |
| 164 | + * Mirrors the web picker's "System" entry. Not a palette of its own: a | |
| 165 | + * request to follow the OS. | |
| 166 | + */ | |
| 167 | +const val SYSTEM_THEME_KEY = "system" | |
| 168 | + | |
| 169 | +// The pair "system" resolves to -- the two themes actually named light and | |
| 170 | +// dark. Only these two are picked automatically; every other palette here is | |
| 171 | +// a named thing the user chose. | |
| 172 | +private const val SYSTEM_LIGHT_KEY = "light" | |
| 173 | +private const val SYSTEM_DARK_KEY = "dark" | |
| 174 | + | |
| 175 | +/** | |
| 176 | + * Resolve a stored theme key to a palette, given whether the OS is in night | |
| 177 | + * mode. This overload exists for the home screen widgets, which build | |
| 178 | + * RemoteViews from a Context with no composition to read. | |
| 179 | + */ | |
| 180 | +fun appThemeForKey(key: String?, systemInDark: Boolean): AppTheme = | |
| 181 | + if (key == null || key == SYSTEM_THEME_KEY) { | |
| 182 | + appThemeByKey(if (systemInDark) SYSTEM_DARK_KEY else SYSTEM_LIGHT_KEY) | |
| 183 | + } else { | |
| 184 | + appThemeByKey(key) | |
| 185 | + } | |
| 186 | + | |
| 187 | +/** | |
| 188 | + * Resolve a stored theme key to a palette. [SYSTEM_THEME_KEY] follows the OS | |
| 189 | + * and re-resolves when it flips, because isSystemInDarkTheme() is read during | |
| 190 | + * composition. | |
| 191 | + */ | |
| 192 | +@Composable | |
| 193 | +fun appThemeForKey(key: String?): AppTheme = appThemeForKey(key, isSystemInDarkTheme()) | |
| 194 | + | |
| 195 | +/** Night mode off a Context, for the widgets' non-Compose resolution. */ | |
| 196 | +fun Context.systemInDarkMode(): Boolean = | |
| 197 | + resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == | |
| 198 | + Configuration.UI_MODE_NIGHT_YES | |
| 199 | + | |
| 200 | +/** What the picker marks as selected: "System" while following. */ | |
| 201 | +fun themeLabel(key: String?, resolved: AppTheme): String = | |
| 202 | + if (key == null || key == SYSTEM_THEME_KEY) "System" else resolved.name | |
| 203 | + | |
| 152 | 204 | val LocalAppTheme = compositionLocalOf { ALL_THEMES.first() } |
modified
clients/android/app/src/main/java/io/neiam/apolloscrib/ui/theme/CribTheme.kt
+5
−5
@@ -10,16 +10,16 @@ import io.neiam.apolloscrib.data.Settings
| 10 | 10 | /** |
| 11 | 11 | * The app's own screens: the ported palette plus B612. |
| 12 | 12 | * |
| 13 | − * Read from settings rather than from the system's light/dark, because the | |
| 14 | − * palettes are named things the user picked -- "Her", "Forest" -- not two | |
| 15 | − * ends of a switch. [LocalAppTheme] carries the colours Material has no slot | |
| 16 | − * for, such as `liveGreen` for a realtime departure. | |
| 13 | + * Read from settings, which holds either a named palette the user picked -- | |
| 14 | + * "Her", "Forest" -- or "system", in which case it follows the OS between | |
| 15 | + * the two named light and dark. [LocalAppTheme] carries the colours Material | |
| 16 | + * has no slot for, such as `liveGreen` for a realtime departure. | |
| 17 | 17 | */ |
| 18 | 18 | @Composable |
| 19 | 19 | fun CribTheme(content: @Composable () -> Unit) { |
| 20 | 20 | val context = LocalContext.current |
| 21 | 21 | val settings = remember { Settings(context) } |
| 22 | − CribTheme(theme = appThemeByKey(settings.themeKey), content = content) | |
| 22 | + CribTheme(theme = appThemeForKey(settings.themeKey), content = content) | |
| 23 | 23 | } |
| 24 | 24 | |
| 25 | 25 | @Composable |
modified
clients/android/app/src/main/java/io/neiam/apolloscrib/widget/BoardWidget.kt
+3
−2
@@ -11,7 +11,8 @@ import android.widget.RemoteViews
| 11 | 11 | import io.neiam.apolloscrib.R |
| 12 | 12 | import io.neiam.apolloscrib.mqtt.AnkyraService |
| 13 | 13 | import io.neiam.apolloscrib.ui.MainActivity |
| 14 | −import io.neiam.apolloscrib.ui.theme.appThemeByKey | |
| 14 | +import io.neiam.apolloscrib.ui.theme.appThemeForKey | |
| 15 | +import io.neiam.apolloscrib.ui.theme.systemInDarkMode | |
| 15 | 16 | import io.neiam.apolloscrib.data.Settings |
| 16 | 17 | |
| 17 | 18 | /** |
@@ -38,7 +39,7 @@ class BoardWidget : AppWidgetProvider() {
| 38 | 39 | |
| 39 | 40 | private fun build(context: Context, widgetId: Int): RemoteViews { |
| 40 | 41 | val settings = Settings(context) |
| 41 | − val palette = appThemeByKey(settings.themeKey) | |
| 42 | + val palette = appThemeForKey(settings.themeKey, context.systemInDarkMode()) | |
| 42 | 43 | |
| 43 | 44 | return RemoteViews(context.packageName, R.layout.widget_board).apply { |
| 44 | 45 | // Transparent takes the ground out entirely: the widget frame is |
modified
clients/android/app/src/main/java/io/neiam/apolloscrib/widget/BoardWidgetService.kt
+5
−3
@@ -10,7 +10,8 @@ import io.neiam.apolloscrib.data.VisionStore
| 10 | 10 | import io.neiam.apolloscrib.targets.Preview |
| 11 | 11 | import io.neiam.apolloscrib.targets.Targets |
| 12 | 12 | import io.neiam.apolloscrib.ui.theme.AppTheme |
| 13 | −import io.neiam.apolloscrib.ui.theme.appThemeByKey | |
| 13 | +import io.neiam.apolloscrib.ui.theme.appThemeForKey | |
| 14 | +import io.neiam.apolloscrib.ui.theme.systemInDarkMode | |
| 14 | 15 | |
| 15 | 16 | /** The cards the widget flips through. */ |
| 16 | 17 | class BoardWidgetService : RemoteViewsService() { |
@@ -22,7 +23,8 @@ private class BoardFactory(private val context: Context) : RemoteViewsService.Re
| 22 | 23 | |
| 23 | 24 | private val store = VisionStore(context) |
| 24 | 25 | private var cards: List<Preview> = emptyList() |
| 25 | − private var palette: AppTheme = appThemeByKey(Settings(context).themeKey) | |
| 26 | + private var palette: AppTheme = | |
| 27 | + appThemeForKey(Settings(context).themeKey, context.systemInDarkMode()) | |
| 26 | 28 | private var transparent: Boolean = Settings(context).transparentWidgets |
| 27 | 29 | |
| 28 | 30 | override fun onCreate() = Unit |
@@ -34,7 +36,7 @@ private class BoardFactory(private val context: Context) : RemoteViewsService.Re
| 34 | 36 | */ |
| 35 | 37 | override fun onDataSetChanged() { |
| 36 | 38 | val settings = Settings(context) |
| 37 | − palette = appThemeByKey(settings.themeKey) | |
| 39 | + palette = appThemeForKey(settings.themeKey, context.systemInDarkMode()) | |
| 38 | 40 | transparent = settings.transparentWidgets |
| 39 | 41 | // The Plus board where there is one: it carries the extended reading |
| 40 | 42 | // for transit and Basic's own answer for every type Plus does not |
modified
clients/android/app/src/main/java/io/neiam/apolloscrib/widget/RichBoardWidget.kt
+3
−2
@@ -11,7 +11,8 @@ import android.widget.RemoteViews
| 11 | 11 | import io.neiam.apolloscrib.R |
| 12 | 12 | import io.neiam.apolloscrib.mqtt.AnkyraService |
| 13 | 13 | import io.neiam.apolloscrib.ui.MainActivity |
| 14 | −import io.neiam.apolloscrib.ui.theme.appThemeByKey | |
| 14 | +import io.neiam.apolloscrib.ui.theme.appThemeForKey | |
| 15 | +import io.neiam.apolloscrib.ui.theme.systemInDarkMode | |
| 15 | 16 | import io.neiam.apolloscrib.data.Settings |
| 16 | 17 | |
| 17 | 18 | /** |
@@ -38,7 +39,7 @@ class RichBoardWidget : AppWidgetProvider() {
| 38 | 39 | |
| 39 | 40 | private fun build(context: Context, widgetId: Int): RemoteViews { |
| 40 | 41 | val settings = Settings(context) |
| 41 | − val palette = appThemeByKey(settings.themeKey) | |
| 42 | + val palette = appThemeForKey(settings.themeKey, context.systemInDarkMode()) | |
| 42 | 43 | |
| 43 | 44 | return RemoteViews(context.packageName, R.layout.widget_rich_board).apply { |
| 44 | 45 | // Transparent takes the ground out entirely: the widget frame is |
modified
clients/android/app/src/main/java/io/neiam/apolloscrib/widget/RichBoardWidgetService.kt
+5
−3
@@ -11,7 +11,8 @@ import io.neiam.apolloscrib.types.SourceType
| 11 | 11 | import io.neiam.apolloscrib.ui.RichFacts |
| 12 | 12 | import io.neiam.apolloscrib.ui.factsFor |
| 13 | 13 | import io.neiam.apolloscrib.ui.theme.AppTheme |
| 14 | −import io.neiam.apolloscrib.ui.theme.appThemeByKey | |
| 14 | +import io.neiam.apolloscrib.ui.theme.appThemeForKey | |
| 15 | +import io.neiam.apolloscrib.ui.theme.systemInDarkMode | |
| 15 | 16 | |
| 16 | 17 | /** |
| 17 | 18 | * The cards behind the detailed widget. |
@@ -31,14 +32,15 @@ class RichBoardWidgetService : RemoteViewsService() {
| 31 | 32 | |
| 32 | 33 | private val store = VisionStore(context) |
| 33 | 34 | private var cards: List<RichFacts> = emptyList() |
| 34 | − private var palette: AppTheme = appThemeByKey(Settings(context).themeKey) | |
| 35 | + private var palette: AppTheme = | |
| 36 | + appThemeForKey(Settings(context).themeKey, context.systemInDarkMode()) | |
| 35 | 37 | private var transparent: Boolean = Settings(context).transparentWidgets |
| 36 | 38 | |
| 37 | 39 | override fun onCreate() = Unit |
| 38 | 40 | |
| 39 | 41 | override fun onDataSetChanged() { |
| 40 | 42 | val settings = Settings(context) |
| 41 | − palette = appThemeByKey(settings.themeKey) | |
| 43 | + palette = appThemeForKey(settings.themeKey, context.systemInDarkMode()) | |
| 42 | 44 | transparent = settings.transparentWidgets |
| 43 | 45 | |
| 44 | 46 | // The Plus board where there is one, as the compact widget does: |
Parents: d0b8c96