From 69d3b2b11d2c542b96af1a2cd5f92c8e1d460e33 Mon Sep 17 00:00:00 2001 From: Josiah Campbell <9521010+jocmp@users.noreply.github.com> Date: Sun, 15 Feb 2026 17:52:13 -0600 Subject: [PATCH] Add accent color preference toggle --- app/build.gradle.kts | 1 + .../app/preferences/AppPreferences.kt | 3 + .../capyreader/app/preferences/ThemeOption.kt | 3 + .../capyreader/app/ui/articles/AccentColor.kt | 26 ++++++ .../capyreader/app/ui/articles/ArticleList.kt | 2 + .../capyreader/app/ui/articles/ArticleRow.kt | 88 ++++++++++++++++--- .../app/ui/articles/FaviconColorCache.kt | 72 +++++++++++++++ .../app/ui/articles/feeds/DrawerItem.kt | 2 +- .../settings/panels/DisplaySettingsPanel.kt | 28 +++++- .../panels/DisplaySettingsViewModel.kt | 14 +-- .../java/com/capyreader/app/ui/theme/Theme.kt | 9 +- app/src/main/res/values/strings.xml | 1 + gradle/libs.versions.toml | 1 + 13 files changed, 229 insertions(+), 21 deletions(-) create mode 100644 app/src/main/java/com/capyreader/app/ui/articles/AccentColor.kt create mode 100644 app/src/main/java/com/capyreader/app/ui/articles/FaviconColorCache.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 903379e99..bd7e64752 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -132,6 +132,7 @@ dependencies { implementation(libs.androidx.media3.exoplayer) implementation(libs.androidx.media3.session) implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.palette) implementation(libs.androidx.paging.compose) implementation(libs.androidx.paging.runtime.ktx) implementation(libs.androidx.preferences) diff --git a/app/src/main/java/com/capyreader/app/preferences/AppPreferences.kt b/app/src/main/java/com/capyreader/app/preferences/AppPreferences.kt index da3921af2..7052e96ad 100644 --- a/app/src/main/java/com/capyreader/app/preferences/AppPreferences.kt +++ b/app/src/main/java/com/capyreader/app/preferences/AppPreferences.kt @@ -66,6 +66,9 @@ class AppPreferences(context: Context) { val pureBlackDarkMode: Preference get() = preferenceStore.getBoolean("pure_black_dark_mode", false) + val accentColors: Preference + get() = preferenceStore.getBoolean("accent_colors", false) + val openLinksInternally: Preference get() = preferenceStore.getBoolean("open_links_internally", true) diff --git a/app/src/main/java/com/capyreader/app/preferences/ThemeOption.kt b/app/src/main/java/com/capyreader/app/preferences/ThemeOption.kt index ebfdb229a..6248f99f1 100644 --- a/app/src/main/java/com/capyreader/app/preferences/ThemeOption.kt +++ b/app/src/main/java/com/capyreader/app/preferences/ThemeOption.kt @@ -36,6 +36,9 @@ enum class AppTheme { MONOCHROME -> R.string.theme_monochrome } + val supportsFeedAccentColor: Boolean + get() = this == MONOCHROME || this == MONET + /** On the off chance someone has selected MONET on an older version, default them */ fun normalized(): AppTheme { return if (this === MONET && Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { diff --git a/app/src/main/java/com/capyreader/app/ui/articles/AccentColor.kt b/app/src/main/java/com/capyreader/app/ui/articles/AccentColor.kt new file mode 100644 index 000000000..17354e5f2 --- /dev/null +++ b/app/src/main/java/com/capyreader/app/ui/articles/AccentColor.kt @@ -0,0 +1,26 @@ +package com.capyreader.app.ui.articles + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import com.capyreader.app.ui.theme.LocalAppTheme + +@Composable +fun rememberAccentColor(faviconURL: String?): Color? { + faviconURL ?: return null + + val isDark = LocalAppTheme.current.isDark + var color by remember(faviconURL, isDark) { mutableStateOf(null) } + val context = LocalContext.current + + LaunchedEffect(faviconURL, isDark) { + color = FaviconColorCache.getColor(faviconURL, context, isDark) + } + + return color +} diff --git a/app/src/main/java/com/capyreader/app/ui/articles/ArticleList.kt b/app/src/main/java/com/capyreader/app/ui/articles/ArticleList.kt index 96c664fd3..96d0ec887 100644 --- a/app/src/main/java/com/capyreader/app/ui/articles/ArticleList.kt +++ b/app/src/main/java/com/capyreader/app/ui/articles/ArticleList.kt @@ -187,6 +187,7 @@ fun rememberArticleOptions(appPreferences: AppPreferences = koinInject()): Artic val fontScale by appPreferences.articleListOptions.fontScale.stateIn(scope).collectAsState() val shortenTitles by appPreferences.articleListOptions.shortenTitles.stateIn(scope) .collectAsState() + val accentColors by appPreferences.accentColors.stateIn(scope).collectAsState() return ArticleRowOptions( showSummary = showSummary, @@ -195,6 +196,7 @@ fun rememberArticleOptions(appPreferences: AppPreferences = koinInject()): Artic imagePreview = imagePreview, fontScale = fontScale, shortenTitles = shortenTitles, + accentColors = accentColors, ) } diff --git a/app/src/main/java/com/capyreader/app/ui/articles/ArticleRow.kt b/app/src/main/java/com/capyreader/app/ui/articles/ArticleRow.kt index 685ecbba2..1e749a8ab 100644 --- a/app/src/main/java/com/capyreader/app/ui/articles/ArticleRow.kt +++ b/app/src/main/java/com/capyreader/app/ui/articles/ArticleRow.kt @@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.material3.MaterialTheme import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.PlayArrow import androidx.compose.material.icons.rounded.Star @@ -23,6 +22,7 @@ import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemColors import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -52,6 +52,7 @@ import coil3.compose.AsyncImage import com.capyreader.app.R import com.capyreader.app.common.ImagePreview import com.capyreader.app.preferences.AppTheme +import com.capyreader.app.preferences.ThemeMode import com.capyreader.app.ui.articles.list.ArticleActionMenu import com.capyreader.app.ui.articles.list.ArticleListItem import com.capyreader.app.ui.articles.list.ArticleRowSwipeBox @@ -75,6 +76,7 @@ data class ArticleRowOptions( val imagePreview: ImagePreview = ImagePreview.default, val fontScale: ArticleListFontScale = ArticleListFontScale.MEDIUM, val shortenTitles: Boolean = true, + val accentColors: Boolean = false, val dim: Boolean = true, ) @@ -89,14 +91,18 @@ fun ArticleRow( options: ArticleRowOptions = ArticleRowOptions(), ) { val imageURL = article.imageURL - val isMonochrome = LocalAppTheme.current == AppTheme.MONOCHROME + val isMonochrome = LocalAppTheme.current.value == AppTheme.MONOCHROME val dim = article.read && options.dim val deEmphasizeFontWeight = dim && isMonochrome val colors = listItemColors( selected = selected, read = dim ) - val feedNameColor = findFeedNameColor(read = dim) + val feedNameColor = findFeedNameColor( + faviconURL = article.faviconURL, + accentColors = options.accentColors, + read = dim, + ) val haptics = LocalHapticFeedback.current val (isArticleMenuOpen, setArticleMenuOpen) = remember { mutableStateOf(false) } val labelsActions = LocalLabelsActions.current @@ -306,25 +312,40 @@ private fun listItemColors( read: Boolean, ): ListItemColors { val defaults = ListItemDefaults.colors() - val isMonochrome = LocalAppTheme.current == AppTheme.MONOCHROME + val isMonochrome = LocalAppTheme.current.value == AppTheme.MONOCHROME val dimColors = read && !isMonochrome return ListItemDefaults.colors( containerColor = if (selected) colorScheme.surfaceVariant else defaults.containerColor, - headlineColor = if (dimColors) defaults.disabledContentColor else defaults.headlineColor, - supportingColor = if (dimColors) defaults.disabledContentColor else defaults.supportingTextColor + headlineColor = if (dimColors) defaults.disabledContentColor else defaults.contentColor, + supportingColor = if (dimColors) defaults.disabledContentColor else defaults.supportingContentColor ) } @Composable -fun findFeedNameColor(read: Boolean): Color { +fun findFeedNameColor( + faviconURL: String? = null, + accentColors: Boolean = false, + read: Boolean, +): Color { + val appThemeState = LocalAppTheme.current + val showAccentColor = appThemeState.value.supportsFeedAccentColor && accentColors + + if (showAccentColor) { + val accentColor = rememberAccentColor(faviconURL) + + if (accentColor != null) { + return if (read) accentColor.copy(alpha = 0.5f) else accentColor + } + } + val defaults = ListItemDefaults.colors() - val isMonochrome = LocalAppTheme.current == AppTheme.MONOCHROME + val isMonochrome = appThemeState.value == AppTheme.MONOCHROME return if (read && !isMonochrome) { - defaults.disabledHeadlineColor + defaults.disabledContentColor } else { - defaults.overlineColor + defaults.overlineContentColor } } @@ -593,3 +614,50 @@ fun ArticleRowPreviewPlaceholder() { PlaceholderArticleRow(ImagePreview.LARGE) } } + +@Preview( + uiMode = Configuration.UI_MODE_NIGHT_YES +) +@Composable +fun ArticleRowPreview_AccentBar() { + val article = Article( + id = "288", + feedID = "123", + title = "How to use the Galaxy S24's AI photo editing tool", + author = "Andrew Romero", + contentHTML = "
Test
", + imageURL = "https://example.com", + summary = "The Galaxy S24 series packs a lot of AI narrative.", + url = URL("https://9to5google.com/?p=605559"), + updatedAt = ZonedDateTime.of(2024, 2, 11, 8, 33, 0, 0, ZoneOffset.UTC), + publishedAt = ZonedDateTime.of(2024, 2, 11, 8, 33, 0, 0, ZoneOffset.UTC), + read = false, + starred = false, + feedName = "9to5Google" + ) + + PreviewKoinApplication { + CapyTheme( + appTheme = AppTheme.MONOCHROME, + themeMode = ThemeMode.DARK, + pureBlack = true, + ) { + Column { + ArticleRow( + article = article, + index = 0, + selected = false, + onSelect = {}, + currentTime = LocalDateTime.now(), + ) + ArticleRow( + article = article.copy(read = true), + index = 1, + selected = false, + onSelect = {}, + currentTime = LocalDateTime.now(), + ) + } + } + } +} diff --git a/app/src/main/java/com/capyreader/app/ui/articles/FaviconColorCache.kt b/app/src/main/java/com/capyreader/app/ui/articles/FaviconColorCache.kt new file mode 100644 index 000000000..b26b505fb --- /dev/null +++ b/app/src/main/java/com/capyreader/app/ui/articles/FaviconColorCache.kt @@ -0,0 +1,72 @@ +package com.capyreader.app.ui.articles + +import android.content.Context +import androidx.collection.LruCache +import androidx.compose.ui.graphics.Color +import androidx.core.graphics.ColorUtils +import androidx.palette.graphics.Palette +import coil3.imageLoader +import coil3.request.ImageRequest +import coil3.request.allowHardware +import coil3.size.Size +import coil3.toBitmap +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +object FaviconColorCache { + private data class ColorPair(val light: Color, val dark: Color) + + private val cache = LruCache(MAX_ENTRIES) + + suspend fun getColor(url: String, context: Context, isDark: Boolean): Color? { + val pair = cache[url] + ?: extractColors(url, context)?.also { cache.put(url, it) } + ?: return null + + return if (isDark) pair.dark else pair.light + } + + private suspend fun extractColors(url: String, context: Context): ColorPair? { + val request = ImageRequest.Builder(context) + .data(url) + .size(Size(64, 64)) + .allowHardware(false) + .build() + + val bitmap = context.imageLoader.execute(request).image?.toBitmap() ?: return null + val palette = withContext(Dispatchers.Default) { Palette.from(bitmap).generate() } + + val darkSwatch = palette.lightVibrantSwatch + ?: palette.vibrantSwatch + ?: palette.lightMutedSwatch + ?: palette.dominantSwatch + ?: return null + + val lightSwatch = palette.darkVibrantSwatch + ?: palette.vibrantSwatch + ?: palette.darkMutedSwatch + ?: palette.dominantSwatch + ?: return null + + return ColorPair( + dark = clampLightness(darkSwatch.rgb, min = MIN_DARK_LIGHTNESS), + light = clampLightness(lightSwatch.rgb, max = MAX_LIGHT_LIGHTNESS), + ) + } + + private fun clampLightness( + rgb: Int, + min: Float = 0f, + max: Float = 1f, + ): Color { + val hsl = FloatArray(3) + ColorUtils.colorToHSL(rgb, hsl) + hsl[2] = hsl[2].coerceIn(min, max) + + return Color(ColorUtils.HSLToColor(hsl)) + } + + private const val MAX_ENTRIES = 200 + private const val MIN_DARK_LIGHTNESS = 0.8f + private const val MAX_LIGHT_LIGHTNESS = 0.35f +} diff --git a/app/src/main/java/com/capyreader/app/ui/articles/feeds/DrawerItem.kt b/app/src/main/java/com/capyreader/app/ui/articles/feeds/DrawerItem.kt index 51639490a..575f27c80 100644 --- a/app/src/main/java/com/capyreader/app/ui/articles/feeds/DrawerItem.kt +++ b/app/src/main/java/com/capyreader/app/ui/articles/feeds/DrawerItem.kt @@ -95,7 +95,7 @@ private data class DrawerColors( private fun NavigationDrawerItemColors.mapToTheme( selected: Boolean, ): DrawerColors { - val isMonochrome = LocalAppTheme.current == AppTheme.MONOCHROME + val isMonochrome = LocalAppTheme.current.value == AppTheme.MONOCHROME val useMonochromeSelected = selected && isMonochrome val unselectedTextColor = textColor(false).value val surfaceColor = MaterialTheme.colorScheme.surface diff --git a/app/src/main/java/com/capyreader/app/ui/settings/panels/DisplaySettingsPanel.kt b/app/src/main/java/com/capyreader/app/ui/settings/panels/DisplaySettingsPanel.kt index 9a565c2b0..20256ad45 100644 --- a/app/src/main/java/com/capyreader/app/ui/settings/panels/DisplaySettingsPanel.kt +++ b/app/src/main/java/com/capyreader/app/ui/settings/panels/DisplaySettingsPanel.kt @@ -1,16 +1,19 @@ package com.capyreader.app.ui.settings.panels +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.clickable import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ButtonGroupDefaults -import androidx.compose.foundation.layout.Box import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ListItem import androidx.compose.material3.Surface @@ -27,6 +30,7 @@ import com.capyreader.app.R import com.capyreader.app.common.ImagePreview import com.capyreader.app.common.RowItem import com.capyreader.app.preferences.AppPreferences +import com.capyreader.app.preferences.AppTheme import com.capyreader.app.preferences.ReaderImageVisibility import com.capyreader.app.preferences.ThemeMode import com.capyreader.app.ui.articles.ArticleListFontScale @@ -47,12 +51,16 @@ fun DisplaySettingsPanel( val pinArticleBars by viewModel.pinArticleBars.collectChangesWithCurrent() val improveTalkback by viewModel.improveTalkback.collectChangesWithCurrent() val markReadButtonPosition by viewModel.markReadButtonPosition.collectChangesWithCurrent() + val appTheme by viewModel.appPreferences.appTheme.collectChangesWithCurrent() DisplaySettingsPanelView( themeMode = viewModel.themeMode, updateThemeMode = viewModel::updateThemeMode, + appTheme = appTheme, pureBlackDarkMode = viewModel.pureBlackDarkMode, updatePureBlackDarkMode = viewModel::updatePureBlackDarkMode, + accentColors = viewModel.accentColors, + updateAccentColors = viewModel::updateAccentColors, appPreferences = viewModel.appPreferences, updatePinArticleBars = viewModel::updatePinArticleBars, pinArticleBars = pinArticleBars, @@ -83,8 +91,11 @@ fun DisplaySettingsPanel( fun DisplaySettingsPanelView( themeMode: ThemeMode, updateThemeMode: (ThemeMode) -> Unit, + appTheme: AppTheme = AppTheme.default, pureBlackDarkMode: Boolean, updatePureBlackDarkMode: (Boolean) -> Unit, + accentColors: Boolean = false, + updateAccentColors: (Boolean) -> Unit = {}, appPreferences: AppPreferences?, updatePinArticleBars: (enable: Boolean) -> Unit, pinArticleBars: Boolean, @@ -121,6 +132,19 @@ fun DisplaySettingsPanelView( title = stringResource(R.string.settings_pure_black_dark_mode) ) } + AnimatedVisibility( + visible = appTheme.supportsFeedAccentColor, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + RowItem { + TextSwitch( + onCheckedChange = updateAccentColors, + checked = accentColors, + title = stringResource(R.string.settings_accent_colors) + ) + } + } Box(Modifier.clickable { onNavigateToUnreadBadges() }) { ListItem( headlineContent = { diff --git a/app/src/main/java/com/capyreader/app/ui/settings/panels/DisplaySettingsViewModel.kt b/app/src/main/java/com/capyreader/app/ui/settings/panels/DisplaySettingsViewModel.kt index c8f20887e..86b52d027 100644 --- a/app/src/main/java/com/capyreader/app/ui/settings/panels/DisplaySettingsViewModel.kt +++ b/app/src/main/java/com/capyreader/app/ui/settings/panels/DisplaySettingsViewModel.kt @@ -8,7 +8,6 @@ import com.capyreader.app.common.ImagePreview import com.capyreader.app.preferences.AppPreferences import com.capyreader.app.preferences.ReaderImageVisibility import com.capyreader.app.preferences.ThemeMode -import com.capyreader.app.preferences.AppTheme import com.capyreader.app.ui.articles.ArticleListFontScale import com.capyreader.app.ui.articles.MarkReadPosition import com.jocmp.capy.Account @@ -26,6 +25,9 @@ class DisplaySettingsViewModel( var pureBlackDarkMode by mutableStateOf(appPreferences.pureBlackDarkMode.get()) private set + var accentColors by mutableStateOf(appPreferences.accentColors.get()) + private set + private val _imagePreview = mutableStateOf(appPreferences.articleListOptions.imagePreview.get()) private val _showSummary = mutableStateOf(appPreferences.articleListOptions.showSummary.get()) @@ -69,16 +71,16 @@ class DisplaySettingsViewModel( this.themeMode = themeMode } - fun updateAppTheme(appTheme: AppTheme) { - appPreferences.appTheme.set(appTheme) - this.appTheme = appTheme - } - fun updatePureBlackDarkMode(enable: Boolean) { appPreferences.pureBlackDarkMode.set(enable) this.pureBlackDarkMode = enable } + fun updateAccentColors(enable: Boolean) { + appPreferences.accentColors.set(enable) + this.accentColors = enable + } + fun updatePinArticleBars(pinBars: Boolean) { appPreferences.readerOptions.pinToolbars.set(pinBars) } diff --git a/app/src/main/java/com/capyreader/app/ui/theme/Theme.kt b/app/src/main/java/com/capyreader/app/ui/theme/Theme.kt index c48b42eb1..ef13ebeb2 100644 --- a/app/src/main/java/com/capyreader/app/ui/theme/Theme.kt +++ b/app/src/main/java/com/capyreader/app/ui/theme/Theme.kt @@ -30,7 +30,12 @@ import com.capyreader.app.ui.theme.colorschemes.SunsetColorScheme import com.capyreader.app.ui.theme.colorschemes.TachiyomiColorScheme import com.capyreader.app.ui.theme.colorschemes.applyPureBlack -val LocalAppTheme = staticCompositionLocalOf { AppTheme.DEFAULT } +data class AppThemeState( + val value: AppTheme = AppTheme.DEFAULT, + val isDark: Boolean = false, +) + +val LocalAppTheme = staticCompositionLocalOf { AppThemeState() } @Composable fun CapyTheme( @@ -57,7 +62,7 @@ fun CapyTheme( StatusBarColorListener(colorScheme, themeMode, pureBlack) } - CompositionLocalProvider(LocalAppTheme provides appTheme) { + CompositionLocalProvider(LocalAppTheme provides AppThemeState(appTheme, isDark)) { MaterialTheme( colorScheme = colorScheme, content = content, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0d51c8780..f88c26ed3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -147,6 +147,7 @@ Monochrome Sunset Pure black dark mode + Accent colors Light Dark System default diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e2d2e4422..a70b6512e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -38,6 +38,7 @@ androidx-media3-datasource-okhttp = { module = "androidx.media3:media3-datasourc androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" } androidx-media3-session = { module = "androidx.media3:media3-session", version.ref = "media3" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation-compose" } +androidx-palette = { module = "androidx.palette:palette-ktx", version = "1.0.0" } androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "paging-compose" } androidx-paging-runtime-ktx = { module = "androidx.paging:paging-runtime-ktx", version.ref = "androidx-paging" } androidx-preferences = { module = "androidx.preference:preference-ktx", version = "1.2.1" }