From 5d83f721796b69d2c5b06ef15d9fae82f5d916b2 Mon Sep 17 00:00:00 2001 From: flazepe <51487578+flazepe@users.noreply.github.com> Date: Sun, 23 Aug 2026 05:10:07 +0800 Subject: [PATCH 1/3] Add manual dark theme preference support --- app/src/main/AndroidManifest.xml | 9 +++- .../java/moe/apex/breadboard/MainActivity.kt | 21 ++++++++ .../breadboard/home/FollowedArtistsScreen.kt | 4 +- .../preferences/LayoutSettingsScreen.kt | 22 +++++++- .../moe/apex/breadboard/preferences/Pref.kt | 27 ++++++++++ .../breadboard/preferences/Preferences.kt | 4 +- .../moe/apex/breadboard/ui/theme/Theme.kt | 52 ++++++++++++++++++- 7 files changed, 131 insertions(+), 8 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6b98fc7f..7aa0cfc8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -18,7 +18,11 @@ android:launchMode="singleTop" android:taskAffinity="moe.apex.breadboard.MainActivity" android:exported="true" - android:theme="@style/Theme.Breadboard"> + android:theme="@style/Theme.Breadboard" + android:configChanges="uiMode"> @@ -29,7 +33,8 @@ android:taskAffinity="moe.apex.breadboard.DeepLinkActivity" android:launchMode="singleTop" android:exported="true" - android:theme="@style/Theme.Breadboard"> + android:theme="@style/Theme.Breadboard" + android:configChanges="uiMode"> diff --git a/app/src/main/java/moe/apex/breadboard/MainActivity.kt b/app/src/main/java/moe/apex/breadboard/MainActivity.kt index b87d0ef7..6d49d818 100644 --- a/app/src/main/java/moe/apex/breadboard/MainActivity.kt +++ b/app/src/main/java/moe/apex/breadboard/MainActivity.kt @@ -1,5 +1,6 @@ package moe.apex.breadboard +import android.app.UiModeManager import android.content.Context import android.content.Intent import android.os.Build @@ -35,6 +36,7 @@ import moe.apex.breadboard.navigation.Home import moe.apex.breadboard.navigation.Navigation import moe.apex.breadboard.navigation.Results import moe.apex.breadboard.navigation.Search +import moe.apex.breadboard.preferences.DarkTheme import moe.apex.breadboard.preferences.ImageSource import moe.apex.breadboard.preferences.LocalPreferences import moe.apex.breadboard.preferences.StartDestination @@ -123,6 +125,25 @@ class MainActivity : SingletonImageLoader.Factory, ComponentActivity(), VolumeBu val viewModel = getGlobalViewModel() val recommendationsProvider by viewModel.recommendationsProvider.collectAsState() + /* Sync the UiModeManager's app night mode preference with the selected app dark theme + preference. This ensures that the splash screen color scheme matches the selected app + dark theme preference. + + UiModeManager#setApplicationNightMode() is only supported on Android 12+. */ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val uiModeManager = getSystemService(UI_MODE_SERVICE) as UiModeManager + + SideEffect(prefs.darkTheme) { + uiModeManager.setApplicationNightMode( + when (prefs.darkTheme) { + DarkTheme.ON -> UiModeManager.MODE_NIGHT_YES + DarkTheme.OFF -> UiModeManager.MODE_NIGHT_NO + DarkTheme.AUTO -> UiModeManager.MODE_NIGHT_AUTO + } + ) + } + } + SideEffect(prefs.imageSource, prefs.filterRatingsLocally) { if ( recommendationsProvider?.imageSource != prefs.imageSource || diff --git a/app/src/main/java/moe/apex/breadboard/home/FollowedArtistsScreen.kt b/app/src/main/java/moe/apex/breadboard/home/FollowedArtistsScreen.kt index 2574b5bb..709bb823 100644 --- a/app/src/main/java/moe/apex/breadboard/home/FollowedArtistsScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/home/FollowedArtistsScreen.kt @@ -1,7 +1,6 @@ package moe.apex.breadboard.home import androidx.compose.foundation.background -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -42,6 +41,7 @@ import moe.apex.breadboard.preferences.ImageSource import moe.apex.breadboard.preferences.LocalPreferences import moe.apex.breadboard.preferences.PreferenceKeys import moe.apex.breadboard.prefs +import moe.apex.breadboard.ui.theme.shouldUseDarkTheme import moe.apex.breadboard.util.DISABLED_OPACITY import moe.apex.breadboard.util.ExpressiveTagEntryContainer import moe.apex.breadboard.util.LargeTitleBar @@ -65,7 +65,7 @@ fun FollowedArtistsScreen(navController: NavHostController) { val userPreferencesRepository = LocalContext.current.prefs val prefs = LocalPreferences.current val followedTags = prefs.followedTags.toList().sorted() - val darkTheme = isSystemInDarkTheme() + val darkTheme = shouldUseDarkTheme() MainScreenScaffold( topAppBar = { diff --git a/app/src/main/java/moe/apex/breadboard/preferences/LayoutSettingsScreen.kt b/app/src/main/java/moe/apex/breadboard/preferences/LayoutSettingsScreen.kt index 90aa3c72..3f607408 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/LayoutSettingsScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/LayoutSettingsScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController import kotlinx.coroutines.launch import moe.apex.breadboard.prefs +import moe.apex.breadboard.ui.theme.doesSystemSupportDarkTheme import moe.apex.breadboard.util.LargeTitleBar import moe.apex.breadboard.util.LazyExpressiveGroup import moe.apex.breadboard.util.MainScreenScaffold @@ -119,7 +120,26 @@ fun LayoutSettingsScreen(navController: NavHostController) { } } - LazyExpressiveGroup("Layout") { + LazyExpressiveGroup("Appearance and Layout") { + item { + EnumPref( + title = "Dark theme", + summary = currentSettings.darkTheme.label, + enumItems = DarkTheme.entries.filter { entry -> + // Hide the auto option if system dark theme is not supported + !(entry == DarkTheme.AUTO && !doesSystemSupportDarkTheme()) + }, + selectedItem = currentSettings.darkTheme, + onSelection = { + scope.launch { + preferencesRepository.updatePref( + PreferenceKeys.DARK_THEME, + it + ) + } + } + ) + } item { ReorderablePref( title = "Reorder image actions", diff --git a/app/src/main/java/moe/apex/breadboard/preferences/Pref.kt b/app/src/main/java/moe/apex/breadboard/preferences/Pref.kt index a20a6718..cddc0303 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/Pref.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/Pref.kt @@ -45,6 +45,7 @@ import moe.apex.breadboard.image.Safebooru import moe.apex.breadboard.image.Yandere import moe.apex.breadboard.image.AI_TAG_NAMES import moe.apex.breadboard.tag.TagCategory +import moe.apex.breadboard.ui.theme.doesSystemSupportDarkTheme import moe.apex.breadboard.util.AgeVerification import moe.apex.breadboard.util.MigrationOnlyField import moe.apex.breadboard.util.PixivArtwork @@ -98,6 +99,7 @@ data object PrefNames { const val INTERNAL_IGNORE_LIST = "internal_ignore_list" const val AUTOPLAY_VIDEOS = "autoplay_videos" const val UNIFIED_INFO_SHEET = "unified_info_sheet" + const val DARK_THEME = "dark_theme" const val FOLLOWED_TAGS = "followed_tags" const val DEFAULT_BROWSE_TAB = "default_browse_tab" const val PROFILES_FOR_ALL_TAGS = "profiles_for_all_tags" @@ -136,6 +138,7 @@ object PreferenceKeys { val INTERNAL_IGNORE_LIST = stringSetPreferencesKey(PrefNames.INTERNAL_IGNORE_LIST) val AUTOPLAY_VIDEOS = stringPreferencesKey(PrefNames.AUTOPLAY_VIDEOS) val UNIFIED_INFO_SHEET = booleanPreferencesKey(PrefNames.UNIFIED_INFO_SHEET) + val DARK_THEME = stringPreferencesKey(PrefNames.DARK_THEME) val FOLLOWED_TAGS = stringSetPreferencesKey(PrefNames.FOLLOWED_TAGS) val DEFAULT_BROWSE_TAB = stringPreferencesKey(PrefNames.DEFAULT_BROWSE_TAB) val PROFILES_FOR_ALL_TAGS = booleanPreferencesKey(PrefNames.PROFILES_FOR_ALL_TAGS) @@ -243,6 +246,7 @@ data class Prefs( val internalIgnoreList: Set, val autoplayVideos: AutoplayVideosMode, val unifiedInfoSheet: Boolean, + val darkTheme: DarkTheme, val followedTags: Set, val defaultBrowseTab: BrowseTab, val profilesForAllTags: Boolean, @@ -280,6 +284,7 @@ data class Prefs( internalIgnoreList = emptySet(), autoplayVideos = AutoplayVideosMode.OFF, unifiedInfoSheet = false, // Unified is called 'Classic' in the UI + darkTheme = DarkTheme.AUTO, followedTags = emptySet(), defaultBrowseTab = BrowseTab.FOR_YOU, profilesForAllTags = false, @@ -350,6 +355,7 @@ class UserPreferencesRepository(private val dataStore: DataStore) { PreferenceKeys.INTERNAL_IGNORE_LIST_TIMESTAMP to PrefMeta(PrefCategory.SETTING, exportable = false), PreferenceKeys.INTERNAL_IGNORE_LIST to PrefMeta(PrefCategory.SETTING, exportable = false), PreferenceKeys.UNIFIED_INFO_SHEET to PrefMeta(PrefCategory.SETTING), + PreferenceKeys.DARK_THEME to PrefMeta(PrefCategory.SETTING), PreferenceKeys.FOLLOWED_TAGS to PrefMeta(PrefCategory.SETTING, mergeable = true), PreferenceKeys.DEFAULT_BROWSE_TAB to PrefMeta(PrefCategory.SETTING), PreferenceKeys.PROFILES_FOR_ALL_TAGS to PrefMeta(PrefCategory.SETTING), @@ -393,6 +399,18 @@ class UserPreferencesRepository(private val dataStore: DataStore) { val currentPreferences = dataStore.data.first() val lastUsedVersionCode = currentPreferences[PreferenceKeys.LAST_USED_VERSION_CODE] ?: 0 + /* Version 3.3.2 introduces the manual dark theme preference. Android 9 had some partial + support for system dark theme, but older versions would have no support whatsoever. + Set the default dark theme preference to a manual OFF state on those versions. */ + val darkTheme = currentPreferences[PreferenceKeys.DARK_THEME] + + if ( + !doesSystemSupportDarkTheme() && + (darkTheme == null || darkTheme == DarkTheme.AUTO.name) + ) { + updatePref(PreferenceKeys.DARK_THEME, DarkTheme.OFF) + } + /* lastUsedVersionCode can be 0 if the user had it installed already but cleared the data. In such a case, we can't reliably determine what their previous version was. Just load the default settings. */ @@ -858,6 +876,7 @@ class UserPreferencesRepository(private val dataStore: DataStore) { val internalIgnoreList = preferences[PreferenceKeys.INTERNAL_IGNORE_LIST] ?: Prefs.DEFAULT.internalIgnoreList val autoplayVideos = preferences[PreferenceKeys.AUTOPLAY_VIDEOS]?.let { AutoplayVideosMode.valueOf(it) } ?: Prefs.DEFAULT.autoplayVideos val unifiedInfoSheet = preferences[PreferenceKeys.UNIFIED_INFO_SHEET] ?: Prefs.DEFAULT.unifiedInfoSheet + val darkTheme = preferences[PreferenceKeys.DARK_THEME]?.let { DarkTheme.valueOf(it) } ?: Prefs.DEFAULT.darkTheme val followedTags = preferences[PreferenceKeys.FOLLOWED_TAGS] ?: Prefs.DEFAULT.followedTags val defaultBrowseTab = preferences[PreferenceKeys.DEFAULT_BROWSE_TAB]?.let { BrowseTab.valueOf(it) } ?: Prefs.DEFAULT.defaultBrowseTab val profilesForAllTags = preferences[PreferenceKeys.PROFILES_FOR_ALL_TAGS] ?: Prefs.DEFAULT.profilesForAllTags @@ -894,6 +913,7 @@ class UserPreferencesRepository(private val dataStore: DataStore) { internalIgnoreList, autoplayVideos, unifiedInfoSheet, + darkTheme, followedTags, defaultBrowseTab, profilesForAllTags, @@ -913,3 +933,10 @@ enum class ImageSource(override val label: String, val imageBoard: ImageBoard) : YANDERE("Yande.re", Yandere), R34("Rule34", Rule34) } + + +enum class DarkTheme(override val label: String) : PrefEnum { + ON("Always"), + OFF("Never"), + AUTO("Follow system") +} diff --git a/app/src/main/java/moe/apex/breadboard/preferences/Preferences.kt b/app/src/main/java/moe/apex/breadboard/preferences/Preferences.kt index c12d8990..fc0010d4 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/Preferences.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/Preferences.kt @@ -1,7 +1,6 @@ package moe.apex.breadboard.preferences import androidx.compose.foundation.background -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize @@ -47,6 +46,7 @@ import moe.apex.breadboard.navigation.DataSettings import moe.apex.breadboard.navigation.ExperimentalSettings import moe.apex.breadboard.navigation.GeneralSettings import moe.apex.breadboard.navigation.LayoutSettings +import moe.apex.breadboard.ui.theme.shouldUseDarkTheme import moe.apex.breadboard.util.ChevronRight import moe.apex.breadboard.util.LazyExpressiveGroup import moe.apex.breadboard.util.MainScreenScaffold @@ -194,7 +194,7 @@ private fun ContainedIcon( label: String, imageVector: ImageVector ) { - val darkTheme = isSystemInDarkTheme() + val darkTheme = shouldUseDarkTheme() val colourPair = remember(label) { generateColours(darkTheme, label) } diff --git a/app/src/main/java/moe/apex/breadboard/ui/theme/Theme.kt b/app/src/main/java/moe/apex/breadboard/ui/theme/Theme.kt index b63b8418..49ec97fb 100644 --- a/app/src/main/java/moe/apex/breadboard/ui/theme/Theme.kt +++ b/app/src/main/java/moe/apex/breadboard/ui/theme/Theme.kt @@ -1,6 +1,10 @@ package moe.apex.breadboard.ui.theme import android.os.Build +import androidx.activity.ComponentActivity +import androidx.activity.SystemBarStyle +import androidx.activity.compose.LocalActivity +import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialExpressiveTheme @@ -11,9 +15,12 @@ import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.Immutable +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import moe.apex.breadboard.preferences.DarkTheme +import moe.apex.breadboard.preferences.LocalPreferences private val DarkColorScheme = darkColorScheme( @@ -54,14 +61,57 @@ val LocalBreadboardColors = staticCompositionLocalOf { } +@Composable +fun shouldUseDarkTheme(): Boolean { + val preferences = LocalPreferences.current + + val userSelectedDarkMode = preferences.darkTheme == DarkTheme.ON + val followSystemDarkMode = preferences.darkTheme == DarkTheme.AUTO && isSystemInDarkTheme() + + return userSelectedDarkMode || followSystemDarkMode +} + + +fun doesSystemSupportDarkTheme(): Boolean { + /* Android 9 had some partial support for system dark theme, + but older versions would have no support whatsoever. */ + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.P +} + + @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun BreadboardTheme( - darkTheme: Boolean = isSystemInDarkTheme(), + darkTheme: Boolean = shouldUseDarkTheme(), // Dynamic color is available on Android 12+ dynamicColor: Boolean = true, content: @Composable () -> Unit ) { + /* Ensure that the status bar and navigation bar color scheme match the current app dark theme + preference. This is necessary on Android 11 amd lower if the app dark theme preference + is the opposite of the system dark theme preference. + + This is not necessary for Android 12+ since we already sync the UiModeManager's app night mode + preference to match the app dark theme preference, so the system changes the style accordingly. */ + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + val context = LocalActivity.current as ComponentActivity + + SideEffect(darkTheme) { + val systemBarStyle = if (darkTheme) { + SystemBarStyle.dark( + scrim = android.graphics.Color.TRANSPARENT + ) + } else { + SystemBarStyle.light( + scrim = android.graphics.Color.TRANSPARENT, + darkScrim = android.graphics.Color.TRANSPARENT + ) + } + + context.enableEdgeToEdge(statusBarStyle = systemBarStyle, navigationBarStyle = systemBarStyle) + } + } + var colorScheme = when { dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { val context = LocalContext.current From db1b5b53ff52ce7531cbf7e92c88a09ac7bfdc70 Mon Sep 17 00:00:00 2001 From: flazepe <51487578+flazepe@users.noreply.github.com> Date: Sun, 23 Aug 2026 05:25:18 +0800 Subject: [PATCH 2/3] Fix typos --- app/src/main/java/moe/apex/breadboard/MainActivity.kt | 2 +- app/src/main/java/moe/apex/breadboard/ui/theme/Theme.kt | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/moe/apex/breadboard/MainActivity.kt b/app/src/main/java/moe/apex/breadboard/MainActivity.kt index 6d49d818..b65564f2 100644 --- a/app/src/main/java/moe/apex/breadboard/MainActivity.kt +++ b/app/src/main/java/moe/apex/breadboard/MainActivity.kt @@ -126,7 +126,7 @@ class MainActivity : SingletonImageLoader.Factory, ComponentActivity(), VolumeBu val recommendationsProvider by viewModel.recommendationsProvider.collectAsState() /* Sync the UiModeManager's app night mode preference with the selected app dark theme - preference. This ensures that the splash screen color scheme matches the selected app + preference. This ensures that the splash screen colour scheme matches the selected app dark theme preference. UiModeManager#setApplicationNightMode() is only supported on Android 12+. */ diff --git a/app/src/main/java/moe/apex/breadboard/ui/theme/Theme.kt b/app/src/main/java/moe/apex/breadboard/ui/theme/Theme.kt index 49ec97fb..3eee8d15 100644 --- a/app/src/main/java/moe/apex/breadboard/ui/theme/Theme.kt +++ b/app/src/main/java/moe/apex/breadboard/ui/theme/Theme.kt @@ -87,8 +87,8 @@ fun BreadboardTheme( dynamicColor: Boolean = true, content: @Composable () -> Unit ) { - /* Ensure that the status bar and navigation bar color scheme match the current app dark theme - preference. This is necessary on Android 11 amd lower if the app dark theme preference + /* Ensure that the status bar and navigation bar colour scheme match the current app dark theme + preference. This is necessary on Android 11 and lower if the app dark theme preference is the opposite of the system dark theme preference. This is not necessary for Android 12+ since we already sync the UiModeManager's app night mode From 013ab83450b781c434c3376728d36b5de241022a Mon Sep 17 00:00:00 2001 From: flazepe <51487578+flazepe@users.noreply.github.com> Date: Sun, 23 Aug 2026 10:56:52 +0800 Subject: [PATCH 3/3] Simplify function --- app/src/main/java/moe/apex/breadboard/ui/theme/Theme.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/moe/apex/breadboard/ui/theme/Theme.kt b/app/src/main/java/moe/apex/breadboard/ui/theme/Theme.kt index 3eee8d15..da5e672e 100644 --- a/app/src/main/java/moe/apex/breadboard/ui/theme/Theme.kt +++ b/app/src/main/java/moe/apex/breadboard/ui/theme/Theme.kt @@ -65,10 +65,11 @@ val LocalBreadboardColors = staticCompositionLocalOf { fun shouldUseDarkTheme(): Boolean { val preferences = LocalPreferences.current - val userSelectedDarkMode = preferences.darkTheme == DarkTheme.ON - val followSystemDarkMode = preferences.darkTheme == DarkTheme.AUTO && isSystemInDarkTheme() - - return userSelectedDarkMode || followSystemDarkMode + return when (preferences.darkTheme) { + DarkTheme.ON -> true + DarkTheme.OFF -> false + DarkTheme.AUTO -> isSystemInDarkTheme() + } }