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
lateinit var tokenDataStore: TokenDataStore
@Inject
lateinit var settingsRepository: com.radiola.domain.repository.SettingsRepository
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState)
@@ -55,7 +58,9 @@ class MainActivity : ComponentActivity() {
}
enableEdgeToEdge()
setContent {
RadiolaTheme {
// Выбранная цветовая тема (мгновенно перекрашивает всё приложение).
val paletteId by settingsRepository.getThemePalette().collectAsState(initial = "forest")
RadiolaTheme(palette = com.radiola.ui.theme.ThemePalette.fromId(paletteId)) {
val navController = rememberNavController()
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
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 COUNTRY_CODE = stringPreferencesKey("country_code")
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] }
@@ -61,4 +62,7 @@ class SettingsRepositoryImpl @Inject constructor(
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 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).
fun getVisualizerStyle(): Flow<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.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
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.ui.theme.Motion
import com.radiola.ui.theme.RadiolaTheme
import com.radiola.ui.theme.ThemePalette
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -45,6 +48,7 @@ fun SettingsScreen(
val enabledServices by viewModel.enabledServices.collectAsState()
val equalizerPreset by viewModel.equalizerPreset.collectAsState()
val visualizerStyle by viewModel.visualizerStyle.collectAsState()
val themePalette by viewModel.themePalette.collectAsState()
val isRecordingEnabled by viewModel.isRecordingEnabled.collectAsState()
val preferredBitrate by viewModel.preferredBitrate.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 {
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. */
@Composable
private fun SectionLabel(text: String) {

View File

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

View File

@@ -31,16 +31,17 @@ fun AppMark(
modifier: Modifier = Modifier
) {
val radius = (size.value * 0.29f).dp
val colors = RadiolaTheme.colors
Box(
modifier = modifier
.size(size)
.clip(RoundedCornerShape(radius))
.background(brandGradient()),
.background(colors.brandGradient),
contentAlignment = Alignment.Center
) {
Text(
text = "R",
color = BgBase,
color = colors.bgBase,
fontWeight = FontWeight.Black,
fontSize = (size.value * 0.62f).sp
)
@@ -55,16 +56,17 @@ fun RadiolaWordmark(
fontSize: Int = 26,
modifier: Modifier = Modifier
) {
val colors = RadiolaTheme.colors
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
Text(
text = "radi",
color = TextPrimary,
color = colors.textPrimary,
fontWeight = FontWeight.Bold,
fontSize = fontSize.sp
)
Text(
text = "OLA",
color = Accent,
color = colors.accent,
fontWeight = FontWeight.Bold,
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`.
*/
data class RadiolaColors(
val bgBase: Color = BgBase,
val surface: Color = BgSurface,
val surface2: Color = BgSurface2,
val elevated: Color = BgElevated,
val accent: Color = Accent,
val accentDim: Color = AccentDim,
val textPrimary: Color = TextPrimary,
val textSecondary: Color = TextSecondary,
val textMuted: Color = TextMuted,
val border: Color = BorderColor,
val live: Color = LiveRed,
val bgBase: Color,
val surface: Color,
val surface2: Color,
val elevated: Color,
val accent: Color,
val accentDim: Color,
val textPrimary: Color,
val textSecondary: Color,
val textMuted: Color,
val border: Color,
val live: Color,
val gradientStart: Color,
val gradientEnd: Color,
) {
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 {
val colors: RadiolaColors
@Composable get() = LocalRadiolaColors.current
}
private val DarkColorScheme = darkColorScheme(
primary = Accent,
onPrimary = BgBase,
secondary = AccentDim,
onSecondary = BgBase,
background = BgBase,
onBackground = TextPrimary,
surface = BgSurface,
onSurface = TextPrimary,
surfaceVariant = BgSurface2,
onSurfaceVariant = TextSecondary,
outline = BorderColor,
outlineVariant = BorderColor,
error = LiveRed,
onError = BgBase,
// Material ColorScheme из наших токенов — чтобы Material-компоненты тоже следовали палитре.
private fun schemeOf(c: RadiolaColors) = darkColorScheme(
primary = c.accent,
onPrimary = c.bgBase,
secondary = c.accentDim,
onSecondary = c.bgBase,
background = c.bgBase,
onBackground = c.textPrimary,
surface = c.surface,
onSurface = c.textPrimary,
surfaceVariant = c.surface2,
onSurfaceVariant = c.textSecondary,
outline = c.border,
outlineVariant = c.border,
error = c.live,
onError = c.bgBase,
)
@Composable
fun RadiolaTheme(
palette: ThemePalette = ThemePalette.FOREST,
content: @Composable () -> Unit
) {
// Приложение всегда в тёмной фирменной теме.
CompositionLocalProvider(LocalRadiolaColors provides RadiolaColors()) {
// Приложение всегда тёмное; палитра выбирается пользователем.
val colors = palette.colors
CompositionLocalProvider(LocalRadiolaColors provides colors) {
MaterialTheme(
colorScheme = DarkColorScheme,
colorScheme = schemeOf(colors),
typography = Typography,
shapes = RadiolaShapes,
content = content