diff --git a/app/src/main/java/com/radiola/ui/navigation/BottomNavBar.kt b/app/src/main/java/com/radiola/ui/navigation/BottomNavBar.kt index 86c8cbe..d43fa0c 100644 --- a/app/src/main/java/com/radiola/ui/navigation/BottomNavBar.kt +++ b/app/src/main/java/com/radiola/ui/navigation/BottomNavBar.kt @@ -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 + ) + } + } + } +} diff --git a/app/src/main/java/com/radiola/ui/theme/Brand.kt b/app/src/main/java/com/radiola/ui/theme/Brand.kt new file mode 100644 index 0000000..4f94d17 --- /dev/null +++ b/app/src/main/java/com/radiola/ui/theme/Brand.kt @@ -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 + ) + } +} diff --git a/app/src/main/java/com/radiola/ui/theme/Color.kt b/app/src/main/java/com/radiola/ui/theme/Color.kt index c552501..997ba40 100644 --- a/app/src/main/java/com/radiola/ui/theme/Color.kt +++ b/app/src/main/java/com/radiola/ui/theme/Color.kt @@ -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) diff --git a/app/src/main/java/com/radiola/ui/theme/Motion.kt b/app/src/main/java/com/radiola/ui/theme/Motion.kt new file mode 100644 index 0000000..5f805c2 --- /dev/null +++ b/app/src/main/java/com/radiola/ui/theme/Motion.kt @@ -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 snappy() = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMediumLow + ) + + fun gentle() = tween(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) + ) + } + } +} diff --git a/app/src/main/java/com/radiola/ui/theme/Shape.kt b/app/src/main/java/com/radiola/ui/theme/Shape.kt new file mode 100644 index 0000000..0f2ad6b --- /dev/null +++ b/app/src/main/java/com/radiola/ui/theme/Shape.kt @@ -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) +) 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 70bc9d1..3a935e8 100644 --- a/app/src/main/java/com/radiola/ui/theme/Theme.kt +++ b/app/src/main/java/com/radiola/ui/theme/Theme.kt @@ -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 + ) + } } diff --git a/app/src/main/java/com/radiola/ui/theme/Type.kt b/app/src/main/java/com/radiola/ui/theme/Type.kt index 9c34606..669a452 100644 --- a/app/src/main/java/com/radiola/ui/theme/Type.kt +++ b/app/src/main/java/com/radiola/ui/theme/Type.kt @@ -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 ) )