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

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

15 files +119 -42
Message
{commit_body(@commit)}

Files changed

modified android/app/src/main/java/org/neiam/dms/app/MainActivity.kt
+15 −7
@@ -22,10 +22,10 @@ import org.neiam.dms.app.ui.DetailScreen
22 22 import org.neiam.dms.app.ui.HomeScreen
23 23 import org.neiam.dms.app.ui.PairScreen
24 24 import org.neiam.dms.app.ui.SearchScreen
25 −import org.neiam.dms.app.ui.theme.ALL_THEMES
26 25 import org.neiam.dms.app.ui.theme.AppTheme
27 26 import org.neiam.dms.app.ui.theme.DmsTheme
28 −import org.neiam.dms.app.ui.theme.appThemeByKey
27 +import org.neiam.dms.app.ui.theme.SYSTEM_THEME_KEY
28 +import org.neiam.dms.app.ui.theme.appThemeForKey
29 29
30 30 class MainActivity : ComponentActivity() {
31 31 override fun onCreate(savedInstanceState: Bundle?) {
@@ -35,18 +35,24 @@ class MainActivity : ComponentActivity() {
35 35 val deepLinkPayload = intent?.let { extractPairPayload(it) }
36 36
37 37 setContent {
38 − var theme by remember {
39 − mutableStateOf(themeStore.themeKey?.let(::appThemeByKey) ?: ALL_THEMES.first())
38 + // Nothing stored means follow the OS, the same default as the
39 + // web, where an absent phx:theme is "system". The key is what
40 + // lives in state -- resolving inside composition is what lets an
41 + // OS light/dark flip recompose into the other half of the pair.
42 + var themeKey by remember {
43 + mutableStateOf(themeStore.themeKey ?: SYSTEM_THEME_KEY)
40 44 }
45 + val theme = appThemeForKey(themeKey)
41 46 DmsTheme(theme = theme) {
42 47 Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
43 48 DmsApp(
44 49 tokens = tokens,
45 50 initialDeepLink = deepLinkPayload,
46 51 currentTheme = theme,
52 + currentThemeKey = themeKey,
47 53 onPickTheme = { picked ->
48 − theme = picked
49 − themeStore.themeKey = picked.key
54 + themeKey = picked
55 + themeStore.themeKey = picked
50 56 },
51 57 )
52 58 }
@@ -65,7 +71,8 @@ fun DmsApp(
65 71 tokens: TokenStore,
66 72 initialDeepLink: PairPayload?,
67 73 currentTheme: AppTheme,
68 − onPickTheme: (AppTheme) -> Unit,
74 + currentThemeKey: String,
75 + onPickTheme: (String) -> Unit,
69 76 ) {
70 77 val nav = rememberNavController()
71 78 val initialRoute = remember(initialDeepLink, tokens) {
@@ -92,6 +99,7 @@ fun DmsApp(
92 99 HomeScreen(
93 100 tokens = tokens,
94 101 currentTheme = currentTheme,
102 + currentThemeKey = currentThemeKey,
95 103 onPickTheme = onPickTheme,
96 104 onUnpair = {
97 105 tokens.clear()
modified android/app/src/main/java/org/neiam/dms/app/ui/HomeScreen.kt
+38 −7
@@ -1,5 +1,6 @@
1 1 package org.neiam.dms.app.ui
2 2
3 +import androidx.compose.foundation.clickable
3 4 import androidx.compose.foundation.layout.*
4 5 import androidx.compose.material.icons.Icons
5 6 import androidx.compose.material.icons.filled.MoreVert
@@ -17,6 +18,9 @@ import org.neiam.dms.app.data.DmsClient
17 18 import org.neiam.dms.app.data.TokenStore
18 19 import org.neiam.dms.app.data.UploadSummary
19 20 import org.neiam.dms.app.ui.theme.ALL_THEMES
21 +import org.neiam.dms.app.ui.theme.SYSTEM_THEME_KEY
22 +import org.neiam.dms.app.ui.theme.appThemeForKey
23 +import org.neiam.dms.app.ui.theme.themeLabel
20 24 import org.neiam.dms.app.ui.theme.AppTheme
21 25 import org.neiam.dms.app.ui.theme.LocalAppTheme
22 26
@@ -31,7 +35,8 @@ import org.neiam.dms.app.ui.theme.LocalAppTheme
31 35 fun HomeScreen(
32 36 tokens: TokenStore,
33 37 currentTheme: AppTheme,
34 − onPickTheme: (AppTheme) -> Unit,
38 + currentThemeKey: String,
39 + onPickTheme: (String) -> Unit,
35 40 onUnpair: () -> Unit,
36 41 onOpenUpload: (String) -> Unit,
37 42 onOpenSearch: () -> Unit,
@@ -114,7 +119,7 @@ fun HomeScreen(
114 119 }
115 120 DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
116 121 DropdownMenuItem(
117 − text = { Text("Theme: ${currentTheme.name}") },
122 + text = { Text("Theme: ${themeLabel(currentThemeKey, currentTheme)}") },
118 123 leadingIcon = { Icon(Icons.Default.Palette, contentDescription = null) },
119 124 onClick = { menuOpen = false; themePickerOpen = true },
120 125 )
@@ -166,7 +171,7 @@ fun HomeScreen(
166 171
167 172 if (themePickerOpen) {
168 173 ThemePickerDialog(
169 − current = currentTheme,
174 + current = currentThemeKey,
170 175 onDismiss = { themePickerOpen = false },
171 176 onPick = { picked ->
172 177 themePickerOpen = false
@@ -178,15 +183,41 @@ fun HomeScreen(
178 183
179 184 @Composable
180 185 private fun ThemePickerDialog(
181 − current: AppTheme,
186 + current: String,
182 187 onDismiss: () -> Unit,
183 − onPick: (AppTheme) -> Unit,
188 + onPick: (String) -> Unit,
184 189 ) {
185 190 AlertDialog(
186 191 onDismissRequest = onDismiss,
187 192 title = { Text("Theme") },
188 193 text = {
189 194 Column {
195 + // System first, as in the web picker. Its swatches show the
196 + // half of the pair the OS is currently asking for, so the row
197 + // previews what picking it would actually do.
198 + val systemTheme = appThemeForKey(SYSTEM_THEME_KEY)
199 + Row(
200 + verticalAlignment = Alignment.CenterVertically,
201 + modifier = Modifier
202 + .fillMaxWidth()
203 + .clickable { onPick(SYSTEM_THEME_KEY) }
204 + .padding(vertical = 4.dp),
205 + ) {
206 + RadioButton(
207 + selected = current == SYSTEM_THEME_KEY,
208 + onClick = { onPick(SYSTEM_THEME_KEY) },
209 + )
210 + Spacer(Modifier.width(8.dp))
211 + Column(modifier = Modifier.weight(1f)) {
212 + Text("System")
213 + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
214 + Swatch(systemTheme.bg)
215 + Swatch(systemTheme.cardBg)
216 + Swatch(systemTheme.primary)
217 + Swatch(systemTheme.accent)
218 + }
219 + }
220 + }
190 221 ALL_THEMES.forEach { theme ->
191 222 Row(
192 223 verticalAlignment = Alignment.CenterVertically,
@@ -195,8 +226,8 @@ private fun ThemePickerDialog(
195 226 .padding(vertical = 4.dp),
196 227 ) {
197 228 RadioButton(
198 − selected = theme.key == current.key,
199 − onClick = { onPick(theme) },
229 + selected = theme.key == current,
230 + onClick = { onPick(theme.key) },
200 231 )
201 232 Spacer(Modifier.width(8.dp))
202 233 // Tiny swatch row so the user can preview each palette.
modified android/app/src/main/java/org/neiam/dms/app/ui/ShareReceiverActivity.kt
+6 −4
@@ -23,9 +23,9 @@ import kotlinx.coroutines.withContext
23 23 import org.neiam.dms.app.data.ThemeStore
24 24 import org.neiam.dms.app.data.TokenStore
25 25 import org.neiam.dms.app.data.displayName
26 −import org.neiam.dms.app.ui.theme.ALL_THEMES
27 26 import org.neiam.dms.app.ui.theme.DmsTheme
28 −import org.neiam.dms.app.ui.theme.appThemeByKey
27 +import org.neiam.dms.app.ui.theme.SYSTEM_THEME_KEY
28 +import org.neiam.dms.app.ui.theme.appThemeForKey
29 29 import org.neiam.dms.app.upload.StagedUpload
30 30 import org.neiam.dms.app.upload.UploadService
31 31 import java.io.File
@@ -45,9 +45,11 @@ class ShareReceiverActivity : ComponentActivity() {
45 45 finish(); return
46 46 }
47 47
48 − val theme = ThemeStore(this).themeKey?.let(::appThemeByKey) ?: ALL_THEMES.first()
48 + // Resolved inside setContent so "system" reads the OS from the
49 + // composition, like every other screen.
50 + val themeKey = ThemeStore(this).themeKey ?: SYSTEM_THEME_KEY
49 51 setContent {
50 − DmsTheme(theme = theme) {
52 + DmsTheme(theme = appThemeForKey(themeKey)) {
51 53 ShareUploadDialog(
52 54 uris = items,
53 55 onUpload = { tags -> handoffToService(creds, items, tags) },
modified android/app/src/main/java/org/neiam/dms/app/ui/theme/AppTheme.kt
+60 −24
@@ -1,8 +1,10 @@
1 1 package org.neiam.dms.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
@@ -99,6 +101,10 @@ private fun oklchTheme(
99 101 contentL: Float, contentC: Float, contentH: Float,
100 102 primaryL: Float, primaryC: Float, primaryH: Float,
101 103 dimL: Float, dimC: Float, dimH: Float,
104 + // The six neiam themes all share daisyUI's yellow accent, so it
105 + // defaults to that; light and dark pass their own explicitly.
106 + accentL: Float = 0.96f, accentC: Float = 0.058f, accentH: Float = 96f,
107 + isLight: Boolean = false,
102 108 ) = AppTheme(
103 109 name = name, key = key,
104 110 bg = oklchToColor(bgL, bgC, bgH),
@@ -106,33 +112,26 @@ private fun oklchTheme(
106 112 content = oklchToColor(contentL, contentC, contentH),
107 113 primary = oklchToColor(primaryL, primaryC, primaryH),
108 114 dim = oklchToColor(dimL, dimC, dimH),
109 − accent = oklchToColor(0.96f, 0.058f, 96f),
115 + accent = oklchToColor(accentL, accentC, accentH),
110 116 liveGreen = oklchToColor(0.75f, 0.15f, 145f),
117 + isLight = isLight,
111 118 )
112 119
113 −/** Plain Material 3 light defaults — handy escape hatch when the OKLCh
114 − * palettes feel like overkill. Hex values come straight from M3's baseline
115 − * light theme so it looks at home alongside Android stock components. */
116 −private object MaterialLight {
117 − val BG = Color(0xFFFFFBFE) // surface
118 − val CARD = Color(0xFFF7F2FA) // surfaceContainer
119 − val CONTENT = Color(0xFF1D1B20) // onSurface
120 − val PRIMARY = Color(0xFF6750A4) // primary
121 − val DIM = Color(0xFF49454F) // onSurfaceVariant
122 − val ACCENT = Color(0xFF7D5260) // tertiary
123 − val LIVE_GREEN = Color(0xFF006E26)
124 −}
125 −
126 120 val ALL_THEMES: List<AppTheme> = listOf(
127 − AppTheme(
128 − name = "Light", key = "light",
129 − bg = MaterialLight.BG,
130 − cardBg = MaterialLight.CARD,
131 − content = MaterialLight.CONTENT,
132 − primary = MaterialLight.PRIMARY,
133 − dim = MaterialLight.DIM,
134 − accent = MaterialLight.ACCENT,
135 − liveGreen = MaterialLight.LIVE_GREEN,
121 + // light and dark below are the daisyUI themes of the same names in
122 + // assets/f/css/style.css, token for token: bg is --color-base-100, cardBg is
123 + // --color-base-200, content is --color-base-content, and primary/accent
124 + // are theirs. `dim` has no web token -- it paints Material's outline and
125 + // onSurfaceVariant -- so it is the 60% blend of content over bg, which is
126 + // what the web writes as text-base-content/60.
127 + oklchTheme(
128 + "Light", "light",
129 + 0.98f, 0.008f, 24f, // base-100
130 + 0.96f, 0.014f, 24f, // base-200
131 + 0.25f, 0.050f, 24f, // base-content
132 + 0.64f, 0.076f, 19f, // primary
133 + 0.54f, 0.033f, 24f, // dim
134 + 0.0f, 0.0f, 0f, // accent: light's is pure black
136 135 isLight = true,
137 136 ),
138 137 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),
@@ -141,9 +140,46 @@ val ALL_THEMES: List<AppTheme> = listOf(
141 140 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),
142 141 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),
143 142 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),
144 − 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),
143 + oklchTheme(
144 + "Dark", "dark",
145 + 0.3033f, 0.016f, 252.42f, // base-100
146 + 0.2526f, 0.014f, 253.1f, // base-200
147 + 0.97807f, 0.029f, 256.847f, // base-content
148 + 0.64f, 0.076f, 19f, // primary
149 + 0.71f, 0.024f, 255f, // dim
150 + 0.64f, 0.135f, 15f, // accent: the rose that replaced the violet
151 + ),
145 152 )
146 153
147 154 fun appThemeByKey(key: String): AppTheme = ALL_THEMES.firstOrNull { it.key == key } ?: ALL_THEMES.first()
148 155
156 +/**
157 + * Mirrors the web picker's "System" entry. Not a palette of its own: a
158 + * request to follow the OS, stored under the same name the web stores.
159 + */
160 +const val SYSTEM_THEME_KEY = "system"
161 +
162 +// The pair "system" resolves to -- the two themes actually named light and
163 +// dark, the same pairing the web bootstrap resolves to. Only these two are
164 +// picked automatically; every other theme is an explicit choice.
165 +private const val SYSTEM_LIGHT_KEY = "light"
166 +private const val SYSTEM_DARK_KEY = "dark"
167 +
168 +/**
169 + * Resolve a stored theme key to a palette. A null key -- nothing chosen yet
170 + * -- or [SYSTEM_THEME_KEY] follows the OS, and re-resolves when the OS flips
171 + * because isSystemInDarkTheme() is read during composition.
172 + */
173 +@Composable
174 +fun appThemeForKey(key: String?): AppTheme =
175 + if (key == null || key == SYSTEM_THEME_KEY) {
176 + appThemeByKey(if (isSystemInDarkTheme()) SYSTEM_DARK_KEY else SYSTEM_LIGHT_KEY)
177 + } else {
178 + appThemeByKey(key)
179 + }
180 +
181 +/** What the menu shows: "System" while following, else the palette's name. */
182 +fun themeLabel(key: String?, resolved: AppTheme): String =
183 + if (key == null || key == SYSTEM_THEME_KEY) "System" else resolved.name
184 +
149 185 val LocalAppTheme = compositionLocalOf { ALL_THEMES.first() }

Parents: d9ed3b6