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 2f686bcc57
commit ae406554de
7 changed files with 425 additions and 50 deletions

View File

@@ -1,22 +1,63 @@
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.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Text
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.compose.currentBackStackEntryAsState
import com.radiola.ui.theme.Motion
import com.radiola.ui.theme.RadiolaTheme
@Composable
fun BottomNavBar(navController: NavController) {
val colors = RadiolaTheme.colors
val currentRoute = navController.currentBackStackEntryAsState().value?.destination?.route
NavigationBar {
NavDestinations.items.filter { it.showInBottomBar }.forEach { destination ->
NavigationBarItem(
icon = { Icon(destination.icon, contentDescription = destination.labelRes) },
label = { Text(destination.labelRes) },
selected = currentRoute == destination.route,
val items = NavDestinations.items.filter { it.showInBottomBar }
Row(
modifier = Modifier
.fillMaxWidth()
.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 = {
if (currentRoute != 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
val Primary = Color(0xFF6200EE)
val PrimaryDark = Color(0xFF3700B3)
val Secondary = Color(0xFF03DAC6)
val Background = Color(0xFF121212)
val Surface = Color(0xFF1E1E1E)
val OnPrimary = Color.White
val OnSecondary = Color.Black
val OnBackground = Color.White
val OnSurface = Color.White
// Базовая палитра radiOLA (тёмно-зелёная тема)
val BgBase = Color(0xFF0C1410)
val BgSurface = Color(0xFF16201A)
val BgSurface2 = Color(0xFF1E2A23)
val BgElevated = Color(0xFF243029)
val Accent = Color(0xFFA8E05F)
val AccentDim = Color(0xFF6FA53C)
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
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
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(
primary = Primary,
secondary = Secondary,
background = Background,
surface = Surface,
onPrimary = OnPrimary,
onSecondary = OnSecondary,
onBackground = OnBackground,
onSurface = OnSurface
)
private val LightColorScheme = lightColorScheme(
primary = Primary,
secondary = Secondary,
onPrimary = OnPrimary,
onSecondary = OnSecondary
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,
)
@Composable
fun RadiolaTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
// Приложение всегда в тёмной фирменной теме.
CompositionLocalProvider(LocalRadiolaColors provides RadiolaColors()) {
MaterialTheme(
colorScheme = DarkColorScheme,
typography = Typography,
shapes = RadiolaShapes,
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.unit.sp
// Системный гротеск; брендовый wordmark рисуется отдельно (Brand.kt).
private val AppFont = FontFamily.Default
val Typography = Typography(
headlineLarge = TextStyle(
fontFamily = FontFamily.Default,
fontFamily = AppFont,
fontWeight = FontWeight.Bold,
fontSize = 28.sp
fontSize = 30.sp,
letterSpacing = (-0.5).sp
),
headlineMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 22.sp
fontFamily = AppFont,
fontWeight = FontWeight.Bold,
fontSize = 26.sp
),
titleLarge = TextStyle(
fontFamily = AppFont,
fontWeight = FontWeight.Bold,
fontSize = 18.sp
),
titleMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontFamily = AppFont,
fontWeight = FontWeight.SemiBold,
fontSize = 16.sp
),
bodyLarge = TextStyle(
fontFamily = AppFont,
fontWeight = FontWeight.Normal,
fontSize = 15.sp
),
bodyMedium = TextStyle(
fontFamily = FontFamily.Default,
fontFamily = AppFont,
fontWeight = FontWeight.Normal,
fontSize = 14.sp
),
labelLarge = TextStyle(
fontFamily = AppFont,
fontWeight = FontWeight.SemiBold,
fontSize = 13.sp
),
labelMedium = TextStyle(
fontFamily = FontFamily.Default,
fontFamily = AppFont,
fontWeight = FontWeight.Medium,
fontSize = 12.sp
),
labelSmall = TextStyle(
fontFamily = AppFont,
fontWeight = FontWeight.SemiBold,
fontSize = 10.sp,
letterSpacing = 1.sp
)
)