From ed926e0a9d5c0287bd3f90fe13302d917b1bfa80 Mon Sep 17 00:00:00 2001 From: nk Date: Sat, 6 Jun 2026 19:11:33 +0300 Subject: [PATCH] =?UTF-8?q?feat(theme):=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80?= =?UTF-8?q?=20=D1=86=D0=B2=D0=B5=D1=82=D0=BE=D0=B2=D0=BE=D0=B9=20=D1=82?= =?UTF-8?q?=D0=B5=D0=BC=D1=8B=20(8=20=D0=BF=D0=B0=D0=BB=D0=B8=D1=82=D1=80)?= =?UTF-8?q?=20=D0=B2=20=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B9=D0=BA?= =?UTF-8?q?=D0=B0=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 8 тёмных палитр (Лес/Океан/Закат/Аметист/Неон/Янтарь/Лёд/Роза) в Palettes.kt. RadiolaColors теперь несёт все токены + градиент; RadiolaTheme(palette) строит из неё и RadiolaColors, и Material ColorScheme — всё приложение берёт цвета через RadiolaTheme.colors, поэтому смена палитры перекрашивает мгновенно. Бренд-марка (AppMark/Wordmark) тоже следует теме. Выбор в DataStore (theme_palette, дефолт forest), читается в MainActivity и подаётся в тему. Секция «ТЕМА ОФОРМЛЕНИЯ» в настройках — горизонтальный ряд свотчей с превью (фон+акцент+градиент). --- app/src/main/java/com/radiola/MainActivity.kt | 7 +- .../data/repository/SettingsRepositoryImpl.kt | 4 + .../domain/repository/SettingsRepository.kt | 3 + .../com/radiola/ui/settings/SettingsScreen.kt | 79 ++++++++++++ .../radiola/ui/settings/SettingsViewModel.kt | 7 ++ .../main/java/com/radiola/ui/theme/Brand.kt | 10 +- .../java/com/radiola/ui/theme/Palettes.kt | 119 ++++++++++++++++++ .../main/java/com/radiola/ui/theme/Theme.kt | 68 +++++----- 8 files changed, 261 insertions(+), 36 deletions(-) create mode 100644 app/src/main/java/com/radiola/ui/theme/Palettes.kt diff --git a/app/src/main/java/com/radiola/MainActivity.kt b/app/src/main/java/com/radiola/MainActivity.kt index fb7202a..9185350 100644 --- a/app/src/main/java/com/radiola/MainActivity.kt +++ b/app/src/main/java/com/radiola/MainActivity.kt @@ -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) } diff --git a/app/src/main/java/com/radiola/data/repository/SettingsRepositoryImpl.kt b/app/src/main/java/com/radiola/data/repository/SettingsRepositoryImpl.kt index 311c04a..b9976c6 100644 --- a/app/src/main/java/com/radiola/data/repository/SettingsRepositoryImpl.kt +++ b/app/src/main/java/com/radiola/data/repository/SettingsRepositoryImpl.kt @@ -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 = dataStore.data.map { it[LAST_STATION_ID] } @@ -61,4 +62,7 @@ class SettingsRepositoryImpl @Inject constructor( override fun getVisualizerStyle(): Flow = dataStore.data.map { it[VISUALIZER_STYLE] ?: "bars_center" } override suspend fun setVisualizerStyle(style: String) { dataStore.edit { it[VISUALIZER_STYLE] = style } } + + override fun getThemePalette(): Flow = dataStore.data.map { it[THEME_PALETTE] ?: "forest" } + override suspend fun setThemePalette(id: String) { dataStore.edit { it[THEME_PALETTE] = id } } } diff --git a/app/src/main/java/com/radiola/domain/repository/SettingsRepository.kt b/app/src/main/java/com/radiola/domain/repository/SettingsRepository.kt index e0eb04a..e4d03f1 100644 --- a/app/src/main/java/com/radiola/domain/repository/SettingsRepository.kt +++ b/app/src/main/java/com/radiola/domain/repository/SettingsRepository.kt @@ -23,4 +23,7 @@ interface SettingsRepository { // Стиль визуализатора звука в плеере (ключ VisualizerStyle). fun getVisualizerStyle(): Flow suspend fun setVisualizerStyle(style: String) + // Цветовая тема приложения (id ThemePalette, напр. "forest"). По умолчанию "forest". + fun getThemePalette(): Flow + suspend fun setThemePalette(id: String) } diff --git a/app/src/main/java/com/radiola/ui/settings/SettingsScreen.kt b/app/src/main/java/com/radiola/ui/settings/SettingsScreen.kt index 2e13733..1fa6099 100644 --- a/app/src/main/java/com/radiola/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/radiola/ui/settings/SettingsScreen.kt @@ -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) { diff --git a/app/src/main/java/com/radiola/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/radiola/ui/settings/SettingsViewModel.kt index 83b9291..3e64ee5 100644 --- a/app/src/main/java/com/radiola/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/radiola/ui/settings/SettingsViewModel.kt @@ -35,6 +35,9 @@ class SettingsViewModel @Inject constructor( val visualizerStyle: StateFlow = settingsRepository.getVisualizerStyle() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "bars_center") + val themePalette: StateFlow = settingsRepository.getThemePalette() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "forest") + val isRecordingEnabled: StateFlow = 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) } } diff --git a/app/src/main/java/com/radiola/ui/theme/Brand.kt b/app/src/main/java/com/radiola/ui/theme/Brand.kt index 4f94d17..23bedf2 100644 --- a/app/src/main/java/com/radiola/ui/theme/Brand.kt +++ b/app/src/main/java/com/radiola/ui/theme/Brand.kt @@ -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 ) diff --git a/app/src/main/java/com/radiola/ui/theme/Palettes.kt b/app/src/main/java/com/radiola/ui/theme/Palettes.kt new file mode 100644 index 0000000..d9086cb --- /dev/null +++ b/app/src/main/java/com/radiola/ui/theme/Palettes.kt @@ -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 + } +} diff --git a/app/src/main/java/com/radiola/ui/theme/Theme.kt b/app/src/main/java/com/radiola/ui/theme/Theme.kt index 3a935e8..b26527b 100644 --- a/app/src/main/java/com/radiola/ui/theme/Theme.kt +++ b/app/src/main/java/com/radiola/ui/theme/Theme.kt @@ -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