diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2f02539435..d4379372d0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -99,6 +99,7 @@ dependencies { implementation(libs.androidx.compose.runtime.tracing) implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.splashscreen) + implementation(libs.androidx.appcompat) implementation(libs.androidx.lifecycle.runtimeCompose) implementation(libs.androidx.lifecycle.viewModel.navigation3) implementation(libs.androidx.profileinstaller) diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/NiaApplication.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/NiaApplication.kt index 4975e5d65f..bc2a8b4daf 100644 --- a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/NiaApplication.kt +++ b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/NiaApplication.kt @@ -20,11 +20,17 @@ import android.app.Application import android.content.pm.ApplicationInfo import android.os.StrictMode import android.os.StrictMode.ThreadPolicy.Builder +import androidx.datastore.core.DataStore import coil.ImageLoader import coil.ImageLoaderFactory +import com.google.samples.apps.nowinandroid.core.datastore.UserPreferences +import com.google.samples.apps.nowinandroid.core.network.di.ApplicationScope import com.google.samples.apps.nowinandroid.sync.initializers.Sync import com.google.samples.apps.nowinandroid.util.ProfileVerifierLogger +import com.google.samples.apps.nowinandroid.util.initializeNightModeFromPreferences +import com.google.samples.apps.nowinandroid.util.observeNightModePreferences import dagger.hilt.android.HiltAndroidApp +import kotlinx.coroutines.CoroutineScope import javax.inject.Inject /** @@ -35,12 +41,24 @@ class NiaApplication : Application(), ImageLoaderFactory { @Inject lateinit var imageLoader: dagger.Lazy + @Inject + lateinit var userPrefsDataStore: DataStore + @Inject lateinit var profileVerifierLogger: ProfileVerifierLogger + @Inject + @ApplicationScope + lateinit var applicationScope: CoroutineScope + override fun onCreate() { super.onCreate() + // Initialize dark mode from user prefs + initializeNightModeFromPreferences(userPrefsDataStore) + + observeNightModePreferences(userPrefsDataStore, applicationScope) + setStrictModePolicy() // Initialize Sync; the system responsible for keeping data in the app up to date. diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/util/UiExtensions.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/util/UiExtensions.kt index 20d55ab4ce..b3220732a9 100644 --- a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/util/UiExtensions.kt +++ b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/util/UiExtensions.kt @@ -16,13 +16,28 @@ package com.google.samples.apps.nowinandroid.util +import android.app.UiModeManager +import android.content.Context import android.content.res.Configuration +import android.os.Build import androidx.activity.ComponentActivity +import androidx.annotation.RequiresApi +import androidx.appcompat.app.AppCompatDelegate import androidx.core.util.Consumer +import androidx.datastore.core.DataStore +import com.google.samples.apps.nowinandroid.core.datastore.DarkThemeConfigProto +import com.google.samples.apps.nowinandroid.core.datastore.UserPreferences +import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking /** * Convenience wrapper for dark mode checking @@ -47,3 +62,81 @@ fun ComponentActivity.isSystemInDarkTheme() = callbackFlow { } .distinctUntilChanged() .conflate() + +/** + * Converts [DarkThemeConfig] to AppCompat night mode constant. + */ +fun DarkThemeConfig.toNightMode(): Int = when (this) { + DarkThemeConfig.FOLLOW_SYSTEM -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM + DarkThemeConfig.LIGHT -> AppCompatDelegate.MODE_NIGHT_NO + DarkThemeConfig.DARK -> AppCompatDelegate.MODE_NIGHT_YES +} + +/** + * Maps a [DarkThemeConfig] value to the corresponding night mode setting + * used by UiModeManager on Android 12 (API level 31) and above. + */ +@RequiresApi(Build.VERSION_CODES.S) +fun DarkThemeConfig.toUiNightMode(): Int = when(this) { + DarkThemeConfig.FOLLOW_SYSTEM -> UiModeManager.MODE_NIGHT_AUTO + DarkThemeConfig.LIGHT -> UiModeManager.MODE_NIGHT_NO + DarkThemeConfig.DARK -> UiModeManager.MODE_NIGHT_YES +} + +/** + * Applies this [DarkThemeConfig] as default night mode. + */ +fun DarkThemeConfig.applyAsDefaultNightMode(context: Context) { + if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val uiModeManager = context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager + uiModeManager.setApplicationNightMode(toUiNightMode()) + } else { + AppCompatDelegate.setDefaultNightMode(toNightMode()) + } +} + +/** + * Converts stored proto data into a DarkThemeConfig object. + */ +fun DarkThemeConfigProto.toDarkThemeConfig(): DarkThemeConfig = when (this) { + DarkThemeConfigProto.DARK_THEME_CONFIG_LIGHT -> DarkThemeConfig.LIGHT + DarkThemeConfigProto.DARK_THEME_CONFIG_DARK -> DarkThemeConfig.DARK + else -> DarkThemeConfig.FOLLOW_SYSTEM +} + +/** + * Sets night mode from user prefs. Call in Application.onCreate() to show correct splash theme. + */ +fun Context.initializeNightModeFromPreferences(userPrefsDataStore: DataStore) { + runBlocking { + runCatching { + val darkThemeConfig = userPrefsDataStore.data + .first() + .darkThemeConfig + .toDarkThemeConfig() + + darkThemeConfig.applyAsDefaultNightMode(this@initializeNightModeFromPreferences) + }.onFailure { + DarkThemeConfig.FOLLOW_SYSTEM.applyAsDefaultNightMode(this@initializeNightModeFromPreferences) + } + } +} + +/** + * Observe theme changes and updates UiModeManager to prevent a bug where the first cold start + * uses the previous theme, while later starts use the correct one. + */ +fun Context.observeNightModePreferences( + userPrefsDataStore: DataStore, + scope: CoroutineScope, +) { + scope.launch { + userPrefsDataStore.data + .map { it.darkThemeConfig.toDarkThemeConfig() } + .distinctUntilChanged() + .drop(1) // Skip first emission (already handled by initializeNightModeFromPreferences) + .collect { darkThemeConfig -> + darkThemeConfig.applyAsDefaultNightMode(this@observeNightModePreferences) + } + } +} \ No newline at end of file