feat(ui): дизайн-система radiOLA — палитра, тема, типографика, бренд, motion, pill таб-бар

- цветовые токены тёмно-зелёной темы + RadiolaColors (CompositionLocal)
- darkColorScheme + всегда тёмная тема, фирменные shapes
- типографика с весами/размерами под макет
- Brand: AppMark (градиентный R), RadiolaWordmark, MonoMark
- Motion: спеки движения, pressScale, живой эквалайзер
- pill-таб-бар с анимированной активной вкладкой
This commit is contained in:
nk
2026-06-02 21:13:27 +03:00
parent bcb999ace9
commit a614ac3764
7 changed files with 425 additions and 50 deletions

View File

@@ -1,22 +1,63 @@
package com.radiola.ui.navigation package com.radiola.ui.navigation
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkHorizontally
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
import com.radiola.ui.theme.Motion
import com.radiola.ui.theme.RadiolaTheme
@Composable @Composable
fun BottomNavBar(navController: NavController) { fun BottomNavBar(navController: NavController) {
val colors = RadiolaTheme.colors
val currentRoute = navController.currentBackStackEntryAsState().value?.destination?.route val currentRoute = navController.currentBackStackEntryAsState().value?.destination?.route
NavigationBar { val items = NavDestinations.items.filter { it.showInBottomBar }
NavDestinations.items.filter { it.showInBottomBar }.forEach { destination ->
NavigationBarItem( Row(
icon = { Icon(destination.icon, contentDescription = destination.labelRes) }, modifier = Modifier
label = { Text(destination.labelRes) }, .fillMaxWidth()
selected = currentRoute == destination.route, .padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 16.dp)
.clip(RoundedCornerShape(36.dp))
.background(colors.surface2)
.border(1.dp, colors.border, RoundedCornerShape(36.dp))
.height(62.dp)
.padding(6.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
items.forEach { destination ->
val selected = currentRoute == destination.route
PillTab(
label = destination.labelRes,
icon = destination.icon,
selected = selected,
modifier = Modifier.weight(if (selected) 1.9f else 1f),
onClick = { onClick = {
if (currentRoute != destination.route) { if (currentRoute != destination.route) {
navController.navigate(destination.route) { navController.navigate(destination.route) {
@@ -30,3 +71,60 @@ fun BottomNavBar(navController: NavController) {
} }
} }
} }
@Composable
private fun PillTab(
label: String,
icon: androidx.compose.ui.graphics.vector.ImageVector,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val colors = RadiolaTheme.colors
val bg by animateColorAsState(
targetValue = if (selected) colors.accent else androidx.compose.ui.graphics.Color.Transparent,
animationSpec = tween(Motion.Medium),
label = "tabBg"
)
val content by animateColorAsState(
targetValue = if (selected) colors.bgBase else colors.textSecondary,
animationSpec = tween(Motion.Medium),
label = "tabFg"
)
Row(
modifier = modifier
.fillMaxWidth()
.height(50.dp)
.clip(RoundedCornerShape(26.dp))
.background(bg)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onClick
),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = label,
tint = content,
modifier = Modifier.height(18.dp).width(18.dp)
)
AnimatedVisibility(
visible = selected,
enter = fadeIn(tween(Motion.Medium)) + expandHorizontally(tween(Motion.Medium)),
exit = fadeOut(tween(Motion.Fast)) + shrinkHorizontally(tween(Motion.Fast))
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Spacer(Modifier.width(8.dp))
Text(
text = label.uppercase(),
color = content,
style = androidx.compose.material3.MaterialTheme.typography.labelSmall,
maxLines = 1
)
}
}
}
}

View File

@@ -0,0 +1,95 @@
package com.radiola.ui.theme
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.material3.Text
/** Брендовый градиент иконки. */
fun brandGradient(): Brush = Brush.linearGradient(listOf(BrandGradientStart, BrandGradientEnd))
/**
* Иконка-марка приложения: градиентный squircle с монограммой «R».
*/
@Composable
fun AppMark(
size: Dp = 76.dp,
modifier: Modifier = Modifier
) {
val radius = (size.value * 0.29f).dp
Box(
modifier = modifier
.size(size)
.clip(RoundedCornerShape(radius))
.background(brandGradient()),
contentAlignment = Alignment.Center
) {
Text(
text = "R",
color = BgBase,
fontWeight = FontWeight.Black,
fontSize = (size.value * 0.62f).sp
)
}
}
/**
* Текстовый логотип «radiOLA»: «radi» основным цветом, «OLA» акцентом.
*/
@Composable
fun RadiolaWordmark(
fontSize: Int = 26,
modifier: Modifier = Modifier
) {
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
Text(
text = "radi",
color = TextPrimary,
fontWeight = FontWeight.Bold,
fontSize = fontSize.sp
)
Text(
text = "OLA",
color = Accent,
fontWeight = FontWeight.Bold,
fontSize = fontSize.sp
)
}
}
/** Монохромная марка для нейтральных подложек (уведомления, виджет). */
@Composable
fun MonoMark(
size: Dp = 24.dp,
color: Color = TextPrimary,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.size(size)
.clip(RoundedCornerShape((size.value * 0.27f).dp))
.border(1.5.dp, color, RoundedCornerShape((size.value * 0.27f).dp)),
contentAlignment = Alignment.Center
) {
Text(
text = "R",
color = color,
fontWeight = FontWeight.Black,
fontSize = (size.value * 0.6f).sp
)
}
}

View File

@@ -2,12 +2,23 @@ package com.radiola.ui.theme
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
val Primary = Color(0xFF6200EE) // Базовая палитра radiOLA (тёмно-зелёная тема)
val PrimaryDark = Color(0xFF3700B3) val BgBase = Color(0xFF0C1410)
val Secondary = Color(0xFF03DAC6) val BgSurface = Color(0xFF16201A)
val Background = Color(0xFF121212) val BgSurface2 = Color(0xFF1E2A23)
val Surface = Color(0xFF1E1E1E) val BgElevated = Color(0xFF243029)
val OnPrimary = Color.White
val OnSecondary = Color.Black val Accent = Color(0xFFA8E05F)
val OnBackground = Color.White val AccentDim = Color(0xFF6FA53C)
val OnSurface = Color.White
val TextPrimary = Color(0xFFFFFFFF)
val TextSecondary = Color(0xFF8FA396)
val TextMuted = Color(0xFF5C6E63)
val BorderColor = Color(0xFF2A352E)
val LiveRed = Color(0xFFFF5252)
val LiveRedSoft = Color(0xFFFF6B6B)
// Брендовый градиент (иконка приложения, акцентные элементы)
val BrandGradientStart = Color(0xFFC2F25B)
val BrandGradientEnd = Color(0xFF6FA53C)

View File

@@ -0,0 +1,102 @@
package com.radiola.ui.theme
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.dp
import kotlin.math.abs
/** Стандартные длительности и кривые движения приложения. */
object Motion {
const val Fast = 120
const val Medium = 220
const val Slow = 360
fun <T> snappy() = spring<T>(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMediumLow
)
fun <T> gentle() = tween<T>(durationMillis = Medium, easing = FastOutSlowInEasing)
}
/** Лёгкое нажатие: плавное уменьшение масштаба, пока палец на элементе. */
@Composable
fun Modifier.pressScale(
pressedScale: Float = 0.94f,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }
): Modifier {
val pressed by interactionSource.collectIsPressedAsState()
val scale by androidx.compose.animation.core.animateFloatAsState(
targetValue = if (pressed) pressedScale else 1f,
animationSpec = tween(Motion.Fast, easing = FastOutSlowInEasing),
label = "pressScale"
)
return this.graphicsLayer {
scaleX = scale
scaleY = scale
}
}
/**
* Живой эквалайзер для прямого эфира — декоративный, без перемотки.
* Полоски плавно «дышат» при воспроизведении.
*/
@Composable
fun LiveEqualizer(
modifier: Modifier = Modifier,
barCount: Int = 36,
color: Color = Accent,
playing: Boolean = true
) {
val transition = rememberInfiniteTransition(label = "eq")
val phase by transition.animateFloat(
initialValue = 0f,
targetValue = (Math.PI * 2).toFloat(),
animationSpec = infiniteRepeatable(
animation = tween(1400, easing = LinearEasing),
repeatMode = RepeatMode.Restart
),
label = "eqPhase"
)
Canvas(modifier = modifier) {
val gap = 3.dp.toPx()
val barWidth = (size.width - gap * (barCount - 1)) / barCount
val maxH = size.height
for (i in 0 until barCount) {
val seed = (i * 0.7f)
val wave = if (playing) {
0.45f + 0.55f * abs(kotlin.math.sin(phase + seed))
} else 0.25f
val h = maxH * wave
val x = i * (barWidth + gap)
val y = (maxH - h) / 2f
drawRoundRect(
color = color,
topLeft = Offset(x, y),
size = Size(barWidth, h),
cornerRadius = CornerRadius(barWidth / 2f, barWidth / 2f)
)
}
}
}

View File

@@ -0,0 +1,13 @@
package com.radiola.ui.theme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Shapes
import androidx.compose.ui.unit.dp
val RadiolaShapes = Shapes(
extraSmall = RoundedCornerShape(8.dp),
small = RoundedCornerShape(12.dp),
medium = RoundedCornerShape(16.dp),
large = RoundedCornerShape(20.dp),
extraLarge = RoundedCornerShape(28.dp)
)

View File

@@ -1,38 +1,69 @@
package com.radiola.ui.theme package com.radiola.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
/**
* Расширенные токены, которых нет в Material ColorScheme.
* Доступ через 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 brandGradient: Brush
get() = Brush.linearGradient(listOf(BrandGradientStart, BrandGradientEnd))
}
val LocalRadiolaColors = staticCompositionLocalOf { RadiolaColors() }
object RadiolaTheme {
val colors: RadiolaColors
@Composable get() = LocalRadiolaColors.current
}
private val DarkColorScheme = darkColorScheme( private val DarkColorScheme = darkColorScheme(
primary = Primary, primary = Accent,
secondary = Secondary, onPrimary = BgBase,
background = Background, secondary = AccentDim,
surface = Surface, onSecondary = BgBase,
onPrimary = OnPrimary, background = BgBase,
onSecondary = OnSecondary, onBackground = TextPrimary,
onBackground = OnBackground, surface = BgSurface,
onSurface = OnSurface onSurface = TextPrimary,
) surfaceVariant = BgSurface2,
onSurfaceVariant = TextSecondary,
private val LightColorScheme = lightColorScheme( outline = BorderColor,
primary = Primary, outlineVariant = BorderColor,
secondary = Secondary, error = LiveRed,
onPrimary = OnPrimary, onError = BgBase,
onSecondary = OnSecondary
) )
@Composable @Composable
fun RadiolaTheme( fun RadiolaTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme // Приложение всегда в тёмной фирменной теме.
CompositionLocalProvider(LocalRadiolaColors provides RadiolaColors()) {
MaterialTheme( MaterialTheme(
colorScheme = colorScheme, colorScheme = DarkColorScheme,
typography = Typography, typography = Typography,
shapes = RadiolaShapes,
content = content content = content
) )
}
} }

View File

@@ -6,30 +6,55 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
// Системный гротеск; брендовый wordmark рисуется отдельно (Brand.kt).
private val AppFont = FontFamily.Default
val Typography = Typography( val Typography = Typography(
headlineLarge = TextStyle( headlineLarge = TextStyle(
fontFamily = FontFamily.Default, fontFamily = AppFont,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
fontSize = 28.sp fontSize = 30.sp,
letterSpacing = (-0.5).sp
), ),
headlineMedium = TextStyle( headlineMedium = TextStyle(
fontFamily = FontFamily.Default, fontFamily = AppFont,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.Bold,
fontSize = 22.sp fontSize = 26.sp
),
titleLarge = TextStyle(
fontFamily = AppFont,
fontWeight = FontWeight.Bold,
fontSize = 18.sp
), ),
titleMedium = TextStyle( titleMedium = TextStyle(
fontFamily = FontFamily.Default, fontFamily = AppFont,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.SemiBold,
fontSize = 16.sp fontSize = 16.sp
), ),
bodyLarge = TextStyle(
fontFamily = AppFont,
fontWeight = FontWeight.Normal,
fontSize = 15.sp
),
bodyMedium = TextStyle( bodyMedium = TextStyle(
fontFamily = FontFamily.Default, fontFamily = AppFont,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
fontSize = 14.sp fontSize = 14.sp
), ),
labelLarge = TextStyle(
fontFamily = AppFont,
fontWeight = FontWeight.SemiBold,
fontSize = 13.sp
),
labelMedium = TextStyle( labelMedium = TextStyle(
fontFamily = FontFamily.Default, fontFamily = AppFont,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
fontSize = 12.sp fontSize = 12.sp
),
labelSmall = TextStyle(
fontFamily = AppFont,
fontWeight = FontWeight.SemiBold,
fontSize = 10.sp,
letterSpacing = 1.sp
) )
) )