feat(#208): add Monochrome, Gruvbox, Catppuccin and AMOLED theme presets

This commit is contained in:
Hy4ri committed 2026-06-21 13:01:55 +03:00
1 parent f738f33fc5
commit 8e8fca756a
6 files changed
+368 -36

No files matched your search

@@ -24,7 +24,13 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge()
setContent {
val themePreference by AuthManager.themePreferenceFlow.collectAsState()
HermesControlTheme(themePreference = themePreference) {
val useDynamicColors by AuthManager.useDynamicColorsFlow.collectAsState()
val themePreset by AuthManager.themePresetFlow.collectAsState()
HermesControlTheme(
themePreference = themePreference,
useDynamicColors = useDynamicColors,
themePreset = themePreset,
) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
@@ -5,6 +5,7 @@ import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import com.m57.hermescontrol.theme.ThemePreference
import com.m57.hermescontrol.theme.ThemePreset
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -28,6 +29,8 @@ object AuthManager {
private const val KEY_TYPING_EFFECT_DELAY_MS = "typing_effect_delay_ms"
private const val KEY_CONNECTION_PROFILES = "connection_profiles"
private const val KEY_SELECTED_PROFILE_ID = "selected_profile_id"
private const val KEY_USE_DYNAMIC_COLORS = "use_dynamic_colors"
private const val KEY_THEME_PRESET = "theme_preset"
private const val DEFAULT_HOST = "127.0.0.1"
private const val DEFAULT_PORT = 9119
@@ -44,6 +47,11 @@ object AuthManager {
private val _themePreferenceFlow = MutableStateFlow<ThemePreference>(ThemePreference.SYSTEM)
val themePreferenceFlow: StateFlow<ThemePreference> = _themePreferenceFlow.asStateFlow()
private val _useDynamicColorsFlow = MutableStateFlow<Boolean>(true)
val useDynamicColorsFlow: StateFlow<Boolean> = _useDynamicColorsFlow.asStateFlow()
private val _themePresetFlow = MutableStateFlow<ThemePreset>(ThemePreset.DEFAULT)
val themePresetFlow: StateFlow<ThemePreset> = _themePresetFlow.asStateFlow()
private val gson = com.google.gson.Gson()
/**
@@ -71,6 +79,11 @@ object AuthManager {
_bottomNavItemsFlow.value = getBottomNavItems()
_themePreferenceFlow.value = getThemePreference()
<<<<<<< HEAD
=======
_useDynamicColorsFlow.value = isUseDynamicColors()
_themePresetFlow.value = getThemePreset()
>>>>>>> a9853754 (feat(#208): add Monochrome, Gruvbox, Catppuccin and AMOLED theme presets)
}
}
@@ -213,6 +226,23 @@ object AuthManager {
_themePreferenceFlow.value = theme
}
fun isUseDynamicColors(): Boolean = requirePrefs().getBoolean(KEY_USE_DYNAMIC_COLORS, true)
fun setUseDynamicColors(value: Boolean) {
requirePrefs().edit().putBoolean(KEY_USE_DYNAMIC_COLORS, value).apply()
_useDynamicColorsFlow.value = value
}
fun getThemePreset(): ThemePreset =
requirePrefs().getString(KEY_THEME_PRESET, ThemePreset.DEFAULT.name)?.let { name ->
runCatching { ThemePreset.valueOf(name) }.getOrNull()
} ?: ThemePreset.DEFAULT
fun setThemePreset(preset: ThemePreset) {
requirePrefs().edit().putString(KEY_THEME_PRESET, preset.name).apply()
_themePresetFlow.value = preset
}
/** Convenience: build the base URL from current host + port.
* NOTE: Uses plain http:// — intended for trusted local network only.
* Exposing the host to a hostile LAN risks token interception. */
@@ -15,19 +15,13 @@ import androidx.compose.ui.platform.LocalContext
enum class ThemePreference { SYSTEM, LIGHT, DARK }
enum class ThemePreset { DEFAULT, MONOCHROME, GRUVBOX, CATPPUCCIN, AMOLED }
val LocalThemePreference = compositionLocalOf { ThemePreference.SYSTEM }
val LocalThemePreset = compositionLocalOf { ThemePreset.DEFAULT }
/**
* Set to true to opt into Material You dynamic color (Android 12+).
*
* On API 31+ the app derives its entire colour scheme from the user's
* wallpaper — primary, secondary, tertiary, surfaces, the lot. On older
* devices the [HermesDarkColorScheme] / [HermesLightColorScheme] brand
* palette is used instead.
*
* Semantic status colours (success / warning / error / info) and chat-bubble
* colours are ALWAYS brand-defined regardless of dynamic colour — see
* [Color.kt] and the `LocalHermesStatusColors` provider below.
*/
private const val ENABLE_DYNAMIC_COLOR = true
@@ -97,10 +91,144 @@ private val HermesLightColorScheme =
scrim = LightScrim,
)
private val MonochromeDarkColorScheme =
darkColorScheme(
primary = Color.White,
onPrimary = Color.Black,
primaryContainer = Color(0xFF2B2B2B),
onPrimaryContainer = Color.White,
secondary = Color(0xFFB0B0B0),
onSecondary = Color.Black,
secondaryContainer = Color(0xFF222222),
onSecondaryContainer = Color(0xFFE0E0E0),
background = Color(0xFF121212),
onBackground = Color(0xFFE0E0E0),
surface = Color(0xFF1E1E1E),
onSurface = Color(0xFFE0E0E0),
surfaceVariant = Color(0xFF2B2B2B),
onSurfaceVariant = Color(0xFFB0B0B0),
outline = Color(0xFF666666),
)
private val MonochromeLightColorScheme =
lightColorScheme(
primary = Color.Black,
onPrimary = Color.White,
primaryContainer = Color(0xFFE0E0E0),
onPrimaryContainer = Color.Black,
secondary = Color(0xFF4A4A4A),
onSecondary = Color.White,
secondaryContainer = Color(0xFFF0F0F0),
onSecondaryContainer = Color(0xFF111111),
background = Color(0xFFFFFFFF),
onBackground = Color(0xFF111111),
surface = Color(0xFFF5F5F5),
onSurface = Color(0xFF111111),
surfaceVariant = Color(0xFFE0E0E0),
onSurfaceVariant = Color(0xFF4A4A4A),
outline = Color(0xFF888888),
)
private val GruvboxDarkColorScheme =
darkColorScheme(
primary = Color(0xFFFE8019),
onPrimary = Color(0xFF282828),
primaryContainer = Color(0xFFD65D0E),
onPrimaryContainer = Color(0xFFFBF1C7),
secondary = Color(0xFFFABD2F),
onSecondary = Color(0xFF282828),
secondaryContainer = Color(0xFF3C3836),
onSecondaryContainer = Color(0xFFBDAE93),
background = Color(0xFF282828),
onBackground = Color(0xFFFBF1C7),
surface = Color(0xFF32302F),
onSurface = Color(0xFFFBF1C7),
surfaceVariant = Color(0xFF3C3836),
onSurfaceVariant = Color(0xFFBDAE93),
outline = Color(0xFF665C54),
)
private val GruvboxLightColorScheme =
lightColorScheme(
primary = Color(0xFFD65D0E),
onPrimary = Color(0xFFFBF1C7),
primaryContainer = Color(0xFFFE8019),
onPrimaryContainer = Color(0xFF282828),
secondary = Color(0xFF458588),
onSecondary = Color(0xFFFBF1C7),
secondaryContainer = Color(0xFFEBDBB2),
onSecondaryContainer = Color(0xFF3C3836),
background = Color(0xFFFBF1C7),
onBackground = Color(0xFF282828),
surface = Color(0xFFF2E5BC),
onSurface = Color(0xFF282828),
surfaceVariant = Color(0xFFEBDBB2),
onSurfaceVariant = Color(0xFF7C6F64),
outline = Color(0xFFA89984),
)
private val CatppuccinDarkColorScheme =
darkColorScheme(
primary = Color(0xFFCBA6F7),
onPrimary = Color(0xFF11111B),
primaryContainer = Color(0xFF585B70),
onPrimaryContainer = Color(0xFFF5E0DC),
secondary = Color(0xFF89B4FA),
onSecondary = Color(0xFF11111B),
secondaryContainer = Color(0xFF313244),
onSecondaryContainer = Color(0xFFBAC2DE),
background = Color(0xFF1E1E2E),
onBackground = Color(0xFFCDD6F4),
surface = Color(0xFF252538),
onSurface = Color(0xFFCDD6F4),
surfaceVariant = Color(0xFF313244),
onSurfaceVariant = Color(0xFFA6ADC8),
outline = Color(0xFF585B70),
)
private val CatppuccinLightColorScheme =
lightColorScheme(
primary = Color(0xFF8839EF),
onPrimary = Color(0xFFEFF1F5),
primaryContainer = Color(0xFFCCD0DA),
onPrimaryContainer = Color(0xFF4C4F69),
secondary = Color(0xFF1E66F5),
onSecondary = Color(0xFFEFF1F5),
secondaryContainer = Color(0xFFE6E9EF),
onSecondaryContainer = Color(0xFF4C4F69),
background = Color(0xFFEFF1F5),
onBackground = Color(0xFF4C4F69),
surface = Color(0xFFE6E9EF),
onSurface = Color(0xFF4C4F69),
surfaceVariant = Color(0xFFCCD0DA),
onSurfaceVariant = Color(0xFF6C6F85),
outline = Color(0xFF9CA0B0),
)
private val AmoledDarkColorScheme =
darkColorScheme(
primary = HermesPurple,
onPrimary = Color.White,
primaryContainer = Color(0xFF1A1040),
onPrimaryContainer = HermesPurpleOnContainer,
secondary = HermesAmber,
onSecondary = Color.Black,
secondaryContainer = Color(0xFF2A2000),
onSecondaryContainer = HermesAmberLight,
background = Color.Black,
onBackground = Color(0xFFE8E6EE),
surface = Color(0xFF08080A),
onSurface = Color(0xFFE8E6EE),
surfaceVariant = Color(0xFF121218),
onSurfaceVariant = Color(0xFFB6B2C4),
outline = Color(0xFF3A3A4A),
)
@Composable
fun HermesControlTheme(
themePreference: ThemePreference = LocalThemePreference.current,
enableDynamicColor: Boolean = ENABLE_DYNAMIC_COLOR,
useDynamicColors: Boolean = true,
themePreset: ThemePreset = ThemePreset.DEFAULT,
content: @Composable () -> Unit,
) {
val darkTheme =
@@ -111,41 +239,101 @@ fun HermesControlTheme(
}
val context = LocalContext.current
val dynamicAvailable = enableDynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
val dynamicAvailable = useDynamicColors && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
val colorScheme =
when {
dynamicAvailable && darkTheme -> dynamicDarkColorScheme(context)
dynamicAvailable && !darkTheme -> dynamicLightColorScheme(context)
darkTheme -> HermesDarkColorScheme
else -> HermesLightColorScheme
else -> {
when (themePreset) {
ThemePreset.DEFAULT -> if (darkTheme) HermesDarkColorScheme else HermesLightColorScheme
ThemePreset.MONOCHROME -> if (darkTheme) MonochromeDarkColorScheme else MonochromeLightColorScheme
ThemePreset.GRUVBOX -> if (darkTheme) GruvboxDarkColorScheme else GruvboxLightColorScheme
ThemePreset.CATPPUCCIN -> if (darkTheme) CatppuccinDarkColorScheme else CatppuccinLightColorScheme
ThemePreset.AMOLED -> if (darkTheme) AmoledDarkColorScheme else HermesLightColorScheme
}
}
}
val statusColors =
if (darkTheme) {
HermesStatusColors(
success = StatusGreen,
successContainer = StatusGreenContainer,
warning = StatusYellow,
warningContainer = StatusYellowContainer,
error = StatusRed,
errorContainer = StatusRedContainer,
info = StatusBlue,
infoContainer = StatusBlueContainer,
)
} else {
HermesStatusColors(
success = Color(0xFF1B873A),
successContainer = Color(0xFFD7F5E0),
warning = HermesAmberDark,
warningContainer = Color(0xFFFFEAB3),
error = Color(0xFFB3261E),
errorContainer = Color(0xFFF9DEDC),
info = Color(0xFF2E6FBD),
infoContainer = Color(0xFFD4E7FF),
)
when {
themePreset == ThemePreset.GRUVBOX && darkTheme -> {
HermesStatusColors(
success = Color(0xFFB8BB26),
successContainer = Color(0xFF32302F),
warning = Color(0xFFFABD2F),
warningContainer = Color(0xFF3C3836),
error = Color(0xFFFB4934),
errorContainer = Color(0xFF282828),
info = Color(0xFF83A598),
infoContainer = Color(0xFF3C3836),
)
}
themePreset == ThemePreset.GRUVBOX && !darkTheme -> {
HermesStatusColors(
success = Color(0xFF98971A),
successContainer = Color(0xFFEBDBB2),
warning = Color(0xFFD79921),
warningContainer = Color(0xFFEBDBB2),
error = Color(0xFFCC241D),
errorContainer = Color(0xFFEBDBB2),
info = Color(0xFF458588),
infoContainer = Color(0xFFEBDBB2),
)
}
themePreset == ThemePreset.CATPPUCCIN && darkTheme -> {
HermesStatusColors(
success = Color(0xFFA6E3A1),
successContainer = Color(0xFF313244),
warning = Color(0xFFF9E2AF),
warningContainer = Color(0xFF313244),
error = Color(0xFFF38BA8),
errorContainer = Color(0xFF313244),
info = Color(0xFF89B4FA),
infoContainer = Color(0xFF313244),
)
}
themePreset == ThemePreset.CATPPUCCIN && !darkTheme -> {
HermesStatusColors(
success = Color(0xFF40A02B),
successContainer = Color(0xFFE6E9EF),
warning = Color(0xFFDF8E1D),
warningContainer = Color(0xFFE6E9EF),
error = Color(0xFFD20F39),
errorContainer = Color(0xFFE6E9EF),
info = Color(0xFF1E66F5),
infoContainer = Color(0xFFE6E9EF),
)
}
darkTheme -> {
HermesStatusColors(
success = StatusGreen,
successContainer = StatusGreenContainer,
warning = StatusYellow,
warningContainer = StatusYellowContainer,
error = StatusRed,
errorContainer = StatusRedContainer,
info = StatusBlue,
infoContainer = StatusBlueContainer,
)
}
else -> {
HermesStatusColors(
success = Color(0xFF1B873A),
successContainer = Color(0xFFD7F5E0),
warning = HermesAmberDark,
warningContainer = Color(0xFFFFEAB3),
error = Color(0xFFB3261E),
errorContainer = Color(0xFFF9DEDC),
info = Color(0xFF2E6FBD),
infoContainer = Color(0xFFD4E7FF),
)
}
}
CompositionLocalProvider(
LocalThemePreference provides themePreference,
LocalThemePreset provides themePreset,
LocalHermesStatusColors provides statusColors,
LocalSpacing provides SpacingDefaults,
LocalMotion provides MotionDefaults,
@@ -64,6 +64,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.BuildConfig
import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.ThemePreference
import com.m57.hermescontrol.theme.ThemePreset
import com.m57.hermescontrol.ui.common.HermesScaffold
@OptIn(ExperimentalMaterial3Api::class)
@@ -322,6 +323,88 @@ fun SettingsScreen(
}
}
}
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.settings_item_use_dynamic_colors),
style = MaterialTheme.typography.bodyLarge,
)
Text(
text = stringResource(R.string.settings_desc_use_dynamic_colors),
style =
MaterialTheme.typography.bodySmall.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
),
)
}
Switch(
checked = state.useDynamicColors,
onCheckedChange = viewModel::onUseDynamicColorsChange,
colors =
SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colorScheme.primary,
checkedTrackColor = MaterialTheme.colorScheme.primaryContainer,
),
)
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.settings_item_theme_preset),
style = MaterialTheme.typography.bodyLarge,
)
Spacer(modifier = Modifier.height(8.dp))
var presetsExpanded by remember { mutableStateOf(false) }
Box(modifier = Modifier.fillMaxWidth()) {
OutlinedButton(
onClick = { presetsExpanded = true },
modifier = Modifier.fillMaxWidth(),
) {
Text(
when (state.themePreset) {
ThemePreset.DEFAULT -> stringResource(R.string.theme_preset_default)
ThemePreset.MONOCHROME -> stringResource(R.string.theme_preset_monochrome)
ThemePreset.GRUVBOX -> stringResource(R.string.theme_preset_gruvbox)
ThemePreset.CATPPUCCIN -> stringResource(R.string.theme_preset_catppuccin)
ThemePreset.AMOLED -> stringResource(R.string.theme_preset_amoled)
},
)
}
DropdownMenu(
expanded = presetsExpanded,
onDismissRequest = { presetsExpanded = false },
modifier = Modifier.fillMaxWidth(0.85f),
) {
ThemePreset.entries.forEach { preset ->
DropdownMenuItem(
text = {
Text(
when (preset) {
ThemePreset.DEFAULT -> stringResource(R.string.theme_preset_default)
ThemePreset.MONOCHROME -> stringResource(R.string.theme_preset_monochrome)
ThemePreset.GRUVBOX -> stringResource(R.string.theme_preset_gruvbox)
ThemePreset.CATPPUCCIN -> stringResource(R.string.theme_preset_catppuccin)
ThemePreset.AMOLED -> stringResource(R.string.theme_preset_amoled)
},
)
},
onClick = {
viewModel.onThemePresetChange(preset)
presetsExpanded = false
},
)
}
}
}
}
// Chat section
@@ -8,6 +8,7 @@ import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.theme.ThemePreference
import com.m57.hermescontrol.theme.ThemePreset
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -22,6 +23,8 @@ data class SettingsUiState(
val token: String = "",
val autoReconnect: Boolean = true,
val themePreference: ThemePreference = ThemePreference.SYSTEM,
val useDynamicColors: Boolean = true,
val themePreset: ThemePreset = ThemePreset.DEFAULT,
val isTesting: Boolean = false,
val testResult: String? = null,
val isSaved: Boolean = false,
@@ -53,6 +56,8 @@ class SettingsViewModel : ViewModel() {
// choice from persistent storage on init. Previously this slot
// was missing — always defaulted to ThemePreference.SYSTEM.
themePreference = AuthManager.getThemePreference(),
useDynamicColors = AuthManager.isUseDynamicColors(),
themePreset = AuthManager.getThemePreset(),
selectedNavItems = AuthManager.getBottomNavItems(),
typingEffectEnabled = AuthManager.isTypingEffectEnabled(),
typingEffectDelayMs = AuthManager.getTypingEffectDelayMs(),
@@ -122,6 +127,16 @@ class SettingsViewModel : ViewModel() {
AuthManager.setThemePreference(theme)
}
fun onUseDynamicColorsChange(enabled: Boolean) {
_uiState.update { it.copy(useDynamicColors = enabled, isSaved = false) }
AuthManager.setUseDynamicColors(enabled)
}
fun onThemePresetChange(preset: ThemePreset) {
_uiState.update { it.copy(themePreset = preset, isSaved = false) }
AuthManager.setThemePreset(preset)
}
fun onTypingEffectEnabledChange(enabled: Boolean) {
_uiState.update { it.copy(typingEffectEnabled = enabled, isSaved = false) }
}
@@ -188,6 +203,8 @@ class SettingsViewModel : ViewModel() {
// B6 (Jun 18 2026, kanban t_86e9be9b): persist theme choice so it
// survives a cold start (was previously dropped on save()).
AuthManager.setThemePreference(state.themePreference)
AuthManager.setUseDynamicColors(state.useDynamicColors)
AuthManager.setThemePreset(state.themePreset)
AuthManager.setTypingEffectEnabled(state.typingEffectEnabled)
AuthManager.setTypingEffectDelayMs(state.typingEffectDelayMs)
ApiClient.rebuild()