From bdacc2866368cb839f8d6b6e2824686e13226cb8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 19:28:18 +0000 Subject: [PATCH 1/3] feat: add app UI theme customization (accent color, font, font size) Adds three new appearance settings to Application Preferences, alongside the existing Theme selector: - Accent Color: Purple (default), Blue, Green, Orange, Red, Pink. Drives the Material primary/secondary/tertiary colors so buttons, links, FABs and switches follow the chosen hue. Purple preserves the original look (purple primary + teal secondary). - Font: System Default, Sans Serif, Serif, Monospace. Applied to the full Material typography and to bare Text via LocalTextStyle. - Font Size: Small, Normal (default), Large, Huge. Scales all text through LocalDensity.fontScale without affecting dp-based layout. Plumbed through the existing UiSettings -> UiSettingsFlow -> UiSharedPreferences (DataStore) pipeline and the AmethystTheme composable. New fields default to the current behavior and are appended, so existing stored settings deserialize unchanged. ColorScheme.isLight now derives from background luminance instead of a fixed primary, so the light/dark check keeps working when a non-purple accent is selected. The primary-derived tint extensions (links, new-item background, secondary button) now compute from the live scheme so they track the accent color. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01REGsru6cnm6wUzqm12Rh2d --- .../amethyst/model/UiSettings.kt | 65 ++++++++++ .../amethyst/model/UiSettingsFlow.kt | 27 ++++ .../model/preferences/UISharedPreferences.kt | 12 ++ .../loggedIn/settings/AppSettingsScreen.kt | 77 ++++++++++++ .../vitorpamplona/amethyst/ui/theme/Color.kt | 13 ++ .../vitorpamplona/amethyst/ui/theme/Theme.kt | 118 ++++++++++++------ .../vitorpamplona/amethyst/ui/theme/Type.kt | 34 +++++ amethyst/src/main/res/values/strings.xml | 20 +++ 8 files changed, 330 insertions(+), 36 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt index 069c8a8805..e986f5f778 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt @@ -54,6 +54,9 @@ data class UiSettings( val showProfileFollowersFeed: Boolean = true, val dontShowOnchainPublicWarning: Boolean = false, val suggestWorkoutsFromHealthConnect: BooleanType = BooleanType.ALWAYS, + val accentColor: AccentColorType = AccentColorType.PURPLE, + val fontFamily: FontFamilyType = FontFamilyType.SYSTEM, + val fontSize: FontSizeType = FontSizeType.NORMAL, ) enum class ThemeType( @@ -73,6 +76,68 @@ fun parseThemeType(code: Int?): ThemeType = else -> ThemeType.SYSTEM } +enum class AccentColorType( + val screenCode: Int, + val resourceId: Int, +) { + PURPLE(0, R.string.accent_color_purple), + BLUE(1, R.string.accent_color_blue), + GREEN(2, R.string.accent_color_green), + ORANGE(3, R.string.accent_color_orange), + RED(4, R.string.accent_color_red), + PINK(5, R.string.accent_color_pink), +} + +fun parseAccentColorType(screenCode: Int): AccentColorType = + when (screenCode) { + AccentColorType.PURPLE.screenCode -> AccentColorType.PURPLE + AccentColorType.BLUE.screenCode -> AccentColorType.BLUE + AccentColorType.GREEN.screenCode -> AccentColorType.GREEN + AccentColorType.ORANGE.screenCode -> AccentColorType.ORANGE + AccentColorType.RED.screenCode -> AccentColorType.RED + AccentColorType.PINK.screenCode -> AccentColorType.PINK + else -> AccentColorType.PURPLE + } + +enum class FontFamilyType( + val screenCode: Int, + val resourceId: Int, +) { + SYSTEM(0, R.string.font_family_system), + SANS_SERIF(1, R.string.font_family_sans_serif), + SERIF(2, R.string.font_family_serif), + MONOSPACE(3, R.string.font_family_monospace), +} + +fun parseFontFamilyType(screenCode: Int): FontFamilyType = + when (screenCode) { + FontFamilyType.SYSTEM.screenCode -> FontFamilyType.SYSTEM + FontFamilyType.SANS_SERIF.screenCode -> FontFamilyType.SANS_SERIF + FontFamilyType.SERIF.screenCode -> FontFamilyType.SERIF + FontFamilyType.MONOSPACE.screenCode -> FontFamilyType.MONOSPACE + else -> FontFamilyType.SYSTEM + } + +enum class FontSizeType( + val scale: Float, + val screenCode: Int, + val resourceId: Int, +) { + SMALL(0.85f, 0, R.string.font_size_small), + NORMAL(1.0f, 1, R.string.font_size_normal), + LARGE(1.15f, 2, R.string.font_size_large), + HUGE(1.3f, 3, R.string.font_size_huge), +} + +fun parseFontSizeType(screenCode: Int): FontSizeType = + when (screenCode) { + FontSizeType.SMALL.screenCode -> FontSizeType.SMALL + FontSizeType.NORMAL.screenCode -> FontSizeType.NORMAL + FontSizeType.LARGE.screenCode -> FontSizeType.LARGE + FontSizeType.HUGE.screenCode -> FontSizeType.HUGE + else -> FontSizeType.NORMAL + } + enum class ConnectivityType( val prefCode: Boolean?, val screenCode: Int, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt index 176691dfe6..d86fc45a51 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt @@ -54,6 +54,9 @@ class UiSettingsFlow( val showProfileFollowersFeed: MutableStateFlow = MutableStateFlow(true), val dontShowOnchainPublicWarning: MutableStateFlow = MutableStateFlow(false), val suggestWorkoutsFromHealthConnect: MutableStateFlow = MutableStateFlow(BooleanType.ALWAYS), + val accentColor: MutableStateFlow = MutableStateFlow(AccentColorType.PURPLE), + val fontFamily: MutableStateFlow = MutableStateFlow(FontFamilyType.SYSTEM), + val fontSize: MutableStateFlow = MutableStateFlow(FontSizeType.NORMAL), ) { val listOfFlows: List> = listOf>( @@ -82,6 +85,9 @@ class UiSettingsFlow( showProfileFollowersFeed, dontShowOnchainPublicWarning, suggestWorkoutsFromHealthConnect, + accentColor, + fontFamily, + fontSize, ) // emits at every change in any of the propertyes. @@ -114,6 +120,9 @@ class UiSettingsFlow( flows[22] as Boolean, flows[23] as Boolean, flows[24] as BooleanType, + flows[25] as AccentColorType, + flows[26] as FontFamilyType, + flows[27] as FontSizeType, ) } @@ -144,6 +153,9 @@ class UiSettingsFlow( showProfileFollowersFeed.value, dontShowOnchainPublicWarning.value, suggestWorkoutsFromHealthConnect.value, + accentColor.value, + fontFamily.value, + fontSize.value, ) fun update(torSettings: UiSettings): Boolean { @@ -249,6 +261,18 @@ class UiSettingsFlow( suggestWorkoutsFromHealthConnect.tryEmit(torSettings.suggestWorkoutsFromHealthConnect) any = true } + if (accentColor.value != torSettings.accentColor) { + accentColor.tryEmit(torSettings.accentColor) + any = true + } + if (fontFamily.value != torSettings.fontFamily) { + fontFamily.tryEmit(torSettings.fontFamily) + any = true + } + if (fontSize.value != torSettings.fontSize) { + fontSize.tryEmit(torSettings.fontSize) + any = true + } return any } @@ -299,6 +323,9 @@ class UiSettingsFlow( MutableStateFlow(uiSettings.showProfileFollowersFeed), MutableStateFlow(uiSettings.dontShowOnchainPublicWarning), MutableStateFlow(uiSettings.suggestWorkoutsFromHealthConnect), + MutableStateFlow(uiSettings.accentColor), + MutableStateFlow(uiSettings.fontFamily), + MutableStateFlow(uiSettings.fontSize), ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt index af4c15f520..25f5ba21cb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt @@ -31,9 +31,12 @@ import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import com.vitorpamplona.amethyst.LocalPreferences +import com.vitorpamplona.amethyst.model.AccentColorType import com.vitorpamplona.amethyst.model.BooleanType import com.vitorpamplona.amethyst.model.ConnectivityType import com.vitorpamplona.amethyst.model.FeatureSetType +import com.vitorpamplona.amethyst.model.FontFamilyType +import com.vitorpamplona.amethyst.model.FontSizeType import com.vitorpamplona.amethyst.model.ProfileGalleryType import com.vitorpamplona.amethyst.model.ThemeType import com.vitorpamplona.amethyst.model.UiSettings @@ -120,6 +123,9 @@ class UiSharedPreferences( val UI_SHOW_PROFILE_FOLLOWERS_FEED = booleanPreferencesKey("ui.show_profile_followers_feed") val UI_DONT_SHOW_ONCHAIN_PUBLIC_WARNING = booleanPreferencesKey("ui.dont_show_onchain_public_warning") val UI_SUGGEST_WORKOUTS_FROM_HEALTH_CONNECT = stringPreferencesKey("ui.suggest_workouts_from_health_connect") + val UI_ACCENT_COLOR = stringPreferencesKey("ui.accent_color") + val UI_FONT_FAMILY = stringPreferencesKey("ui.font_family") + val UI_FONT_SIZE = stringPreferencesKey("ui.font_size") suspend fun uiPreferences(context: Context): UiSettings? = try { @@ -157,6 +163,9 @@ class UiSharedPreferences( dontShowOnchainPublicWarning = preferences[UI_DONT_SHOW_ONCHAIN_PUBLIC_WARNING] ?: false, suggestWorkoutsFromHealthConnect = preferences[UI_SUGGEST_WORKOUTS_FROM_HEALTH_CONNECT]?.let { BooleanType.valueOf(it) } ?: BooleanType.ALWAYS, + accentColor = preferences[UI_ACCENT_COLOR]?.let { AccentColorType.valueOf(it) } ?: AccentColorType.PURPLE, + fontFamily = preferences[UI_FONT_FAMILY]?.let { FontFamilyType.valueOf(it) } ?: FontFamilyType.SYSTEM, + fontSize = preferences[UI_FONT_SIZE]?.let { FontSizeType.valueOf(it) } ?: FontSizeType.NORMAL, ) } catch (e: Exception) { if (e is CancellationException) throw e @@ -206,6 +215,9 @@ class UiSharedPreferences( preferences[UI_SHOW_PROFILE_FOLLOWERS_FEED] = sharedSettings.showProfileFollowersFeed preferences[UI_DONT_SHOW_ONCHAIN_PUBLIC_WARNING] = sharedSettings.dontShowOnchainPublicWarning preferences[UI_SUGGEST_WORKOUTS_FROM_HEALTH_CONNECT] = sharedSettings.suggestWorkoutsFromHealthConnect.name + preferences[UI_ACCENT_COLOR] = sharedSettings.accentColor.name + preferences[UI_FONT_FAMILY] = sharedSettings.fontFamily.name + preferences[UI_FONT_SIZE] = sharedSettings.fontSize.name } } catch (e: Exception) { if (e is CancellationException) throw e diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt index 0d3a39e992..608b2b0ac2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt @@ -48,14 +48,20 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.os.LocaleListCompat import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.AccentColorType import com.vitorpamplona.amethyst.model.ConnectivityType import com.vitorpamplona.amethyst.model.FeatureSetType +import com.vitorpamplona.amethyst.model.FontFamilyType +import com.vitorpamplona.amethyst.model.FontSizeType import com.vitorpamplona.amethyst.model.ProfileGalleryType import com.vitorpamplona.amethyst.model.ThemeType import com.vitorpamplona.amethyst.model.UiSettingsFlow +import com.vitorpamplona.amethyst.model.parseAccentColorType import com.vitorpamplona.amethyst.model.parseBooleanType import com.vitorpamplona.amethyst.model.parseConnectivityType import com.vitorpamplona.amethyst.model.parseFeatureSetType +import com.vitorpamplona.amethyst.model.parseFontFamilyType +import com.vitorpamplona.amethyst.model.parseFontSizeType import com.vitorpamplona.amethyst.model.parseGalleryType import com.vitorpamplona.amethyst.model.parseThemeType import com.vitorpamplona.amethyst.ui.components.TextSpinner @@ -113,6 +119,9 @@ fun SettingsScreen(sharedPrefs: UiSettingsFlow) { ) { ShowLanguageChoice(sharedPrefs) ShowThemeChoice(sharedPrefs) + ShowAccentColorChoice(sharedPrefs) + ShowFontFamilyChoice(sharedPrefs) + ShowFontSizeChoice(sharedPrefs) ShowImagePreviewChoice(sharedPrefs) ShowVideoPlaybackChoice(sharedPrefs) AutoplayVideosChoice(sharedPrefs) @@ -218,6 +227,74 @@ fun ShowThemeChoice(sharedPrefs: UiSettingsFlow) { } } +@Composable +fun ShowAccentColorChoice(sharedPrefs: UiSettingsFlow) { + val accentOptions = + persistentListOf( + TitleExplainer(stringRes(AccentColorType.PURPLE.resourceId)), + TitleExplainer(stringRes(AccentColorType.BLUE.resourceId)), + TitleExplainer(stringRes(AccentColorType.GREEN.resourceId)), + TitleExplainer(stringRes(AccentColorType.ORANGE.resourceId)), + TitleExplainer(stringRes(AccentColorType.RED.resourceId)), + TitleExplainer(stringRes(AccentColorType.PINK.resourceId)), + ) + + val accentIndex by sharedPrefs.accentColor.collectAsState() + + SettingsRow( + R.string.accent_color, + R.string.accent_color_description, + accentOptions, + accentIndex.screenCode, + ) { + sharedPrefs.accentColor.tryEmit(parseAccentColorType(it)) + } +} + +@Composable +fun ShowFontFamilyChoice(sharedPrefs: UiSettingsFlow) { + val fontOptions = + persistentListOf( + TitleExplainer(stringRes(FontFamilyType.SYSTEM.resourceId)), + TitleExplainer(stringRes(FontFamilyType.SANS_SERIF.resourceId)), + TitleExplainer(stringRes(FontFamilyType.SERIF.resourceId)), + TitleExplainer(stringRes(FontFamilyType.MONOSPACE.resourceId)), + ) + + val fontIndex by sharedPrefs.fontFamily.collectAsState() + + SettingsRow( + R.string.font_family, + R.string.font_family_description, + fontOptions, + fontIndex.screenCode, + ) { + sharedPrefs.fontFamily.tryEmit(parseFontFamilyType(it)) + } +} + +@Composable +fun ShowFontSizeChoice(sharedPrefs: UiSettingsFlow) { + val fontSizeOptions = + persistentListOf( + TitleExplainer(stringRes(FontSizeType.SMALL.resourceId)), + TitleExplainer(stringRes(FontSizeType.NORMAL.resourceId)), + TitleExplainer(stringRes(FontSizeType.LARGE.resourceId)), + TitleExplainer(stringRes(FontSizeType.HUGE.resourceId)), + ) + + val fontSizeIndex by sharedPrefs.fontSize.collectAsState() + + SettingsRow( + R.string.font_size, + R.string.font_size_description, + fontSizeOptions, + fontSizeIndex.screenCode, + ) { + sharedPrefs.fontSize.tryEmit(parseFontSizeType(it)) + } +} + @Composable fun ShowImagePreviewChoice(sharedPrefs: UiSettingsFlow) { val connectivityBasedOptions = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Color.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Color.kt index 98c40b26bc..6ca1574e84 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Color.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Color.kt @@ -36,6 +36,19 @@ val Purple200 = Color(0xFFBB86FC) val Purple500 = Color(0xFF6200EE) val Purple700 = Color(0xFF3700B3) val Teal200 = Color(0xFF03DAC5) + +// Accent palette options selected through Settings -> Accent Color. +// Each accent ships a brighter variant for the dark theme and a deeper variant for the light theme. +val AccentBlueDark = Color(0xFF82B1FF) +val AccentBlueLight = Color(0xFF1565C0) +val AccentGreenDark = Color(0xFF80CBC4) +val AccentGreenLight = Color(0xFF2E7D32) +val AccentOrangeDark = Color(0xFFFFB74D) +val AccentOrangeLight = Color(0xFFE65100) +val AccentRedDark = Color(0xFFEF9A9A) +val AccentRedLight = Color(0xFFC62828) +val AccentPinkDark = Color(0xFFF48FB1) +val AccentPinkLight = Color(0xFFAD1457) val BitcoinOrange = Color(0xFFF7931A) val RoyalBlue = Color(0xFF4169E1) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt index 213d04d4e6..a53fa12c30 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt @@ -31,25 +31,31 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.ColorScheme +import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalView import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextLinkStyles import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.em import androidx.compose.ui.unit.sp @@ -62,48 +68,58 @@ import com.patrykandpatrick.vico.compose.common.VicoTheme import com.patrykandpatrick.vico.compose.common.VicoTheme.CandlestickCartesianLayerColors import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.icons.symbols.ProvideMaterialSymbols +import com.vitorpamplona.amethyst.model.AccentColorType +import com.vitorpamplona.amethyst.model.FontFamilyType +import com.vitorpamplona.amethyst.model.FontSizeType import com.vitorpamplona.amethyst.model.ThemeType -private val DarkColorPalette = +// The accent color (primary/secondary/tertiary) is user-selectable in Settings -> Accent Color. +// Purple keeps the original Amethyst look (purple primary + teal secondary). Every other accent +// uses its single hue across primary and secondary for a cohesive single-color theme. +private fun accentPrimary( + accent: AccentColorType, + dark: Boolean, +): Color = + when (accent) { + AccentColorType.PURPLE -> if (dark) Purple200 else Purple500 + AccentColorType.BLUE -> if (dark) AccentBlueDark else AccentBlueLight + AccentColorType.GREEN -> if (dark) AccentGreenDark else AccentGreenLight + AccentColorType.ORANGE -> if (dark) AccentOrangeDark else AccentOrangeLight + AccentColorType.RED -> if (dark) AccentRedDark else AccentRedLight + AccentColorType.PINK -> if (dark) AccentPinkDark else AccentPinkLight + } + +private fun accentSecondary( + accent: AccentColorType, + dark: Boolean, +): Color = if (accent == AccentColorType.PURPLE) Teal200 else accentPrimary(accent, dark) + +private fun darkColors(accent: AccentColorType) = darkColorScheme( - primary = Purple200, - secondary = Teal200, - tertiary = Teal200, + primary = accentPrimary(accent, dark = true), + secondary = accentSecondary(accent, dark = true), + tertiary = accentSecondary(accent, dark = true), background = Color.Black, surface = Color.Black, surfaceDim = Color.Black, surfaceVariant = Color(red = 29, green = 26, blue = 34), ) -private val LightColorPalette = +private fun lightColors(accent: AccentColorType) = lightColorScheme( - primary = Purple500, - secondary = Teal200, - tertiary = Teal200, + primary = accentPrimary(accent, dark = false), + secondary = accentSecondary(accent, dark = false), + tertiary = accentSecondary(accent, dark = false), surfaceContainerHighest = Color(red = 236, green = 230, blue = 240), surfaceVariant = Color(red = 250, green = 245, blue = 252), ) -private val DarkNewItemBackground = DarkColorPalette.primary.copy(0.12f) -private val LightNewItemBackground = LightColorPalette.primary.copy(0.12f) +private val DarkColorPalette = darkColors(AccentColorType.PURPLE) +private val LightColorPalette = lightColors(AccentColorType.PURPLE) private val DarkTransparentBackground = DarkColorPalette.background.copy(0.32f) private val LightTransparentBackground = LightColorPalette.background.copy(0.32f) -private val DarkSelectedNote = DarkNewItemBackground.compositeOver(DarkColorPalette.background) -private val LightSelectedNote = LightNewItemBackground.compositeOver(LightColorPalette.background) - -private val DarkButtonBackground = - DarkColorPalette.primary.copy(alpha = 0.32f).compositeOver(DarkColorPalette.background) -private val LightButtonBackground = - LightColorPalette.primary.copy(alpha = 0.32f).compositeOver(LightColorPalette.background) - -private val DarkLessImportantLink = DarkColorPalette.primary.copy(alpha = 0.52f) -private val LightLessImportantLink = LightColorPalette.primary.copy(alpha = 0.52f) - -private val DarkMediumImportantLink = DarkColorPalette.primary.copy(alpha = 0.32f) -private val LightMediumImportantLink = LightColorPalette.primary.copy(alpha = 0.32f) - private val DarkGrayText = DarkColorPalette.onSurface.copy(alpha = 0.52f) private val LightGrayText = LightColorPalette.onSurface.copy(alpha = 0.52f) @@ -407,26 +423,30 @@ val MarkDownStyleOnLight = ), ) +// Derived from background luminance instead of a fixed primary so the check keeps working +// when the user picks a non-purple accent color (only primary/secondary change, not background). val ColorScheme.isLight: Boolean - get() = primary == Purple500 + get() = background.luminance() > 0.5f +// The accent-derived tints below are computed from the live scheme's primary so they follow +// the selected accent color. Color is an inline value class, so these copies don't allocate. val ColorScheme.newItemBackgroundColor: Color - get() = if (isLight) LightNewItemBackground else DarkNewItemBackground + get() = primary.copy(alpha = 0.12f) val ColorScheme.transparentBackground: Color get() = if (isLight) LightTransparentBackground else DarkTransparentBackground val ColorScheme.selectedNote: Color - get() = if (isLight) LightSelectedNote else DarkSelectedNote + get() = primary.copy(alpha = 0.12f).compositeOver(background) val ColorScheme.secondaryButtonBackground: Color - get() = if (isLight) LightButtonBackground else DarkButtonBackground + get() = primary.copy(alpha = 0.32f).compositeOver(background) val ColorScheme.lessImportantLink: Color - get() = if (isLight) LightLessImportantLink else DarkLessImportantLink + get() = primary.copy(alpha = 0.52f) val ColorScheme.mediumImportanceLink: Color - get() = if (isLight) LightMediumImportantLink else DarkMediumImportantLink + get() = primary.copy(alpha = 0.32f) val ColorScheme.placeholderText: Color get() = if (isLight) LightPlaceholderText else DarkPlaceholderText @@ -562,15 +582,21 @@ val ColorScheme.chartStyle: VicoTheme @Composable fun AmethystTheme(content: @Composable () -> Unit) { - val theme by Amethyst.instance.uiPrefs.value.theme - .collectAsStateWithLifecycle() + val uiPrefs = Amethyst.instance.uiPrefs.value + val theme by uiPrefs.theme.collectAsStateWithLifecycle() + val accentColor by uiPrefs.accentColor.collectAsStateWithLifecycle() + val fontFamily by uiPrefs.fontFamily.collectAsStateWithLifecycle() + val fontSize by uiPrefs.fontSize.collectAsStateWithLifecycle() - AmethystTheme(theme, content) + AmethystTheme(theme, accentColor, fontFamily, fontSize, content) } @Composable fun AmethystTheme( prefTheme: ThemeType, + accentColor: AccentColorType = AccentColorType.PURPLE, + fontFamily: FontFamilyType = FontFamilyType.SYSTEM, + fontSize: FontSizeType = FontSizeType.NORMAL, content: @Composable () -> Unit, ) { val context = LocalContext.current @@ -592,13 +618,33 @@ fun AmethystTheme( isSystemInDarkTheme() } } - val colors = if (darkTheme) DarkColorPalette else LightColorPalette + val colors = + remember(darkTheme, accentColor) { + if (darkTheme) darkColors(accentColor) else lightColors(accentColor) + } + + val resolvedFontFamily = remember(fontFamily) { fontFamily.toFontFamily() } + val typography = remember(fontFamily) { Typography.withFontFamily(resolvedFontFamily) } + + val density = LocalDensity.current + val scaledDensity = + remember(density, fontSize) { + Density(density.density, density.fontScale * fontSize.scale) + } MaterialTheme( colorScheme = colors, - typography = Typography, + typography = typography, shapes = Shapes, - content = { ProvideMaterialSymbols(content = content) }, + content = { + ProvideMaterialSymbols { + CompositionLocalProvider( + LocalDensity provides scaledDensity, + LocalTextStyle provides LocalTextStyle.current.merge(TextStyle(fontFamily = resolvedFontFamily)), + content = content, + ) + } + }, ) val view = LocalView.current diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Type.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Type.kt index b36b940d4f..88aa8e5178 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Type.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Type.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.em import androidx.compose.ui.unit.sp import com.halilibo.richtext.ui.HeadingStyle +import com.vitorpamplona.amethyst.model.FontFamilyType // Set of Material typography styles to start with val Typography = @@ -52,6 +53,39 @@ val Typography = */ ) +// Maps the user-selected font preference to a Compose [FontFamily]. +// SYSTEM returns null so the platform default is used unchanged. +fun FontFamilyType.toFontFamily(): FontFamily? = + when (this) { + FontFamilyType.SYSTEM -> null + FontFamilyType.SANS_SERIF -> FontFamily.SansSerif + FontFamilyType.SERIF -> FontFamily.Serif + FontFamilyType.MONOSPACE -> FontFamily.Monospace + } + +// Applies the chosen [FontFamily] to every text style so Material components pick it up too. +// A null family leaves the typography untouched (platform default). +fun Typography.withFontFamily(fontFamily: FontFamily?): Typography { + if (fontFamily == null) return this + return copy( + displayLarge = displayLarge.copy(fontFamily = fontFamily), + displayMedium = displayMedium.copy(fontFamily = fontFamily), + displaySmall = displaySmall.copy(fontFamily = fontFamily), + headlineLarge = headlineLarge.copy(fontFamily = fontFamily), + headlineMedium = headlineMedium.copy(fontFamily = fontFamily), + headlineSmall = headlineSmall.copy(fontFamily = fontFamily), + titleLarge = titleLarge.copy(fontFamily = fontFamily), + titleMedium = titleMedium.copy(fontFamily = fontFamily), + titleSmall = titleSmall.copy(fontFamily = fontFamily), + bodyLarge = bodyLarge.copy(fontFamily = fontFamily), + bodyMedium = bodyMedium.copy(fontFamily = fontFamily), + bodySmall = bodySmall.copy(fontFamily = fontFamily), + labelLarge = labelLarge.copy(fontFamily = fontFamily), + labelMedium = labelMedium.copy(fontFamily = fontFamily), + labelSmall = labelSmall.copy(fontFamily = fontFamily), + ) +} + val Font4SP = 4.sp val Font6SP = 6.sp val Font8SP = 8.sp diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 013e8c069e..b0be6ff2ef 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1621,6 +1621,26 @@ Wallet Connect Language Theme + Accent Color + Main color used across buttons and links + Purple + Blue + Green + Orange + Red + Pink + Font + Typeface used throughout the app + System Default + Sans Serif + Serif + Monospace + Font Size + Scale the text size across the app + Small + Normal + Large + Huge Image Preview Video Playback Autoplay Videos From 1069fb0701d0c6bed8ea17158a871ded248e24f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 19:50:51 +0000 Subject: [PATCH 2/3] refactor: move Profile Gallery Style setting to Profile UI settings The gallery style selector is profile-specific, so it now lives on the Profile UI settings screen alongside the other profile display toggles instead of the general Application Preferences screen. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01REGsru6cnm6wUzqm12Rh2d --- .../loggedIn/settings/AppSettingsScreen.kt | 23 -------------- .../settings/ProfileUiSettingsScreen.kt | 30 +++++++++++++++++++ 2 files changed, 30 insertions(+), 23 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt index 608b2b0ac2..9a3f6839da 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt @@ -53,7 +53,6 @@ import com.vitorpamplona.amethyst.model.ConnectivityType import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.FontFamilyType import com.vitorpamplona.amethyst.model.FontSizeType -import com.vitorpamplona.amethyst.model.ProfileGalleryType import com.vitorpamplona.amethyst.model.ThemeType import com.vitorpamplona.amethyst.model.UiSettingsFlow import com.vitorpamplona.amethyst.model.parseAccentColorType @@ -62,7 +61,6 @@ import com.vitorpamplona.amethyst.model.parseConnectivityType import com.vitorpamplona.amethyst.model.parseFeatureSetType import com.vitorpamplona.amethyst.model.parseFontFamilyType import com.vitorpamplona.amethyst.model.parseFontSizeType -import com.vitorpamplona.amethyst.model.parseGalleryType import com.vitorpamplona.amethyst.model.parseThemeType import com.vitorpamplona.amethyst.ui.components.TextSpinner import com.vitorpamplona.amethyst.ui.components.TitleExplainer @@ -129,7 +127,6 @@ fun SettingsScreen(sharedPrefs: UiSettingsFlow) { ShowProfilePictureChoice(sharedPrefs) ImmersiveScrollingChoice(sharedPrefs) FeatureSetChoice(sharedPrefs) - GalleryChoice(sharedPrefs) } } @@ -440,26 +437,6 @@ fun FeatureSetChoice(sharedPrefs: UiSettingsFlow) { } } -@Composable -fun GalleryChoice(sharedPrefs: UiSettingsFlow) { - val galleryItems = - persistentListOf( - TitleExplainer(stringRes(ProfileGalleryType.CLASSIC.resourceId)), - TitleExplainer(stringRes(ProfileGalleryType.MODERN.resourceId)), - ) - - val galleryIndex by sharedPrefs.gallerySet.collectAsState() - - SettingsRow( - R.string.gallery_style, - R.string.gallery_style_description, - galleryItems, - galleryIndex.screenCode, - ) { - sharedPrefs.gallerySet.tryEmit(parseGalleryType(it)) - } -} - @Composable fun SettingsRow( name: Int, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ProfileUiSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ProfileUiSettingsScreen.kt index 5b6ffb4fe2..c39794a696 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ProfileUiSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ProfileUiSettingsScreen.kt @@ -43,6 +43,10 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.ProfileGalleryType +import com.vitorpamplona.amethyst.model.UiSettingsFlow +import com.vitorpamplona.amethyst.model.parseGalleryType +import com.vitorpamplona.amethyst.ui.components.TitleExplainer import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton @@ -51,6 +55,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size20dp import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow +import kotlinx.collections.immutable.persistentListOf @Preview @Composable @@ -129,11 +134,36 @@ fun ProfileUiSettingsContent(accountViewModel: AccountViewModel) { checked = showFollowers, onCheckedChange = { ui.showProfileFollowersFeed.tryEmit(it) }, ) + HorizontalDivider(modifier = Modifier.padding(horizontal = Size20dp)) + + Column(modifier = Modifier.padding(vertical = 12.dp, horizontal = Size20dp)) { + GalleryChoice(ui) + } Spacer(Modifier.height(16.dp)) } } +@Composable +fun GalleryChoice(sharedPrefs: UiSettingsFlow) { + val galleryItems = + persistentListOf( + TitleExplainer(stringRes(ProfileGalleryType.CLASSIC.resourceId)), + TitleExplainer(stringRes(ProfileGalleryType.MODERN.resourceId)), + ) + + val galleryIndex by sharedPrefs.gallerySet.collectAsStateWithLifecycle() + + SettingsRow( + R.string.gallery_style, + R.string.gallery_style_description, + galleryItems, + galleryIndex.screenCode, + ) { + sharedPrefs.gallerySet.tryEmit(parseGalleryType(it)) + } +} + @Composable private fun ProfileUiSwitchRow( title: String, From 373aa7d74045feaa594699ddb98e4863b136bf7d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 19:56:37 +0000 Subject: [PATCH 3/3] perf: keep ColorScheme.isLight O(1) after accent-color change isLight fans out to hundreds of themed-color getters on hot note/chat/feed render paths. The accent-color work had switched it to background.luminance(), which adds per-call gamma math. Since the accent never touches background (only primary/secondary) and the dark palette's background is exactly Color.Black, a single reference comparison is just as accent-robust and restores the original constant-time cost. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01REGsru6cnm6wUzqm12Rh2d --- .../java/com/vitorpamplona/amethyst/ui/theme/Theme.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt index a53fa12c30..39f561d15d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt @@ -46,7 +46,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.compositeOver -import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity @@ -423,10 +422,12 @@ val MarkDownStyleOnLight = ), ) -// Derived from background luminance instead of a fixed primary so the check keeps working -// when the user picks a non-purple accent color (only primary/secondary change, not background). +// Compared against the dark palette's background instead of a fixed primary so the check keeps +// working when the user picks a non-purple accent (accent only changes primary/secondary, never +// background). Kept as a single reference comparison because this getter fans out to hundreds of +// themed-color call sites on hot rendering paths — luminance()/etc. would add real per-frame cost. val ColorScheme.isLight: Boolean - get() = background.luminance() > 0.5f + get() = background != Color.Black // The accent-derived tints below are computed from the live scheme's primary so they follow // the selected accent color. Color is an inline value class, so these copies don't allocate.