From 52f3296dc7cd32e9fe6e205fb02a96680110a76d Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 11 Sep 2026 12:47:26 +0200 Subject: [PATCH] feat(theme): follow the stored theme mode and dynamic colour The theme now reads the persisted preference instead of the device night flag alone. The system bar styles are resolved from the same value and re-applied when it changes, so a light theme forced under a dark system no longer draws white status bar icons onto a light background. Co-Authored-By: Claude Opus 5 --- .../jeanlucmakiola/clockula/MainActivity.kt | 54 ++++++++++++++++--- 1 file changed, 46 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/MainActivity.kt b/app/src/main/java/de/jeanlucmakiola/clockula/MainActivity.kt index 8009bf1..f0ff864 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/MainActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/MainActivity.kt @@ -1,38 +1,51 @@ package de.jeanlucmakiola.clockula import android.content.Intent +import android.graphics.Color import android.os.Bundle import androidx.activity.ComponentActivity +import androidx.activity.SystemBarStyle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle import dagger.hilt.android.AndroidEntryPoint +import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs import de.jeanlucmakiola.clockula.ui.crash.CrashReportActivity import de.jeanlucmakiola.clockula.ui.theme.ClockulaTheme import de.jeanlucmakiola.floret.crash.CrashReportDialog import de.jeanlucmakiola.floret.crash.CrashReporter import de.jeanlucmakiola.floret.crash.submitCrashReport +import de.jeanlucmakiola.floret.prefs.Appearance +import de.jeanlucmakiola.floret.prefs.isDark +import javax.inject.Inject /** - * Single activity. M0 scaffolding: it brings up the theme and the crash surface - * and nothing else. The four-tab shell and the live pill (docs/PLAN.md §9) land - * in M4, and the theme starts following stored preferences once the data layer - * exists in M2 — until then [ClockulaTheme]'s own defaults (follow-system, - * dynamic colour) stand in. + * Single activity. M1 scaffolding: it brings up the theme — now following the + * stored appearance preference — and the crash surface, and nothing else. The + * four-tab shell and the live pill (docs/PLAN.md §9) land in M4. */ @AndroidEntryPoint class MainActivity : ComponentActivity() { + // Field-injected rather than read through a ViewModel: the appearance feeds + // the theme before any screen exists. The settings ViewModel arrives with + // the settings screen in M10. + @Inject + lateinit var settingsPrefs: SettingsPrefs + // A captured crash report awaiting the user's decision, surfaced as a dialog // over the app on the next launch (the single-crash path). A startup // crash-loop is handled out of band, before setContent — see below. @@ -50,13 +63,27 @@ class MainActivity : ComponentActivity() { return } - enableEdgeToEdge() - // Surface a single captured crash as a dialog on the next launch. if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this) setContent { - ClockulaTheme { + // Until the first snapshot arrives, the family's defaults stand in — + // follow the system, dynamic colour on — so the first frame is never + // blocked on disk. + val appearance by settingsPrefs.appearance + .collectAsStateWithLifecycle(initialValue = Appearance()) + val darkTheme = appearance.themeMode.isDark(isSystemInDarkTheme()) + + // The system bars follow the *resolved* theme, not the device night + // flag: with the device dark and ThemeMode.LIGHT stored, deriving the + // icon colours from the system would paint white icons onto a light + // background. Re-applied whenever the stored preference changes. + LaunchedEffect(darkTheme) { applyEdgeToEdge(darkTheme) } + + ClockulaTheme( + darkTheme = darkTheme, + dynamicColor = appearance.dynamicColor, + ) { Placeholder(Modifier.fillMaxSize()) pendingCrashReport?.let { report -> CrashReportDialog( @@ -76,6 +103,13 @@ class MainActivity : ComponentActivity() { } } + private fun applyEdgeToEdge(darkTheme: Boolean) { + enableEdgeToEdge( + statusBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT) { darkTheme }, + navigationBarStyle = SystemBarStyle.auto(NAV_BAR_LIGHT_SCRIM, NAV_BAR_DARK_SCRIM) { darkTheme }, + ) + } + override fun onResume() { super.onResume() // A successful start breaks any loop; reset the timing trail so a later @@ -98,6 +132,10 @@ private fun Placeholder(modifier: Modifier = Modifier) { } } +/** The scrims androidx uses for a three-button navigation bar, matched exactly. */ +private val NAV_BAR_LIGHT_SCRIM = Color.argb(0xe6, 0xFF, 0xFF, 0xFF) +private val NAV_BAR_DARK_SCRIM = Color.argb(0x80, 0x1b, 0x1b, 0x1b) + @Composable private fun stringResourceAppName(): String = androidx.compose.ui.res.stringResource(R.string.app_name)