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:
@@ -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) {
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
119
app/src/main/java/com/radiola/ui/theme/Palettes.kt
Normal file
119
app/src/main/java/com/radiola/ui/theme/Palettes.kt
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user