⭐
Log in to mark this repository.
Android: the real light/dark palettes, and a System theme that follows the OS
b3cf971 · Gabriel Morell · 2026-09-14 04:29
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