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

874c837 · Gabriel Morell · 2026-09-14 04:29

23 files +121 -47
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