feat(theme): выбор цветовой темы (8 палитр) в настройках

8 тёмных палитр (Лес/Океан/Закат/Аметист/Неон/Янтарь/Лёд/Роза) в Palettes.kt.
RadiolaColors теперь несёт все токены + градиент; RadiolaTheme(palette) строит из
неё и RadiolaColors, и Material ColorScheme — всё приложение берёт цвета через
RadiolaTheme.colors, поэтому смена палитры перекрашивает мгновенно. Бренд-марка
(AppMark/Wordmark) тоже следует теме. Выбор в DataStore (theme_palette, дефолт
forest), читается в MainActivity и подаётся в тему. Секция «ТЕМА ОФОРМЛЕНИЯ» в
настройках — горизонтальный ряд свотчей с превью (фон+акцент+градиент).
This commit is contained in:
nk
2026-06-06 19:11:33 +03:00
parent d9acc0efb4
commit ed926e0a9d
8 changed files with 261 additions and 36 deletions

View File

@@ -46,6 +46,9 @@ class MainActivity : ComponentActivity() {
@Inject @Inject
lateinit var tokenDataStore: TokenDataStore lateinit var tokenDataStore: TokenDataStore
@Inject
lateinit var settingsRepository: com.radiola.domain.repository.SettingsRepository
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen() installSplashScreen()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -55,7 +58,9 @@ class MainActivity : ComponentActivity() {
} }
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
RadiolaTheme { // Выбранная цветовая тема (мгновенно перекрашивает всё приложение).
val paletteId by settingsRepository.getThemePalette().collectAsState(initial = "forest")
RadiolaTheme(palette = com.radiola.ui.theme.ThemePalette.fromId(paletteId)) {
val navController = rememberNavController() val navController = rememberNavController()
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var showPlayer by remember { mutableStateOf(false) } var showPlayer by remember { mutableStateOf(false) }

View File

@@ -32,6 +32,7 @@ class SettingsRepositoryImpl @Inject constructor(
private val PREFERRED_BITRATE = intPreferencesKey("preferred_bitrate") private val PREFERRED_BITRATE = intPreferencesKey("preferred_bitrate")
private val COUNTRY_CODE = stringPreferencesKey("country_code") private val COUNTRY_CODE = stringPreferencesKey("country_code")
private val VISUALIZER_STYLE = stringPreferencesKey("visualizer_style") private val VISUALIZER_STYLE = stringPreferencesKey("visualizer_style")
private val THEME_PALETTE = stringPreferencesKey("theme_palette")
} }
override fun getLastStationId(): Flow<Int?> = dataStore.data.map { it[LAST_STATION_ID] } override fun getLastStationId(): Flow<Int?> = dataStore.data.map { it[LAST_STATION_ID] }
@@ -61,4 +62,7 @@ class SettingsRepositoryImpl @Inject constructor(
override fun getVisualizerStyle(): Flow<String> = dataStore.data.map { it[VISUALIZER_STYLE] ?: "bars_center" } override fun getVisualizerStyle(): Flow<String> = dataStore.data.map { it[VISUALIZER_STYLE] ?: "bars_center" }
override suspend fun setVisualizerStyle(style: String) { dataStore.edit { it[VISUALIZER_STYLE] = style } } override suspend fun setVisualizerStyle(style: String) { dataStore.edit { it[VISUALIZER_STYLE] = style } }
override fun getThemePalette(): Flow<String> = dataStore.data.map { it[THEME_PALETTE] ?: "forest" }
override suspend fun setThemePalette(id: String) { dataStore.edit { it[THEME_PALETTE] = id } }
} }

View File

@@ -23,4 +23,7 @@ interface SettingsRepository {
// Стиль визуализатора звука в плеере (ключ VisualizerStyle). // Стиль визуализатора звука в плеере (ключ VisualizerStyle).
fun getVisualizerStyle(): Flow<String> fun getVisualizerStyle(): Flow<String>
suspend fun setVisualizerStyle(style: String) suspend fun setVisualizerStyle(style: String)
// Цветовая тема приложения (id ThemePalette, напр. "forest"). По умолчанию "forest".
fun getThemePalette(): Flow<String>
suspend fun setThemePalette(id: String)
} }

