diff --git a/app/src/main/java/com/m57/hermescontrol/MainActivity.kt b/app/src/main/java/com/m57/hermescontrol/MainActivity.kt index ffc26e5d..004014da 100644 --- a/app/src/main/java/com/m57/hermescontrol/MainActivity.kt +++ b/app/src/main/java/com/m57/hermescontrol/MainActivity.kt @@ -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, diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt index d5061410..76400951 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt @@ -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.SYSTEM) val themePreferenceFlow: StateFlow = _themePreferenceFlow.asStateFlow() + private val _useDynamicColorsFlow = MutableStateFlow(true) + val useDynamicColorsFlow: StateFlow = _useDynamicColorsFlow.asStateFlow() + + private val _themePresetFlow = MutableStateFlow(ThemePreset.DEFAULT) + val themePresetFlow: StateFlow = _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. */ diff --git a/app/src/main/java/com/m57/hermescontrol/theme/Theme.kt b/app/src/main/java/com/m57/hermescontrol/theme/Theme.kt index ed2f0378..6adf38ea 100644 --- a/app/src/main/java/com/m57/hermescontrol/theme/Theme.kt +++ b/app/src/main/java/com/m57/hermescontrol/theme/Theme.kt @@ -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, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt index 06ab577f..1e2b71ea 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt @@ -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 diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt index b6947c89..17a5a039 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt @@ -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() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bec1d6a7..ded22663 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -148,6 +148,14 @@ System Light Dark + Dynamic Colors + Derive scheme from wallpaper (Android 12+) + Preset Theme + Default (Voltage Purple) + Monochrome + Gruvbox + Catppuccin + AMOLED Black (Dark only) Profile: %1$s