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 <noreply@anthropic.com>
This commit is contained in:
2026-09-11 12:47:26 +02:00
co-authored by Claude Opus 5
parent fc615e4622
commit 52f3296dc7
@@ -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)