View File

@@ -5,7 +5,9 @@ import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
@@ -32,6 +34,7 @@ import com.radiola.domain.model.DeeplinkService
import com.radiola.domain.model.StationTestStatus import com.radiola.domain.model.StationTestStatus
import com.radiola.ui.theme.Motion import com.radiola.ui.theme.Motion
import com.radiola.ui.theme.RadiolaTheme import com.radiola.ui.theme.RadiolaTheme
import com.radiola.ui.theme.ThemePalette
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -45,6 +48,7 @@ fun SettingsScreen(
val enabledServices by viewModel.enabledServices.collectAsState() val enabledServices by viewModel.enabledServices.collectAsState()
val equalizerPreset by viewModel.equalizerPreset.collectAsState() val equalizerPreset by viewModel.equalizerPreset.collectAsState()
val visualizerStyle by viewModel.visualizerStyle.collectAsState() val visualizerStyle by viewModel.visualizerStyle.collectAsState()
val themePalette by viewModel.themePalette.collectAsState()
val isRecordingEnabled by viewModel.isRecordingEnabled.collectAsState() val isRecordingEnabled by viewModel.isRecordingEnabled.collectAsState()
val preferredBitrate by viewModel.preferredBitrate.collectAsState() val preferredBitrate by viewModel.preferredBitrate.collectAsState()
val isTesting by viewModel.isTesting.collectAsState() val isTesting by viewModel.isTesting.collectAsState()
@@ -76,6 +80,26 @@ fun SettingsScreen(
) )
} }
// --- Тема оформления ---
item {
SectionLabel("ТЕМА ОФОРМЛЕНИЯ")
Spacer(Modifier.height(8.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
ThemePalette.entries.forEach { palette ->
ThemeSwatch(
palette = palette,
selected = themePalette == palette.id,
onClick = { viewModel.setThemePalette(palette.id) }
)
}
}
}
// --- Профиль --- // --- Профиль ---
item { item {
SectionLabel("ПРОФИЛЬ") SectionLabel("ПРОФИЛЬ")
@@ -594,6 +618,61 @@ fun SettingsScreen(
} }
} }
/**
* Превью цветовой темы: квадрат с фоном палитры, акцентным кружком и брендовым
* градиентом снизу. Выбранная — с акцентной рамкой и жирной подписью.
*/
@Composable
private fun ThemeSwatch(
palette: ThemePalette,
selected: Boolean,
onClick: () -> Unit
) {
val p = palette.colors
val outer = RadiolaTheme.colors
Column(
modifier = Modifier
.width(72.dp)
.clip(RoundedCornerShape(18.dp))
.clickable(onClick = onClick),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Box(
modifier = Modifier
.size(64.dp)
.clip(RoundedCornerShape(18.dp))
.background(p.bgBase)
.border(
width = if (selected) 2.dp else 1.dp,
color = if (selected) outer.accent else outer.border,
shape = RoundedCornerShape(18.dp)
)
) {
Box(
modifier = Modifier
.align(Alignment.Center)
.size(26.dp)
.clip(CircleShape)
.background(p.accent)
)
Box(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.height(10.dp)
.background(p.brandGradient)
)
}
Text(
text = palette.title,
style = MaterialTheme.typography.labelMedium,
color = if (selected) outer.accent else outer.textSecondary,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal
)
}
}
/** Подпись секции: заглавные буквы, textMuted, labelSmall. */ /** Подпись секции: заглавные буквы, textMuted, labelSmall. */
@Composable @Composable
private fun SectionLabel(text: String) { private fun SectionLabel(text: String) {

View File

@@ -35,6 +35,9 @@ class SettingsViewModel @Inject constructor(
val visualizerStyle: StateFlow<String> = settingsRepository.getVisualizerStyle() val visualizerStyle: StateFlow<String> = settingsRepository.getVisualizerStyle()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "bars_center") .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "bars_center")
val themePalette: StateFlow<String> = settingsRepository.getThemePalette()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "forest")
val isRecordingEnabled: StateFlow<Boolean> = settingsRepository.isRecordingEnabled() val isRecordingEnabled: StateFlow<Boolean> = settingsRepository.isRecordingEnabled()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
@@ -84,6 +87,10 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { settingsRepository.setVisualizerStyle(style) } viewModelScope.launch { settingsRepository.setVisualizerStyle(style) }
} }
fun setThemePalette(id: String) {
viewModelScope.launch { settingsRepository.setThemePalette(id) }
}
fun setRecordingEnabled(enabled: Boolean) { fun setRecordingEnabled(enabled: Boolean) {
viewModelScope.launch { settingsRepository.setRecordingEnabled(enabled) } viewModelScope.launch { settingsRepository.setRecordingEnabled(enabled) }
} }

