Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,34 @@ class ThemeModeUiContractGuardTest {
}

@Test
fun commonTheme_mapsSystemToSystemDarkTheme() {
fun commonTheme_mapsSystemToLifecycleSafePlatformDark() {
val source = read("shared/src/commonMain/kotlin/com/devil/phoenixproject/ui/theme/Theme.kt")

assertTrue(
source.contains("ThemeMode.SYSTEM -> isSystemInDarkTheme()"),
"System theme mode must continue to follow the platform system dark-theme signal.",
source.contains("ThemeMode.SYSTEM -> rememberPlatformSystemDark()"),
"System theme mode must use the lifecycle-safe platform dark signal (Configuration.uiMode on Android) rather than the transient isSystemInDarkTheme().",
)
assertFalse(
source.contains("isSystemInDarkTheme()"),
"Theme.kt must not directly call isSystemInDarkTheme(); use rememberPlatformSystemDark() for lifecycle-safe resume reconciliation.",
)
}

@Test
fun androidSystemDarkDiagnostic_readsTheLatestComposeSignalOnResume() {
val source = read("shared/src/androidMain/kotlin/com/devil/phoenixproject/ui/theme/PlatformSystemDark.android.kt")

assertTrue(
source.contains("Lifecycle.Event.ON_RESUME") && source.contains("isDark = refreshed"),
"The Android-owned theme source must reconcile its state from Configuration.uiMode on every lifecycle resume so lock/unlock can repair a stale system appearance.",
)
assertTrue(
source.contains("val currentComposeSignal by rememberUpdatedState(composeSignal)"),
"The lifecycle observer must bridge a recomposed isSystemInDarkTheme() value with rememberUpdatedState so ON_RESUME does not compare Configuration.uiMode with the first composition's stale Compose signal.",
)
assertTrue(
source.contains("currentComposeSignal != refreshed"),
"The resume mismatch diagnostic must compare Configuration.uiMode with the current Compose signal rather than the observer's initial captured value.",
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package com.devil.phoenixproject.ui.theme

import android.content.res.Configuration
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import co.touchlab.kermit.Logger

private val log = Logger.withTag("PlatformSystemDark")

/**
* Android-owned lifecycle-safe system appearance source.
*
* Seeds from `Configuration.uiMode` on first composition and refreshes on every
* `ON_RESUME` event so lock/unlock, display-mode changes, and other configuration
* transitions are captured. Logs a mismatch when the Compose
* `isSystemInDarkTheme()` signal disagrees with the Android-owned value — this
* telemetry is the first diagnostic that would have caught issue #677.
*/
@Composable
actual fun rememberPlatformSystemDark(): Boolean {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current

fun readUiModeDark(): Boolean {
val nightMask = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
return nightMask == Configuration.UI_MODE_NIGHT_YES
}

var isDark by remember { mutableStateOf(readUiModeDark()) }

// Capture the Compose system-dark signal during composition and bridge its latest
// value into the long-lived lifecycle observer for each resume diagnostic.
val composeSignal = isSystemInDarkTheme()
val currentComposeSignal by rememberUpdatedState(composeSignal)

DisposableEffect(lifecycleOwner) {
Comment thread
9thLevelSoftware marked this conversation as resolved.
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
val refreshed = readUiModeDark()
if (refreshed != isDark) {
log.i { "System dark mode changed on resume: $isDark -> $refreshed" }
}
isDark = refreshed

// Diagnostic: log mismatch between Android-owned value and the latest
// recomposed Compose signal. `rememberUpdatedState` keeps this observer
// current without re-registering it on every recomposition.
if (currentComposeSignal != refreshed) {
log.w {
"MISMATCH: Configuration.uiMode says dark=$refreshed " +
"but Compose isSystemInDarkTheme() says dark=$currentComposeSignal"
}
}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}

return isDark
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.devil.phoenixproject.ui.theme

import androidx.compose.runtime.Composable

/**
* Returns the platform-owned system dark-mode state, refreshed on lifecycle resume.
*
* On Android this reads `Configuration.uiMode` (the OS-owned source of truth) and
* is refreshed on `ON_RESUME` so lock/unlock and other configuration changes are
* captured without relying on the transient Compose `isSystemInDarkTheme()` signal.
*
* On iOS this delegates to `isSystemInDarkTheme()` which is already lifecycle-stable.
*
* Use this instead of `isSystemInDarkTheme()` when resolving `ThemeMode.SYSTEM`.
*/
@Composable
expect fun rememberPlatformSystemDark(): Boolean
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package com.devil.phoenixproject.ui.theme

import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
Expand Down Expand Up @@ -107,7 +106,7 @@ fun VitruvianTheme(
content: @Composable () -> Unit,
) {
val useDarkColors = when (themeMode) {
ThemeMode.SYSTEM -> isSystemInDarkTheme()
ThemeMode.SYSTEM -> rememberPlatformSystemDark()
ThemeMode.LIGHT -> false
ThemeMode.DARK -> true
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.devil.phoenixproject.ui.theme

import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable

/**
* iOS: delegate to the platform `isSystemInDarkTheme()` signal.
* iOS does not have the same transient-signal issue as Android's Compose bridge;
* the SwiftUI/UIViewController lifecycle keeps the appearance stable.
*/
@Composable
actual fun rememberPlatformSystemDark(): Boolean = isSystemInDarkTheme()
Loading