View File

@@ -31,16 +31,17 @@ fun AppMark(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val radius = (size.value * 0.29f).dp val radius = (size.value * 0.29f).dp
val colors = RadiolaTheme.colors
Box( Box(
modifier = modifier modifier = modifier
.size(size) .size(size)
.clip(RoundedCornerShape(radius)) .clip(RoundedCornerShape(radius))
.background(brandGradient()), .background(colors.brandGradient),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Text(
text = "R", text = "R",
color = BgBase, color = colors.bgBase,
fontWeight = FontWeight.Black, fontWeight = FontWeight.Black,
fontSize = (size.value * 0.62f).sp fontSize = (size.value * 0.62f).sp
) )
@@ -55,16 +56,17 @@ fun RadiolaWordmark(
fontSize: Int = 26, fontSize: Int = 26,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val colors = RadiolaTheme.colors
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
Text( Text(
text = "radi", text = "radi",
color = TextPrimary, color = colors.textPrimary,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
fontSize = fontSize.sp fontSize = fontSize.sp
) )
Text( Text(
text = "OLA", text = "OLA",
color = Accent, color = colors.accent,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
fontSize = fontSize.sp fontSize = fontSize.sp
) )

View File

@@ -0,0 +1,119 @@
package com.radiola.ui.theme
import androidx.compose.ui.graphics.Color
/**
* Цветовые темы приложения. radiOLA всегда тёмная — палитры различаются оттенком
* фона, акцентом и брендовым градиентом. Выбор хранится по [id] в настройках
* (`SettingsRepository.getThemePalette`), применяется в `RadiolaTheme`.
*
* Все цвета берутся компонентами через `RadiolaTheme.colors` / `MaterialTheme.colorScheme`,
* поэтому смена палитры перекрашивает приложение мгновенно.
*/
enum class ThemePalette(
val id: String,
val title: String,
val colors: RadiolaColors,
) {
// Фирменная зелёная (по умолчанию) — берёт значения из Color.kt.
FOREST(
"forest", "Лес",
RadiolaColors(
bgBase = BgBase, surface = BgSurface, surface2 = BgSurface2, elevated = BgElevated,
accent = Accent, accentDim = AccentDim,
textPrimary = TextPrimary, textSecondary = TextSecondary, textMuted = TextMuted,
border = BorderColor, live = LiveRed,
gradientStart = BrandGradientStart, gradientEnd = BrandGradientEnd,
),
),
// Глубокий сине-бирюзовый, акцент циан.
OCEAN(
"ocean", "Океан",
RadiolaColors(
bgBase = Color(0xFF0A0F1A), surface = Color(0xFF121A2A), surface2 = Color(0xFF1A2438), elevated = Color(0xFF222F47),
accent = Color(0xFF4FD6E0), accentDim = Color(0xFF2E8E9A),
textPrimary = Color(0xFFFFFFFF), textSecondary = Color(0xFF8F9DB3), textMuted = Color(0xFF5C6A82),
border = Color(0xFF26324A), live = Color(0xFFFF5C7A),
gradientStart = Color(0xFF5BE1F2), gradientEnd = Color(0xFF3A7BD5),
),
),
// Тёплый тёмный, коралл/оранжевый.
SUNSET(
"sunset", "Закат",
RadiolaColors(
bgBase = Color(0xFF1A0F0C), surface = Color(0xFF261712), surface2 = Color(0xFF33201A), elevated = Color(0xFF422A22),
accent = Color(0xFFFF8A5B), accentDim = Color(0xFFC55E3A),
textPrimary = Color(0xFFFFFFFF), textSecondary = Color(0xFFB39A8F), textMuted = Color(0xFF82675C),
border = Color(0xFF4A322A), live = Color(0xFFFF4D6D),
gradientStart = Color(0xFFFFB36B), gradientEnd = Color(0xFFFF6B5B),
),
),
// Тёмно-фиолетовый, акцент сирень.
AMETHYST(
"amethyst", "Аметист",
RadiolaColors(
bgBase = Color(0xFF120E1A), surface = Color(0xFF1C1528), surface2 = Color(0xFF271D38), elevated = Color(0xFF332747),
accent = Color(0xFFB388FF), accentDim = Color(0xFF7E5BC5),
textPrimary = Color(0xFFFFFFFF), textSecondary = Color(0xFFA095B3), textMuted = Color(0xFF6E5C82),
border = Color(0xFF372A4A), live = Color(0xFFFF5C9D),
gradientStart = Color(0xFFC9A6FF), gradientEnd = Color(0xFF8B5BD5),
),
),
// Почти чёрный, неоновый розово-малиновый (киберпанк).
NEON(
"neon", "Неон",
RadiolaColors(
bgBase = Color(0xFF0D0A12), surface = Color(0xFF16111E), surface2 = Color(0xFF1F1729), elevated = Color(0xFF2A1F38),
accent = Color(0xFFFF4D9D), accentDim = Color(0xFFC53A75),
textPrimary = Color(0xFFFFFFFF), textSecondary = Color(0xFFB095A6), textMuted = Color(0xFF7C5C6E),
border = Color(0xFF3A2A33), live = Color(0xFFFF3D5C),
gradientStart = Color(0xFFFF5BD0), gradientEnd = Color(0xFFFF4D7A),
),
),
// Тёплый уголь, золотой акцент.
AMBER(
"amber", "Янтарь",
RadiolaColors(
bgBase = Color(0xFF14110A), surface = Color(0xFF201A10), surface2 = Color(0xFF2C2418), elevated = Color(0xFF392F20),
accent = Color(0xFFFFC247), accentDim = Color(0xFFC5902E),
textPrimary = Color(0xFFFFFFFF), textSecondary = Color(0xFFB3A88F), textMuted = Color(0xFF82765C),
border = Color(0xFF4A3F2A), live = Color(0xFFFF5C52),
gradientStart = Color(0xFFFFD66B), gradientEnd = Color(0xFFFF9F45),
),
),
// Холодный графит, ледяной голубой.
ICE(
"ice", "Лёд",
RadiolaColors(
bgBase = Color(0xFF0C1014), surface = Color(0xFF161C22), surface2 = Color(0xFF202830), elevated = Color(0xFF2C3640),
accent = Color(0xFF7FB3FF), accentDim = Color(0xFF4F7CC5),
textPrimary = Color(0xFFFFFFFF), textSecondary = Color(0xFF95A3B3), textMuted = Color(0xFF5C6A7C),
border = Color(0xFF2A3540), live = Color(0xFFFF6B6B),
gradientStart = Color(0xFFA6D0FF), gradientEnd = Color(0xFF6B9FFF),
),
),
// Тёмная мальва, розовый акцент.
ROSE(
"rose", "Роза",
RadiolaColors(
bgBase = Color(0xFF160E12), surface = Color(0xFF22151B), surface2 = Color(0xFF2E1E26), elevated = Color(0xFF3C2A33),
accent = Color(0xFFFF7EA8), accentDim = Color(0xFFC5547A),
textPrimary = Color(0xFFFFFFFF), textSecondary = Color(0xFFB395A0), textMuted = Color(0xFF825C6A),
border = Color(0xFF4A2A38), live = Color(0xFFFF4D6D),
gradientStart = Color(0xFFFFA6C2), gradientEnd = Color(0xFFFF6B9F),
),
),
;
companion object {
/** Палитра по сохранённому id (фолбэк — «Лес»). */
fun fromId(id: String?): ThemePalette = entries.firstOrNull { it.id == id } ?: FOREST
}
}

View File

@@ -13,54 +13,60 @@ import androidx.compose.ui.graphics.Color
* Доступ через MaterialTheme-стиль: `RadiolaTheme.colors`. * Доступ через MaterialTheme-стиль: `RadiolaTheme.colors`.
*/ */
data class RadiolaColors( data class RadiolaColors(
val bgBase: Color = BgBase, val bgBase: Color,
val surface: Color = BgSurface, val surface: Color,
val surface2: Color = BgSurface2, val surface2: Color,
val elevated: Color = BgElevated, val elevated: Color,
val accent: Color = Accent, val accent: Color,
val accentDim: Color = AccentDim, val accentDim: Color,
val textPrimary: Color = TextPrimary, val textPrimary: Color,
val textSecondary: Color = TextSecondary, val textSecondary: Color,
val textMuted: Color = TextMuted, val textMuted: Color,
val border: Color = BorderColor, val border: Color,
val live: Color = LiveRed, val live: Color,
val gradientStart: Color,
val gradientEnd: Color,
) { ) {
val brandGradient: Brush val brandGradient: Brush
get() = Brush.linearGradient(listOf(BrandGradientStart, BrandGradientEnd)) get() = Brush.linearGradient(listOf(gradientStart, gradientEnd))
} }
val LocalRadiolaColors = staticCompositionLocalOf { RadiolaColors() } // По умолчанию — фирменная палитра «Лес».
val LocalRadiolaColors = staticCompositionLocalOf { ThemePalette.FOREST.colors }
object RadiolaTheme { object RadiolaTheme {
val colors: RadiolaColors val colors: RadiolaColors
@Composable get() = LocalRadiolaColors.current @Composable get() = LocalRadiolaColors.current
} }
private val DarkColorScheme = darkColorScheme( // Material ColorScheme из наших токенов — чтобы Material-компоненты тоже следовали палитре.
primary = Accent, private fun schemeOf(c: RadiolaColors) = darkColorScheme(
onPrimary = BgBase, primary = c.accent,
secondary = AccentDim, onPrimary = c.bgBase,
onSecondary = BgBase, secondary = c.accentDim,
background = BgBase, onSecondary = c.bgBase,
onBackground = TextPrimary, background = c.bgBase,
surface = BgSurface, onBackground = c.textPrimary,
onSurface = TextPrimary, surface = c.surface,
surfaceVariant = BgSurface2, onSurface = c.textPrimary,
onSurfaceVariant = TextSecondary, surfaceVariant = c.surface2,
outline = BorderColor, onSurfaceVariant = c.textSecondary,
outlineVariant = BorderColor, outline = c.border,
error = LiveRed, outlineVariant = c.border,
onError = BgBase, error = c.live,
onError = c.bgBase,
) )
@Composable @Composable
fun RadiolaTheme( fun RadiolaTheme(
palette: ThemePalette = ThemePalette.FOREST,
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
// Приложение всегда в тёмной фирменной теме. // Приложение всегда тёмное; палитра выбирается пользователем.
CompositionLocalProvider(LocalRadiolaColors provides RadiolaColors()) { val colors = palette.colors
CompositionLocalProvider(LocalRadiolaColors provides colors) {
MaterialTheme( MaterialTheme(
colorScheme = DarkColorScheme, colorScheme = schemeOf(colors),
typography = Typography, typography = Typography,
shapes = RadiolaShapes, shapes = RadiolaShapes,
content = content content = content