8 Commits

Author SHA1 Message Date
nk
a3f3494da2 polish(ui): плавные обложки, бегущая строка длинных названий, тактильный отклик
- Coil crossfade для всех обложек (Images.crossfadeModel) — без «моргания» при загрузке
- basicMarquee для длинных названий трека (плеер и мини-плеер) вместо обрезки
- haptic feedback на play/pause и добавление в избранное (плеер, мини-плеер, карточка)
2026-06-02 22:33:33 +03:00
nk
8a951dd4c5 fix(player): отображение трека и обложки — объединение REST и socket now-playing
REST-поллинг (refreshNowPlaying -> api.getNowPlaying, 200 OK) писал данные в
_nowPlaying, который нигде не читался; getNowPlaying() брал только сокет (пустой).
Теперь getNowPlaying/getAllNowPlaying объединяют оба источника (socket ?: REST),
поэтому название трека, обложка и deep-link сервисов работают.
2026-06-02 22:23:37 +03:00
nk
58f735823e fix(ui): отступы под системную навигацию + подписи сервисов + краш навбара
- навбар и мини-плеер: navigationBarsPadding — не налезают на системные кнопки
- плеер: navigationBarsPadding снизу, ряд сервисов не уходит под системную панель
- подписи сервисов без обрезки слов (Яндекс / ВК Музыка / YT Music и т.д.)
- фикс NPE при холодном старте: навбар обращается к NavDestinations напрямую,
  не к companion-списку (порядок инициализации Kotlin)
2026-06-02 22:13:10 +03:00
nk
9e9f4c8009 fix(ui): единый скролл на экране станций + всегда видимый навбар
- StationsScreen: закреплённые заголовок/поиск/жанры, одна прокручиваемая
  сетка станций; поиск и фильтры больше не исчезают при пустом результате
  (+ кнопка «Сбросить фильтры»)
- таб-бар показывается без обязательного входа (скрыт только на экране входа)
- старт сразу со «Станций» — авторизация необязательна, вход из Настроек
2026-06-02 21:58:11 +03:00
nk
44ea21042f feat(ui): мягкие переходы между экранами (fade + лёгкий slide) 2026-06-02 21:32:28 +03:00
nk
bdace2d5b9 feat(ui): рестайл всех экранов + плеер + официальные mono-логотипы сервисов
- экраны (Станции/Избранное/История/Записи/Настройки/Вход): двухцветные
  заголовки, токены темы, EmptyState, анимации появления и перестановки
- AuthScreen: брендовый локап (AppMark + RadiolaWordmark)
- PlayerBottomSheet: живой эфир — LiveEqualizer вместо перемотки,
  Crossfade трека и play/pause, pressScale, анимация избранного/записи
- кнопки музыкальных сервисов: монохромные официальные логотипы
  (vector drawable из Simple Icons CC0 + Yandex), маппинг serviceLogoRes
- DeeplinkBottomSheet: сетка сервисов с логотипами
2026-06-02 21:31:16 +03:00
nk
f81dc52e92 feat(ui): рестайл общих компонентов под дизайн-систему
- StationCard: обложка/иконка-заглушка, анимированное сердечко, pressScale
- MiniPlayer: elevated-бар, метка «СЕЙЧАС ИГРАЕТ», Crossfade play/pause
- SearchBar: surface-поле, акцентный курсор, скругление 14
- FilterChips: акцентный активный чип с анимацией цвета
- EmptyState: иконка-плашка + текст
- TrackListItem: thumb-заглушка, pressScale
2026-06-02 21:17:28 +03:00
nk
a614ac3764 feat(ui): дизайн-система radiOLA — палитра, тема, типографика, бренд, motion, pill таб-бар
- цветовые токены тёмно-зелёной темы + RadiolaColors (CompositionLocal)
- darkColorScheme + всегда тёмная тема, фирменные shapes
- типографика с весами/размерами под макет
- Brand: AppMark (градиентный R), RadiolaWordmark, MonoMark
- Motion: спеки движения, pressScale, живой эквалайзер
- pill-таб-бар с анимированной активной вкладкой
2026-06-02 21:13:27 +03:00
32 changed files with 2044 additions and 721 deletions

View File

@@ -9,9 +9,11 @@ import androidx.compose.foundation.layout.*
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.radiola.data.local.TokenDataStore import com.radiola.data.local.TokenDataStore
@@ -65,13 +67,18 @@ class MainActivity : ComponentActivity() {
val isRecording by playerViewModel.isRecording.collectAsState() val isRecording by playerViewModel.isRecording.collectAsState()
val isLoggedIn by tokenDataStore.isLoggedIn.collectAsState(initial = false) val isLoggedIn by tokenDataStore.isLoggedIn.collectAsState(initial = false)
val startDestination = remember(isLoggedIn) { // Авторизация необязательна — всегда стартуем со станций.
if (isLoggedIn) NavDestinations.Stations.route else NavDestinations.Auth.route // Вход доступен из Настроек.
} val startDestination = NavDestinations.Stations.route
val currentRoute = navController
.currentBackStackEntryAsState().value?.destination?.route
val showChrome = currentRoute != NavDestinations.Auth.route
Scaffold( Scaffold(
bottomBar = { bottomBar = {
Column { if (showChrome) {
Column(Modifier.navigationBarsPadding()) {
if (currentStation != null) { if (currentStation != null) {
MiniPlayer( MiniPlayer(
stationName = currentStation!!.name, stationName = currentStation!!.name,
@@ -80,8 +87,10 @@ class MainActivity : ComponentActivity() {
onClick = { showPlayer = true }, onClick = { showPlayer = true },
onPlayPause = { playerViewModel.togglePlayPause() } onPlayPause = { playerViewModel.togglePlayPause() }
) )
Spacer(Modifier.height(8.dp))
} }
if (isLoggedIn) { // Навигация доступна и без входа — приложением можно
// пользоваться анонимно.
BottomNavBar(navController) BottomNavBar(navController)
} }
} }
@@ -90,7 +99,16 @@ class MainActivity : ComponentActivity() {
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = startDestination, startDestination = startDestination,
modifier = Modifier.padding(paddingValues) modifier = Modifier.padding(paddingValues),
enterTransition = {
androidx.compose.animation.fadeIn(androidx.compose.animation.core.tween(220)) +
androidx.compose.animation.slideInVertically(
androidx.compose.animation.core.tween(220)
) { it / 24 }
},
exitTransition = { androidx.compose.animation.fadeOut(androidx.compose.animation.core.tween(160)) },
popEnterTransition = { androidx.compose.animation.fadeIn(androidx.compose.animation.core.tween(220)) },
popExitTransition = { androidx.compose.animation.fadeOut(androidx.compose.animation.core.tween(160)) }
) { ) {
composable(NavDestinations.Stations.route) { composable(NavDestinations.Stations.route) {
StationsScreen( StationsScreen(

View File

@@ -7,7 +7,7 @@ import com.radiola.domain.model.Track
import com.radiola.domain.repository.NowPlayingRepository import com.radiola.domain.repository.NowPlayingRepository
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.combine
import javax.inject.Inject import javax.inject.Inject
class NowPlayingRepositoryImpl @Inject constructor( class NowPlayingRepositoryImpl @Inject constructor(
@@ -21,11 +21,19 @@ class NowPlayingRepositoryImpl @Inject constructor(
socketClient.connect() socketClient.connect()
} }
// Объединяем два источника: сокет (реалтайм, приоритет) и REST-поллинг
// (refreshNowPlaying). Раньше REST-данные писались в _nowPlaying, но никем
// не читались — из-за этого трек и обложка не отображались.
override fun getNowPlaying(stationId: Int): Flow<Track?> { override fun getNowPlaying(stationId: Int): Flow<Track?> {
return socketClient.nowPlaying.map { it[stationId] } return combine(socketClient.nowPlaying, _nowPlaying) { socketMap, restMap ->
socketMap[stationId] ?: restMap[stationId]
}
} }
override fun getAllNowPlaying(): Flow<Map<Int, Track>> = socketClient.nowPlaying override fun getAllNowPlaying(): Flow<Map<Int, Track>> =
combine(socketClient.nowPlaying, _nowPlaying) { socketMap, restMap ->
restMap + socketMap
}
override suspend fun refreshNowPlaying(): Result<Unit> { override suspend fun refreshNowPlaying(): Result<Unit> {
return try { return try {

View File

@@ -1,16 +1,25 @@
package com.radiola.ui.auth package com.radiola.ui.auth
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.radiola.ui.theme.AppMark
import com.radiola.ui.theme.RadiolaTheme
import com.radiola.ui.theme.RadiolaWordmark
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -23,6 +32,10 @@ fun AuthScreen(
val email by viewModel.email.collectAsState() val email by viewModel.email.collectAsState()
var code by remember { mutableStateOf("") } var code by remember { mutableStateOf("") }
var showCodeInput by remember { mutableStateOf(false) } var showCodeInput by remember { mutableStateOf(false) }
// Отслеживаем фокус на полях ввода для акцентной рамки
var emailFocused by remember { mutableStateOf(false) }
var codeFocused by remember { mutableStateOf(false) }
val colors = RadiolaTheme.colors
LaunchedEffect(state) { LaunchedEffect(state) {
when (state) { when (state) {
@@ -32,36 +45,48 @@ fun AuthScreen(
} }
} }
Scaffold( Box(
topBar = {
TopAppBar(
title = { Text("Вход в radiOLA") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background
)
)
}
) { padding ->
Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(padding) .background(colors.bgBase),
.padding(24.dp), contentAlignment = Alignment.Center
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Логотип
AppMark(size = 84.dp)
Spacer(Modifier.height(4.dp))
RadiolaWordmark(fontSize = 26)
Spacer(Modifier.height(8.dp))
// Заголовок
Text( Text(
text = if (showCodeInput) "Введите код из письма" else "Добро пожаловать", text = if (showCodeInput) "Введите код из письма" else "Добро пожаловать",
style = MaterialTheme.typography.headlineSmall style = MaterialTheme.typography.headlineLarge,
color = colors.textPrimary,
textAlign = TextAlign.Center
) )
// Подзаголовок
Text( Text(
text = if (showCodeInput) "Мы отправили 6-значный код на ваш email" else "Войдите, чтобы синхронизировать избранное и историю между устройствами", text = if (showCodeInput)
"Мы отправили 6-значный код на $email"
else
"Войдите, чтобы синхронизировать избранное и историю",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = colors.textSecondary,
textAlign = TextAlign.Center
) )
Spacer(Modifier.height(4.dp))
if (!showCodeInput) { if (!showCodeInput) {
// Поле ввода email с акцентной рамкой при фокусе
OutlinedTextField( OutlinedTextField(
value = email, value = email,
onValueChange = viewModel::onEmailChange, onValueChange = viewModel::onEmailChange,
@@ -72,18 +97,43 @@ fun AuthScreen(
imeAction = ImeAction.Done imeAction = ImeAction.Done
), ),
keyboardActions = KeyboardActions(onDone = { viewModel.requestCode() }), keyboardActions = KeyboardActions(onDone = { viewModel.requestCode() }),
modifier = Modifier.fillMaxWidth() modifier = Modifier
.fillMaxWidth()
.onFocusChanged { emailFocused = it.isFocused },
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = colors.accent,
unfocusedBorderColor = colors.border,
focusedLabelColor = colors.accent,
unfocusedLabelColor = colors.textSecondary,
focusedTextColor = colors.textPrimary,
unfocusedTextColor = colors.textPrimary,
cursorColor = colors.accent,
focusedContainerColor = colors.surface,
unfocusedContainerColor = colors.surface
),
shape = RoundedCornerShape(14.dp)
) )
Button( Button(
onClick = { viewModel.requestCode() }, onClick = { viewModel.requestCode() },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
enabled = state !is AuthViewModel.AuthState.Loading enabled = state !is AuthViewModel.AuthState.Loading,
colors = ButtonDefaults.buttonColors(
containerColor = colors.accent,
contentColor = colors.bgBase,
disabledContainerColor = colors.accentDim.copy(alpha = 0.5f),
disabledContentColor = colors.bgBase.copy(alpha = 0.5f)
),
shape = RoundedCornerShape(14.dp)
) { ) {
if (state is AuthViewModel.AuthState.Loading) { if (state is AuthViewModel.AuthState.Loading) {
CircularProgressIndicator(modifier = Modifier.size(20.dp)) CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = colors.bgBase,
strokeWidth = 2.dp
)
} else { } else {
Text("Получить код") Text("Получить код", style = MaterialTheme.typography.labelLarge)
} }
} }
@@ -92,16 +142,15 @@ fun AuthScreen(
onClick = onSkip, onClick = onSkip,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Text("Продолжить без входа") Text(
"Продолжить без входа",
color = colors.textSecondary,
style = MaterialTheme.typography.labelLarge
)
} }
} }
} else { } else {
Text( // Поле ввода кода с акцентной рамкой при фокусе
text = "Код отправлен на $email",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
OutlinedTextField( OutlinedTextField(
value = code, value = code,
onValueChange = { if (it.length <= 6) code = it.uppercase() }, onValueChange = { if (it.length <= 6) code = it.uppercase() },
@@ -111,21 +160,44 @@ fun AuthScreen(
keyboardType = KeyboardType.NumberPassword, keyboardType = KeyboardType.NumberPassword,
imeAction = ImeAction.Done imeAction = ImeAction.Done
), ),
keyboardActions = KeyboardActions(onDone = { keyboardActions = KeyboardActions(onDone = { viewModel.verifyCode(code) }),
viewModel.verifyCode(code) modifier = Modifier
}), .fillMaxWidth()
modifier = Modifier.fillMaxWidth() .onFocusChanged { codeFocused = it.isFocused },
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = colors.accent,
unfocusedBorderColor = colors.border,
focusedLabelColor = colors.accent,
unfocusedLabelColor = colors.textSecondary,
focusedTextColor = colors.textPrimary,
unfocusedTextColor = colors.textPrimary,
cursorColor = colors.accent,
focusedContainerColor = colors.surface,
unfocusedContainerColor = colors.surface
),
shape = RoundedCornerShape(14.dp)
) )
Button( Button(
onClick = { viewModel.verifyCode(code) }, onClick = { viewModel.verifyCode(code) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
enabled = state !is AuthViewModel.AuthState.Loading && code.length == 6 enabled = state !is AuthViewModel.AuthState.Loading && code.length == 6,
colors = ButtonDefaults.buttonColors(
containerColor = colors.accent,
contentColor = colors.bgBase,
disabledContainerColor = colors.accentDim.copy(alpha = 0.5f),
disabledContentColor = colors.bgBase.copy(alpha = 0.5f)
),
shape = RoundedCornerShape(14.dp)
) { ) {
if (state is AuthViewModel.AuthState.Loading) { if (state is AuthViewModel.AuthState.Loading) {
CircularProgressIndicator(modifier = Modifier.size(20.dp)) CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = colors.bgBase,
strokeWidth = 2.dp
)
} else { } else {
Text("Войти") Text("Войти", style = MaterialTheme.typography.labelLarge)
} }
} }
@@ -136,20 +208,29 @@ fun AuthScreen(
viewModel.dismissError() viewModel.dismissError()
} }
) { ) {
Text("Отправить код повторно") Text(
"Отправить код повторно",
color = colors.textSecondary,
style = MaterialTheme.typography.labelLarge
)
} }
} }
} }
} }
// Диалог ошибки
if (state is AuthViewModel.AuthState.Error) { if (state is AuthViewModel.AuthState.Error) {
val errorMessage = (state as AuthViewModel.AuthState.Error).message val errorMessage = (state as AuthViewModel.AuthState.Error).message
AlertDialog( AlertDialog(
onDismissRequest = viewModel::dismissError, onDismissRequest = viewModel::dismissError,
title = { Text("Ошибка") }, containerColor = colors.elevated,
text = { Text(errorMessage) }, title = { Text("Ошибка", color = colors.textPrimary) },
text = { Text(errorMessage, color = colors.textSecondary) },
confirmButton = { confirmButton = {
TextButton(onClick = viewModel::dismissError) { TextButton(
onClick = viewModel::dismissError,
colors = ButtonDefaults.textButtonColors(contentColor = colors.accent)
) {
Text("OK") Text("OK")
} }
} }

View File

@@ -1,19 +1,29 @@
package com.radiola.ui.components package com.radiola.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Music
import com.radiola.deeplink.DeeplinkNavigator import com.radiola.deeplink.DeeplinkNavigator
import com.radiola.domain.model.DeeplinkService import com.radiola.domain.model.DeeplinkService
import com.radiola.domain.model.Track import com.radiola.domain.model.Track
import com.radiola.ui.player.PlayerViewModel import com.radiola.ui.player.PlayerViewModel
import com.radiola.ui.theme.RadiolaTheme
import com.radiola.ui.theme.pressScale
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -25,21 +35,44 @@ fun DeeplinkBottomSheet(
) { ) {
val context = androidx.compose.ui.platform.LocalContext.current val context = androidx.compose.ui.platform.LocalContext.current
val enabledServices by viewModel.enabledServices.collectAsState() val enabledServices by viewModel.enabledServices.collectAsState()
val colors = RadiolaTheme.colors
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
modifier = modifier modifier = modifier,
containerColor = colors.elevated
) { ) {
Column(modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp)) {
Text( Text(
text = "Найти трек", text = "Найти трек",
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp) color = colors.textPrimary
) )
LazyColumn { Spacer(Modifier.height(4.dp))
Text(
text = "${track.artist}${track.song}",
style = MaterialTheme.typography.bodyMedium,
color = colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(Modifier.height(20.dp))
}
// Сетка кнопок сервисов — монохромные, без официальных логотипов
LazyVerticalGrid(
columns = GridCells.Fixed(4),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
contentPadding = PaddingValues(bottom = 32.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
items(enabledServices) { service -> items(enabledServices) { service ->
ListItem( ServiceGridBtn(
headlineContent = { Text(service.displayName) }, service = service,
modifier = Modifier.clickable { onClick = {
DeeplinkNavigator.openSearch(context, track, service) DeeplinkNavigator.openSearch(context, track, service)
onDismiss() onDismiss()
} }
@@ -48,3 +81,52 @@ fun DeeplinkBottomSheet(
} }
} }
} }
/** Монохромная кнопка сервиса в сетке боттомшита. */
@Composable
private fun ServiceGridBtn(
service: DeeplinkService,
onClick: () -> Unit
) {
val colors = RadiolaTheme.colors
val interaction = remember { MutableInteractionSource() }
Column(
modifier = Modifier
.pressScale(interactionSource = interaction)
.clickable(interactionSource = interaction, indication = null, onClick = onClick),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Box(
modifier = Modifier
.size(52.dp)
.clip(CircleShape)
.background(colors.surface2),
contentAlignment = Alignment.Center
) {
val logoRes = serviceLogoRes(service)
if (logoRes != null) {
Icon(
painter = androidx.compose.ui.res.painterResource(logoRes),
contentDescription = service.displayName,
tint = colors.textSecondary,
modifier = Modifier.size(24.dp)
)
} else {
Icon(
imageVector = Lucide.Music,
contentDescription = service.displayName,
tint = colors.textSecondary,
modifier = Modifier.size(22.dp)
)
}
}
Text(
text = service.displayName,
style = MaterialTheme.typography.labelSmall,
color = colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}

View File

@@ -1,27 +1,49 @@
package com.radiola.ui.components package com.radiola.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.material3.MaterialTheme import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Radio
import com.radiola.ui.theme.RadiolaTheme
@Composable @Composable
fun EmptyState( fun EmptyState(
message: String, message: String,
modifier: Modifier = Modifier modifier: Modifier = Modifier,
icon: ImageVector = Lucide.Radio
) { ) {
val colors = RadiolaTheme.colors
Box( Box(
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize().padding(32.dp),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = Modifier
.size(72.dp)
.clip(RoundedCornerShape(24.dp))
.background(colors.surface2),
contentAlignment = Alignment.Center
) {
Icon(icon, contentDescription = null, tint = colors.textMuted, modifier = Modifier.size(32.dp))
}
Spacer(Modifier.height(16.dp))
Text( Text(
text = message, text = message,
style = MaterialTheme.typography.bodyLarge, style = androidx.compose.material3.MaterialTheme.typography.bodyLarge,
color = Color(0xFF888888) color = colors.textSecondary,
textAlign = TextAlign.Center
) )
} }
}
} }

View File

@@ -1,13 +1,28 @@
package com.radiola.ui.components package com.radiola.ui.components
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.Arrangement
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material3.* import androidx.compose.foundation.shape.RoundedCornerShape
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.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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 com.radiola.ui.theme.Motion
import com.radiola.ui.theme.RadiolaTheme
@Composable @Composable
fun FilterChips( fun FilterChips(
@@ -18,22 +33,49 @@ fun FilterChips(
) { ) {
LazyRow( LazyRow(
modifier = modifier, modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(9.dp),
contentPadding = PaddingValues(horizontal = 16.dp) contentPadding = PaddingValues(horizontal = 16.dp)
) { ) {
item { item {
FilterChip( Chip(label = "Все", selected = selectedTag == null) { onTagSelected(null) }
selected = selectedTag == null,
onClick = { onTagSelected(null) },
label = { Text("Все") }
)
} }
items(tags) { tag -> items(tags) { tag ->
FilterChip( Chip(label = tag, selected = selectedTag == tag) { onTagSelected(tag) }
selected = selectedTag == tag,
onClick = { onTagSelected(tag) },
label = { Text(tag) }
)
} }
} }
} }
@Composable
private fun Chip(label: String, selected: Boolean, onClick: () -> Unit) {
val colors = RadiolaTheme.colors
val bg by animateColorAsState(
targetValue = if (selected) colors.accent else colors.surface2,
animationSpec = tween(Motion.Medium),
label = "chipBg"
)
val fg by animateColorAsState(
targetValue = if (selected) colors.bgBase else colors.textSecondary,
animationSpec = tween(Motion.Medium),
label = "chipFg"
)
Text(
text = label,
color = fg,
fontWeight = FontWeight.SemiBold,
style = androidx.compose.material3.MaterialTheme.typography.labelLarge,
modifier = Modifier
.clip(RoundedCornerShape(18.dp))
.background(bg)
.border(
width = if (selected) 0.dp else 1.dp,
color = if (selected) Color.Transparent else colors.border,
shape = RoundedCornerShape(18.dp)
)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onClick
)
.padding(horizontal = 16.dp, vertical = 9.dp)
)
}

View File

@@ -0,0 +1,16 @@
package com.radiola.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import coil.request.ImageRequest
/**
* Модель изображения с плавным проявлением (crossfade).
* Используется во всех обложках, чтобы загрузка не «моргала».
*/
@Composable
fun crossfadeModel(url: String?): ImageRequest =
ImageRequest.Builder(LocalContext.current)
.data(url)
.crossfade(220)
.build()

View File

@@ -1,25 +1,39 @@
package com.radiola.ui.components package com.radiola.ui.components
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.basicMarquee
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale
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 coil.compose.AsyncImage import coil.compose.AsyncImage
import com.composables.icons.lucide.Lucide import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Pause import com.composables.icons.lucide.Pause
import com.composables.icons.lucide.Play import com.composables.icons.lucide.Play
import com.composables.icons.lucide.Radio
import com.radiola.domain.model.Track import com.radiola.domain.model.Track
import com.radiola.ui.theme.Motion
import com.radiola.ui.theme.RadiolaTheme
import com.radiola.ui.theme.pressScale
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun MiniPlayer( fun MiniPlayer(
stationName: String, stationName: String,
@@ -29,42 +43,79 @@ fun MiniPlayer(
onPlayPause: () -> Unit, onPlayPause: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val colors = RadiolaTheme.colors
val haptics = LocalHapticFeedback.current
Row( Row(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.height(64.dp) .padding(horizontal = 16.dp)
.background(Color(0xFF1E1E1E)) .clip(RoundedCornerShape(18.dp))
.background(colors.elevated)
.border(1.dp, colors.border, RoundedCornerShape(18.dp))
.clickable(onClick = onClick) .clickable(onClick = onClick)
.padding(horizontal = 16.dp), .padding(8.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
AsyncImage( Box(
model = track?.coverUrl,
contentDescription = null,
modifier = Modifier modifier = Modifier
.size(48.dp) .size(48.dp)
.clip(RoundedCornerShape(6.dp)) .clip(RoundedCornerShape(12.dp))
.background(colors.surface2),
contentAlignment = Alignment.Center
) {
if (track?.coverUrl != null) {
AsyncImage(
model = crossfadeModel(track.coverUrl),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
) )
Spacer(modifier = Modifier.width(12.dp)) } else {
Icon(Lucide.Radio, null, tint = colors.textSecondary, modifier = Modifier.size(20.dp))
}
}
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = stationName, text = "СЕЙЧАС ИГРАЕТ",
style = MaterialTheme.typography.bodyMedium, color = colors.accent,
maxLines = 1 fontSize = 9.sp,
fontWeight = FontWeight.SemiBold,
letterSpacing = 1.sp
) )
Spacer(Modifier.height(2.dp))
Text( Text(
text = track?.let { "${it.artist}${it.song}" } ?: "", text = track?.let { "${it.artist}${it.song}" } ?: stationName,
style = MaterialTheme.typography.labelMedium, style = androidx.compose.material3.MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), color = colors.textPrimary,
maxLines = 1 maxLines = 1,
modifier = Modifier.basicMarquee()
) )
} }
IconButton(onClick = onPlayPause) { Spacer(Modifier.width(8.dp))
Box(
modifier = Modifier
.pressScale()
.size(44.dp)
.clip(RoundedCornerShape(22.dp))
.background(colors.accent)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
onPlayPause()
},
contentAlignment = Alignment.Center
) {
Crossfade(targetState = isPlaying, animationSpec = tween(Motion.Fast), label = "miniPlay") { playing ->
Icon( Icon(
imageVector = if (isPlaying) Lucide.Pause else Lucide.Play, imageVector = if (playing) Lucide.Pause else Lucide.Play,
contentDescription = if (isPlaying) "Pause" else "Play", contentDescription = if (playing) "Пауза" else "Играть",
tint = Color.White tint = colors.bgBase,
modifier = Modifier.size(20.dp)
) )
} }
} }
}
} }

View File

@@ -1,10 +1,8 @@
package com.radiola.ui.components package com.radiola.ui.components
import androidx.compose.foundation.background import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextField import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TextFieldDefaults
@@ -14,6 +12,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.composables.icons.lucide.Lucide import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Search import com.composables.icons.lucide.Search
import com.radiola.ui.theme.RadiolaTheme
@Composable @Composable
fun SearchBar( fun SearchBar(
@@ -22,22 +21,23 @@ fun SearchBar(
placeholder: String = "Поиск станции...", placeholder: String = "Поиск станции...",
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val colors = RadiolaTheme.colors
TextField( TextField(
value = query, value = query,
onValueChange = onQueryChange, onValueChange = onQueryChange,
modifier = modifier modifier = modifier.fillMaxWidth(),
.fillMaxWidth() shape = RoundedCornerShape(14.dp),
.background(Color(0xFF2A2A2A), RoundedCornerShape(8.dp)), placeholder = { Text(placeholder, color = colors.textMuted) },
placeholder = { Text(placeholder, color = Color(0xFF888888)) }, leadingIcon = { Icon(Lucide.Search, contentDescription = null, tint = colors.textMuted) },
leadingIcon = { Icon(Lucide.Search, contentDescription = null, tint = Color(0xFF888888)) },
singleLine = true, singleLine = true,
colors = TextFieldDefaults.colors( colors = TextFieldDefaults.colors(
focusedContainerColor = Color(0xFF2A2A2A), focusedContainerColor = colors.surface,
unfocusedContainerColor = Color(0xFF2A2A2A), unfocusedContainerColor = colors.surface,
focusedIndicatorColor = Color.Transparent, focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent,
focusedTextColor = Color.White, cursorColor = colors.accent,
unfocusedTextColor = Color.White focusedTextColor = colors.textPrimary,
unfocusedTextColor = colors.textPrimary
) )
) )
} }

View File

@@ -0,0 +1,24 @@
package com.radiola.ui.components
import androidx.annotation.DrawableRes
import com.radiola.R
import com.radiola.domain.model.DeeplinkService
/**
* Официальные монохромные логотипы музыкальных сервисов (vector drawable).
* Геометрия — из public-domain набора Simple Icons (CC0) и свободного знака Yandex Music.
* Сами бренды — товарные знаки правообладателей; используются как one-color версии
* для номинативного указания «открыть трек в сервисе».
* Для BOOM официального знака нет — вернётся null (рисуется нейтральная иконка-нота).
*/
@DrawableRes
fun serviceLogoRes(service: DeeplinkService): Int? = when (service.serviceId) {
"yandex" -> R.drawable.ic_service_yandex
"vk" -> R.drawable.ic_service_vk
"spotify" -> R.drawable.ic_service_spotify
"apple" -> R.drawable.ic_service_apple
"youtube" -> R.drawable.ic_service_youtube
"tidal" -> R.drawable.ic_service_tidal
"deezer" -> R.drawable.ic_service_deezer
else -> null
}

View File

@@ -1,27 +1,33 @@
package com.radiola.ui.components package com.radiola.ui.components
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.composables.icons.lucide.Heart import com.composables.icons.lucide.Heart
import com.composables.icons.lucide.Lucide import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Radio
import com.radiola.domain.model.Station import com.radiola.domain.model.Station
import com.radiola.ui.theme.Motion
import com.radiola.ui.theme.RadiolaTheme
import com.radiola.ui.theme.pressScale
@Composable @Composable
fun StationCard( fun StationCard(
@@ -31,61 +37,80 @@ fun StationCard(
onFavoriteClick: () -> Unit, onFavoriteClick: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Card( val colors = RadiolaTheme.colors
val haptics = LocalHapticFeedback.current
val interaction = remember { MutableInteractionSource() }
val heartTint by animateColorAsState(
targetValue = if (isFavorite) colors.accent else colors.textPrimary,
animationSpec = tween(Motion.Medium),
label = "heartTint"
)
Column(
modifier = modifier modifier = modifier
.aspectRatio(1f) .pressScale(interactionSource = interaction)
.clickable(onClick = onClick), .clickable(interactionSource = interaction, indication = null, onClick = onClick)
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFF1E1E1E))
) { ) {
Box(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize()) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.weight(1f) .aspectRatio(1f)
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)) .clip(RoundedCornerShape(16.dp))
.background( .background(colors.surface2)
Brush.linearGradient(
colors = listOf(
Color(0xFF667eea),
Color(0xFF764ba2)
)
)
)
) { ) {
if (!station.coverUrl.isNullOrBlank()) {
AsyncImage( AsyncImage(
model = station.coverUrl, model = crossfadeModel(station.coverUrl),
contentDescription = station.name, contentDescription = station.name,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop contentScale = ContentScale.Crop
) )
} } else {
Text( Icon(
text = station.name, Lucide.Radio,
style = MaterialTheme.typography.bodyMedium, contentDescription = null,
modifier = Modifier.padding(12.dp), tint = colors.textMuted,
maxLines = 1 modifier = Modifier.align(Alignment.Center).size(34.dp)
) )
} }
IconButton( Box(
onClick = onFavoriteClick,
modifier = Modifier modifier = Modifier
.align(Alignment.TopEnd) .align(Alignment.TopEnd)
.padding(4.dp) .padding(10.dp)
.size(32.dp) .size(32.dp)
.background( .clip(RoundedCornerShape(16.dp))
color = Color.Black.copy(alpha = 0.4f), .background(androidx.compose.ui.graphics.Color.Black.copy(alpha = 0.4f))
shape = RoundedCornerShape(8.dp) .clickable {
) haptics.performHapticFeedback(HapticFeedbackType.LongPress)
onFavoriteClick()
},
contentAlignment = Alignment.Center
) { ) {
Icon( Icon(
imageVector = Lucide.Heart, imageVector = Lucide.Heart,
contentDescription = if (isFavorite) "В избранном" else "Добавить в избранное", contentDescription = if (isFavorite) "В избранном" else "В избранное",
tint = if (isFavorite) Color(0xFFFF4081) else Color.White, tint = heartTint,
modifier = Modifier.size(18.dp) modifier = Modifier.size(17.dp)
) )
} }
} }
Spacer(Modifier.height(10.dp))
Text(
text = station.name,
style = androidx.compose.material3.MaterialTheme.typography.titleMedium,
color = colors.textPrimary,
maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis
)
if (station.genre.isNotBlank()) {
Text(
text = station.genre,
style = androidx.compose.material3.MaterialTheme.typography.labelMedium,
color = colors.textSecondary,
maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
fontWeight = FontWeight.Normal
)
}
} }
} }

View File

@@ -1,17 +1,26 @@
package com.radiola.ui.components package com.radiola.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Icon
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Music
import com.radiola.domain.model.Track import com.radiola.domain.model.Track
import com.radiola.ui.theme.RadiolaTheme
import com.radiola.ui.theme.pressScale
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
@@ -23,38 +32,57 @@ fun TrackListItem(
onClick: () -> Unit = {}, onClick: () -> Unit = {},
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val colors = RadiolaTheme.colors
val interaction = remember { MutableInteractionSource() }
Row( Row(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.clickable(onClick = onClick) .pressScale(interactionSource = interaction)
.padding(horizontal = 16.dp, vertical = 10.dp), .clickable(interactionSource = interaction, indication = null, onClick = onClick)
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
AsyncImage( Box(
model = track.coverUrl,
contentDescription = null,
modifier = Modifier modifier = Modifier
.size(48.dp) .size(44.dp)
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(10.dp))
.background(colors.surface2),
contentAlignment = Alignment.Center
) {
if (!track.coverUrl.isNullOrBlank()) {
AsyncImage(
model = crossfadeModel(track.coverUrl),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
) )
} else {
Icon(Lucide.Music, null, tint = colors.textSecondary, modifier = Modifier.size(20.dp))
}
}
Spacer(modifier = Modifier.width(12.dp)) Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = "${track.artist}${track.song}", text = "${track.artist}${track.song}",
style = MaterialTheme.typography.bodyMedium, style = androidx.compose.material3.MaterialTheme.typography.titleMedium,
maxLines = 1 color = colors.textPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
) )
Text( Text(
text = track.stationName, text = track.stationName,
style = MaterialTheme.typography.labelMedium, style = androidx.compose.material3.MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) color = colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
) )
} }
timestamp?.let { timestamp?.let {
Spacer(Modifier.width(8.dp))
Text( Text(
text = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(it)), text = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(it)),
style = MaterialTheme.typography.labelMedium, style = androidx.compose.material3.MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f) color = colors.textMuted
) )
} }
} }

View File

@@ -1,5 +1,10 @@
package com.radiola.ui.favorites package com.radiola.ui.favorites
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.animation.fadeIn
import androidx.compose.animation.slideInVertically
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
@@ -7,14 +12,20 @@ import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.composables.icons.lucide.Heart
import com.composables.icons.lucide.Lucide
import com.radiola.domain.model.Station import com.radiola.domain.model.Station
import com.radiola.ui.components.EmptyState import com.radiola.ui.components.EmptyState
import com.radiola.ui.components.StationCard import com.radiola.ui.components.StationCard
import com.radiola.ui.theme.RadiolaTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun FavoritesScreen( fun FavoritesScreen(
onStationClick: (Station) -> Unit, onStationClick: (Station) -> Unit,
@@ -23,41 +34,70 @@ fun FavoritesScreen(
) { ) {
val favorites by viewModel.favorites.collectAsState() val favorites by viewModel.favorites.collectAsState()
val favoriteIds by viewModel.favoriteIds.collectAsState() val favoriteIds by viewModel.favoriteIds.collectAsState()
val colors = RadiolaTheme.colors
Scaffold( Column(
topBar = { modifier = modifier
TopAppBar( .fillMaxSize()
title = { Text("Избранное") }, .padding(horizontal = 20.dp)
colors = TopAppBarDefaults.topAppBarColors( ) {
containerColor = MaterialTheme.colorScheme.background // Заголовок с двухцветным текстом и счётчиком
Row(
modifier = Modifier.padding(top = 20.dp, bottom = 20.dp),
verticalAlignment = androidx.compose.ui.Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = buildAnnotatedString {
withStyle(SpanStyle(color = colors.textPrimary)) { append("Из") }
withStyle(SpanStyle(color = colors.accent)) { append("бранное") }
},
style = MaterialTheme.typography.headlineLarge
) )
if (favorites.isNotEmpty()) {
Text(
text = "${favorites.size}",
style = MaterialTheme.typography.titleMedium,
color = colors.textSecondary,
modifier = Modifier.padding(bottom = 4.dp)
) )
} }
) { padding -> }
if (favorites.isEmpty()) {
Crossfade(
targetState = favorites.isEmpty(),
label = "favoritesState"
) { isEmpty ->
if (isEmpty) {
AnimatedVisibility(
visible = true,
enter = fadeIn() + slideInVertically()
) {
EmptyState( EmptyState(
message = "Нет избранных станций", message = "Нет избранных станций",
modifier = Modifier.fillMaxSize().padding(padding) icon = Lucide.Heart,
modifier = Modifier.fillMaxSize()
) )
}
} else { } else {
LazyVerticalGrid( LazyVerticalGrid(
columns = GridCells.Fixed(2), columns = GridCells.Fixed(2),
modifier = modifier modifier = Modifier.fillMaxSize(),
.fillMaxSize() contentPadding = PaddingValues(bottom = 16.dp),
.padding(padding), horizontalArrangement = Arrangement.spacedBy(14.dp),
contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(14.dp)
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
items(favorites, key = { it.id }) { station -> items(favorites, key = { it.id }) { station ->
StationCard( StationCard(
station = station, station = station,
isFavorite = favoriteIds.contains(station.id), isFavorite = favoriteIds.contains(station.id),
onClick = { onStationClick(station) }, onClick = { onStationClick(station) },
onFavoriteClick = { viewModel.toggleFavorite(station) } onFavoriteClick = { viewModel.toggleFavorite(station) },
modifier = Modifier.animateItemPlacement()
) )
} }
} }
} }
} }
}
} }

View File

@@ -1,20 +1,31 @@
package com.radiola.ui.history package com.radiola.ui.history
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.animation.fadeIn
import androidx.compose.animation.slideInVertically
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.composables.icons.lucide.History
import com.composables.icons.lucide.Lucide
import com.radiola.domain.model.Track import com.radiola.domain.model.Track
import com.radiola.ui.components.DeeplinkBottomSheet import com.radiola.ui.components.DeeplinkBottomSheet
import com.radiola.ui.components.EmptyState import com.radiola.ui.components.EmptyState
import com.radiola.ui.components.SearchBar import com.radiola.ui.components.SearchBar
import com.radiola.ui.components.TrackListItem import com.radiola.ui.components.TrackListItem
import com.radiola.ui.theme.RadiolaTheme
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun HistoryScreen( fun HistoryScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -23,41 +34,60 @@ fun HistoryScreen(
val history by viewModel.history.collectAsState() val history by viewModel.history.collectAsState()
val searchQuery by viewModel.searchQuery.collectAsState() val searchQuery by viewModel.searchQuery.collectAsState()
var selectedTrack by remember { mutableStateOf<Track?>(null) } var selectedTrack by remember { mutableStateOf<Track?>(null) }
val colors = RadiolaTheme.colors
Scaffold(
topBar = {
TopAppBar(
title = { Text("История") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background
)
)
}
) { padding ->
Column( Column(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
.padding(padding) .padding(horizontal = 20.dp)
) { ) {
// Двухцветный заголовок
Text(
text = buildAnnotatedString {
withStyle(SpanStyle(color = colors.textPrimary)) { append("Исто") }
withStyle(SpanStyle(color = colors.accent)) { append("рия") }
},
style = MaterialTheme.typography.headlineLarge,
modifier = Modifier.padding(top = 20.dp, bottom = 16.dp)
)
SearchBar( SearchBar(
query = searchQuery, query = searchQuery,
onQueryChange = viewModel::onSearchQueryChange, onQueryChange = viewModel::onSearchQueryChange,
placeholder = "Поиск в истории...", placeholder = "Поиск в истории...",
modifier = Modifier.padding(16.dp) modifier = Modifier.padding(bottom = 16.dp)
) )
if (history.isEmpty()) {
EmptyState(message = "История пуста", modifier = Modifier.fillMaxSize()) Crossfade(
targetState = history.isEmpty(),
label = "historyState"
) { isEmpty ->
if (isEmpty) {
AnimatedVisibility(
visible = true,
enter = fadeIn() + slideInVertically()
) {
EmptyState(
message = "История пуста",
icon = Lucide.History,
modifier = Modifier.fillMaxSize()
)
}
} else { } else {
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp) contentPadding = PaddingValues(bottom = 16.dp)
) { ) {
items(history) { track -> items(history) { track ->
TrackListItem( TrackListItem(
track = track, track = track,
onClick = { selectedTrack = track } onClick = { selectedTrack = track }
) )
Divider(color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.05f)) // Разделитель с низким alpha
HorizontalDivider(
color = colors.border.copy(alpha = 0.5f),
thickness = 0.5.dp
)
} }
} }
} }

View File

@@ -1,22 +1,71 @@
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 { // Обращаемся к объектам напрямую: companion-список NavDestinations.items
NavDestinations.items.filter { it.showInBottomBar }.forEach { destination -> // при холодном старте может содержать null (порядок инициализации Kotlin).
NavigationBarItem( val items = listOf(
icon = { Icon(destination.icon, contentDescription = destination.labelRes) }, NavDestinations.Stations,
label = { Text(destination.labelRes) }, NavDestinations.Favorites,
selected = currentRoute == destination.route, NavDestinations.History,
NavDestinations.Recordings,
NavDestinations.Settings
)
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 = { onClick = {
if (currentRoute != destination.route) { if (currentRoute != destination.route) {
navController.navigate(destination.route) { navController.navigate(destination.route) {
@@ -30,3 +79,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

@@ -1,8 +1,16 @@
package com.radiola.ui.player package com.radiola.ui.player
import android.util.Log
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.basicMarquee
import androidx.compose.animation.Crossfade
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
@@ -12,28 +20,38 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import android.util.Log
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.composables.icons.lucide.Heart import com.composables.icons.lucide.Heart
import com.composables.icons.lucide.Lucide import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Mic
import com.composables.icons.lucide.MicOff
import com.composables.icons.lucide.Music
import com.composables.icons.lucide.Pause import com.composables.icons.lucide.Pause
import com.composables.icons.lucide.Play import com.composables.icons.lucide.Play
import com.composables.icons.lucide.Radio
import com.composables.icons.lucide.SkipBack import com.composables.icons.lucide.SkipBack
import com.composables.icons.lucide.SkipForward import com.composables.icons.lucide.SkipForward
import com.composables.icons.lucide.Circle import com.radiola.deeplink.DeeplinkNavigator
import com.composables.icons.lucide.Square
import com.radiola.domain.model.DeeplinkService import com.radiola.domain.model.DeeplinkService
import com.radiola.domain.model.Station import com.radiola.domain.model.Station
import com.radiola.deeplink.DeeplinkNavigator
import com.radiola.domain.model.Track import com.radiola.domain.model.Track
import com.radiola.ui.theme.LiveEqualizer
import com.radiola.ui.theme.Motion
import com.radiola.ui.theme.RadiolaTheme
import com.radiola.ui.theme.pressScale
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun PlayerBottomSheet( fun PlayerBottomSheet(
station: Station?, station: Station?,
@@ -51,176 +69,274 @@ fun PlayerBottomSheet(
) { ) {
val context = LocalContext.current val context = LocalContext.current
val enabledServices by viewModel.enabledServices.collectAsState() val enabledServices by viewModel.enabledServices.collectAsState()
val colors = RadiolaTheme.colors
val haptics = LocalHapticFeedback.current
Column( Column(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.background( .background(colors.bgBase)
brush = Brush.verticalGradient( .navigationBarsPadding()
colors = listOf(Color(0xFF1a1a2e), Color(0xFF121212)) .padding(horizontal = 24.dp, vertical = 20.dp),
)
)
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
station?.let { s -> // Метка «В ЭФИРЕ»
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text( Text(
text = s.name, text = "В ЭФИРЕ",
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelSmall,
color = Color(0xFF888888) color = colors.accent,
letterSpacing = 2.sp,
fontWeight = FontWeight.SemiBold
) )
IconButton( Spacer(Modifier.height(16.dp))
onClick = onToggleFavorite,
modifier = Modifier.size(24.dp) // Обложка станции/трека
) {
Icon(
imageVector = Lucide.Heart,
contentDescription = "Избранное",
tint = if (isFavorite) Color(0xFFFF4081) else Color.White,
modifier = Modifier.size(16.dp)
)
}
IconButton(
onClick = onToggleRecording,
modifier = Modifier.size(24.dp)
) {
Icon(
imageVector = if (isRecording) Lucide.Square else Lucide.Circle,
contentDescription = if (isRecording) "Остановить запись" else "Запись",
tint = if (isRecording) Color(0xFFFF5252) else Color(0xFFFF5252),
modifier = Modifier.size(16.dp)
)
}
}
}
Spacer(modifier = Modifier.height(24.dp))
Box( Box(
modifier = Modifier modifier = Modifier
.size(240.dp) .size(220.dp)
.clip(RoundedCornerShape(20.dp)) .clip(RoundedCornerShape(24.dp))
.background( .background(colors.surface2),
Brush.linearGradient(listOf(Color(0xFF667eea), Color(0xFF764ba2))) contentAlignment = Alignment.Center
)
) { ) {
val coverModel = track?.coverUrl ?: station?.coverUrl
if (!coverModel.isNullOrBlank()) {
AsyncImage( AsyncImage(
model = track?.coverUrl ?: station?.coverUrl, model = com.radiola.ui.components.crossfadeModel(coverModel),
contentDescription = null, contentDescription = station?.name,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop contentScale = ContentScale.Crop
) )
} } else {
Spacer(modifier = Modifier.height(24.dp)) Icon(
Text( imageVector = Lucide.Radio,
text = track?.song ?: "", contentDescription = null,
style = MaterialTheme.typography.headlineMedium, tint = colors.textMuted,
maxLines = 1 modifier = Modifier.size(56.dp)
) )
}
}
Spacer(Modifier.height(22.dp))
// Название трека и исполнитель с Crossfade при смене
Crossfade(
targetState = track?.song to track?.artist,
animationSpec = tween(Motion.Medium),
label = "trackInfo"
) { (song, artist) ->
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text( Text(
text = track?.artist ?: "", text = song ?: (station?.name ?: ""),
style = MaterialTheme.typography.headlineLarge,
color = colors.textPrimary,
maxLines = 1,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
modifier = Modifier.basicMarquee()
)
Spacer(Modifier.height(4.dp))
Text(
text = artist ?: (station?.genre ?: ""),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = Color(0xFF888888), color = colors.textSecondary,
maxLines = 1 maxLines = 1,
) overflow = TextOverflow.Ellipsis,
Spacer(modifier = Modifier.height(20.dp)) textAlign = androidx.compose.ui.text.style.TextAlign.Center
LazyRow(
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(horizontal = 8.dp)
) {
items(enabledServices) { service ->
DeeplinkButton(
service = service,
onClick = {
track?.let { t ->
Log.d("PlayerBottomSheet", "DeeplinkButton clicked, track=${t.artist} - ${t.song}")
DeeplinkNavigator.openSearch(context, t, service)
} ?: Log.d("PlayerBottomSheet", "DeeplinkButton clicked but track is null")
}
) )
} }
} }
Spacer(modifier = Modifier.height(30.dp)) Spacer(Modifier.height(20.dp))
// Живой эквалайзер — вместо прогресс-бара (эфир нельзя перематывать)
LiveEqualizer(
modifier = Modifier
.fillMaxWidth()
.height(40.dp),
playing = isPlaying,
color = colors.accent
)
Spacer(Modifier.height(24.dp))
// Управление воспроизведением
Row( Row(
horizontalArrangement = Arrangement.spacedBy(24.dp), horizontalArrangement = Arrangement.spacedBy(24.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
ControlButton( // Кнопка избранного
size = 56.dp, val heartTint by animateColorAsState(
icon = Lucide.SkipBack, targetValue = if (isFavorite) colors.accent else colors.textSecondary,
onClick = onPrevious animationSpec = tween(Motion.Medium),
label = "heartTint"
) )
ControlButton( PlayerIconBtn(size = 44.dp) {
size = 72.dp, IconButton(
isPlay = true, onClick = {
isPlaying = isPlaying, haptics.performHapticFeedback(HapticFeedbackType.LongPress)
onClick = onPlayPause onToggleFavorite()
) },
ControlButton( modifier = Modifier.size(44.dp)
size = 56.dp,
icon = Lucide.SkipForward,
onClick = onNext
)
}
}
}
@Composable
private fun DeeplinkButton(
service: DeeplinkService,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.size(48.dp)
.clip(RoundedCornerShape(12.dp))
.background(Color(0xFF2A2A2A))
.clickable(onClick = onClick),
contentAlignment = Alignment.Center
) { ) {
Text( Icon(Lucide.Heart, "Избранное", tint = heartTint, modifier = Modifier.size(22.dp))
text = service.displayName.take(2), }
style = MaterialTheme.typography.labelSmall,
color = Color(0xFF888888)
)
} }
}
@Composable // Кнопка «предыдущая станция»
private fun ControlButton( PlayerIconBtn(size = 48.dp) {
size: androidx.compose.ui.unit.Dp, IconButton(onClick = onPrevious, modifier = Modifier.size(48.dp)) {
isPlay: Boolean = false, Icon(Lucide.SkipBack, "Предыдущая", tint = colors.textPrimary, modifier = Modifier.size(24.dp))
isPlaying: Boolean = false, }
icon: androidx.compose.ui.graphics.vector.ImageVector? = null, }
onClick: () -> Unit,
modifier: Modifier = Modifier // Главная кнопка play/pause
) { val playInteraction = remember { MutableInteractionSource() }
Box( Box(
modifier = modifier modifier = Modifier
.size(size) .size(68.dp)
.clip(CircleShape) .clip(CircleShape)
.background(if (isPlay) Color.White else Color(0xFF2A2A2A)) .background(colors.accent)
.clickable(onClick = onClick), .pressScale(interactionSource = playInteraction)
.clickable(interactionSource = playInteraction, indication = null) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
onPlayPause()
},
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
if (isPlay) { Crossfade(
targetState = isPlaying,
animationSpec = tween(Motion.Fast),
label = "playPause"
) { playing ->
Icon( Icon(
imageVector = if (isPlaying) Lucide.Pause else Lucide.Play, imageVector = if (playing) Lucide.Pause else Lucide.Play,
contentDescription = if (isPlaying) "Pause" else "Play", contentDescription = if (playing) "Пауза" else "Воспроизвести",
tint = Color.Black, tint = colors.bgBase,
modifier = Modifier.size(size * 0.4f) modifier = Modifier.size(30.dp)
) )
} else if (icon != null) { }
}
// Кнопка «следующая станция»
PlayerIconBtn(size = 48.dp) {
IconButton(onClick = onNext, modifier = Modifier.size(48.dp)) {
Icon(Lucide.SkipForward, "Следующая", tint = colors.textPrimary, modifier = Modifier.size(24.dp))
}
}
// Кнопка записи
val recordTint by animateColorAsState(
targetValue = if (isRecording) colors.live else colors.textSecondary,
animationSpec = tween(Motion.Medium),
label = "recordTint"
)
PlayerIconBtn(size = 44.dp) {
IconButton(onClick = onToggleRecording, modifier = Modifier.size(44.dp)) {
Crossfade(
targetState = isRecording,
animationSpec = tween(Motion.Fast),
label = "recordIcon"
) { recording ->
Icon( Icon(
imageVector = icon, imageVector = if (recording) Lucide.MicOff else Lucide.Mic,
contentDescription = null, contentDescription = if (recording) "Остановить запись" else "Запись",
tint = Color.White, tint = recordTint,
modifier = Modifier.size(size * 0.4f) modifier = Modifier.size(20.dp)
) )
} }
} }
}
}
Spacer(Modifier.height(20.dp))
// Ряд кнопок музыкальных сервисов
if (enabledServices.isNotEmpty()) {
LazyRow(
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(horizontal = 4.dp)
) {
items(enabledServices) { service ->
ServiceDeeplinkBtn(
service = service,
onClick = {
track?.let { t ->
Log.d("PlayerBottomSheet", "Deeplink: ${t.artist} - ${t.song}")
DeeplinkNavigator.openSearch(context, t, service)
} ?: Log.d("PlayerBottomSheet", "Deeplink нажат, но трек null")
}
)
}
}
}
}
}
/** Обёртка для иконок-кнопок управления — прозрачный фон. */
@Composable
private fun PlayerIconBtn(
size: Dp,
content: @Composable () -> Unit
) {
Box(modifier = Modifier.size(size), contentAlignment = Alignment.Center) {
content()
}
}
/** Короткая подпись сервиса под кнопкой (без обрезки слов). */
private fun serviceShortName(service: DeeplinkService): String = when (service.serviceId) {
"yandex" -> "Яндекс"
"vk" -> "ВК Музыка"
"boom" -> "BOOM"
"spotify" -> "Spotify"
"apple" -> "Apple Music"
"youtube" -> "YT Music"
"tidal" -> "Tidal"
"deezer" -> "Deezer"
else -> service.displayName
}
/** Монохромная кнопка сервиса для поиска трека. */
@Composable
private fun ServiceDeeplinkBtn(
service: DeeplinkService,
onClick: () -> Unit
) {
val colors = RadiolaTheme.colors
val interaction = remember { MutableInteractionSource() }
Column(
modifier = Modifier
.pressScale(interactionSource = interaction)
.clickable(interactionSource = interaction, indication = null, onClick = onClick),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Box(
modifier = Modifier
.size(44.dp)
.clip(CircleShape)
.background(colors.surface2),
contentAlignment = Alignment.Center
) {
val logoRes = com.radiola.ui.components.serviceLogoRes(service)
if (logoRes != null) {
Icon(
painter = androidx.compose.ui.res.painterResource(logoRes),
contentDescription = service.displayName,
tint = colors.textSecondary,
modifier = Modifier.size(22.dp)
)
} else {
Icon(
imageVector = Lucide.Music,
contentDescription = service.displayName,
tint = colors.textSecondary,
modifier = Modifier.size(20.dp)
)
}
}
Text(
text = serviceShortName(service),
style = MaterialTheme.typography.labelSmall,
color = colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
} }

View File

@@ -1,30 +1,46 @@
package com.radiola.ui.recordings package com.radiola.ui.recordings
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.animation.fadeIn
import androidx.compose.animation.slideInVertically
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.composables.icons.lucide.Lucide import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Mic
import com.composables.icons.lucide.Play import com.composables.icons.lucide.Play
import com.composables.icons.lucide.Trash2 import com.composables.icons.lucide.Trash2
import com.radiola.domain.model.Recording import com.radiola.domain.model.Recording
import com.radiola.ui.theme.RadiolaTheme
import com.radiola.ui.theme.pressScale
import com.radiola.ui.components.EmptyState
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun RecordingsScreen( fun RecordingsScreen(
viewModel: RecordingsViewModel = hiltViewModel() viewModel: RecordingsViewModel = hiltViewModel()
@@ -32,73 +48,79 @@ fun RecordingsScreen(
val recordings by viewModel.recordings.collectAsState() val recordings by viewModel.recordings.collectAsState()
val isRecording by viewModel.isRecording.collectAsState() val isRecording by viewModel.isRecording.collectAsState()
val context = LocalContext.current val context = LocalContext.current
val colors = RadiolaTheme.colors
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.background) .padding(horizontal = 20.dp)
.padding(16.dp)
) { ) {
// Двухцветный заголовок
Text( Text(
text = "Записи", text = buildAnnotatedString {
style = MaterialTheme.typography.headlineMedium, withStyle(SpanStyle(color = colors.textPrimary)) { append("За") }
modifier = Modifier.padding(bottom = 16.dp) withStyle(SpanStyle(color = colors.accent)) { append("писи") }
},
style = MaterialTheme.typography.headlineLarge,
modifier = Modifier.padding(top = 20.dp, bottom = 16.dp)
) )
// Баннер активной записи
if (isRecording) { if (isRecording) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
colors = CardDefaults.cardColors(
containerColor = Color(0xFFFF5252).copy(alpha = 0.1f)
)
) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp), .clip(RoundedCornerShape(16.dp))
.background(colors.live.copy(alpha = 0.12f))
.border(1.dp, colors.live.copy(alpha = 0.4f), RoundedCornerShape(16.dp))
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween
) { ) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
// Индикатор записи
Box( Box(
modifier = Modifier modifier = Modifier
.size(12.dp) .size(10.dp)
.clip(RoundedCornerShape(6.dp)) .clip(CircleShape)
.background(Color(0xFFFF5252)) .background(colors.live)
) )
Spacer(modifier = Modifier.width(12.dp)) Spacer(modifier = Modifier.width(10.dp))
Text( Text(
text = "Идёт запись...", text = "Идёт запись",
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.labelLarge,
color = Color(0xFFFF5252) color = colors.live
) )
} }
} }
} Spacer(modifier = Modifier.height(16.dp))
} }
if (recordings.isEmpty()) { Crossfade(
Box( targetState = recordings.isEmpty(),
modifier = Modifier.fillMaxSize(), label = "recordingsState"
contentAlignment = Alignment.Center ) { isEmpty ->
if (isEmpty) {
AnimatedVisibility(
visible = true,
enter = fadeIn() + slideInVertically()
) { ) {
Text( EmptyState(
text = "Нет записей", message = "Нет записей",
style = MaterialTheme.typography.bodyLarge, icon = Lucide.Mic,
color = MaterialTheme.colorScheme.onSurfaceVariant modifier = Modifier.fillMaxSize()
) )
} }
} else { } else {
LazyColumn( LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(10.dp),
contentPadding = PaddingValues(bottom = 16.dp)
) { ) {
items(recordings, key = { it.id }) { recording -> items(recordings, key = { it.id }) { recording ->
RecordingItem( RecordingItem(
recording = recording, recording = recording,
onPlay = { onPlay = {
// TODO: play recording via external player or ExoPlayer // TODO: воспроизвести запись через ExoPlayer или внешний плеер
val intent = android.content.Intent(android.content.Intent.ACTION_VIEW).apply { val intent = android.content.Intent(android.content.Intent.ACTION_VIEW).apply {
setDataAndType( setDataAndType(
androidx.core.content.FileProvider.getUriForFile( androidx.core.content.FileProvider.getUriForFile(
@@ -112,84 +134,95 @@ fun RecordingsScreen(
} }
context.startActivity(intent) context.startActivity(intent)
}, },
onDelete = { viewModel.deleteRecording(recording.id) } onDelete = { viewModel.deleteRecording(recording.id) },
modifier = Modifier.animateItemPlacement()
) )
} }
} }
} }
} }
}
} }
@Composable @Composable
private fun RecordingItem( private fun RecordingItem(
recording: Recording, recording: Recording,
onPlay: () -> Unit, onPlay: () -> Unit,
onDelete: () -> Unit onDelete: () -> Unit,
modifier: Modifier = Modifier
) { ) {
val colors = RadiolaTheme.colors
val dateFormat = SimpleDateFormat("dd.MM.yyyy HH:mm", Locale.getDefault()) val dateFormat = SimpleDateFormat("dd.MM.yyyy HH:mm", Locale.getDefault())
val durationText = recording.duration?.let { ms -> val durationText = recording.duration?.let { ms ->
val minutes = TimeUnit.MILLISECONDS.toMinutes(ms) val minutes = TimeUnit.MILLISECONDS.toMinutes(ms)
val seconds = TimeUnit.MILLISECONDS.toSeconds(ms) % 60 val seconds = TimeUnit.MILLISECONDS.toSeconds(ms) % 60
String.format("%02d:%02d", minutes, seconds) String.format("%02d:%02d", minutes, seconds)
} ?: "??:??" } ?: "??:??"
val interactionPlay = remember { MutableInteractionSource() }
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
)
) {
Row( Row(
modifier = Modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(colors.surface)
.padding(12.dp), .padding(12.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp) horizontalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
IconButton( // Круглая кнопка play
onClick = onPlay, Box(
modifier = Modifier.size(40.dp) modifier = Modifier
.size(44.dp)
.clip(CircleShape)
.background(colors.surface2)
.pressScale(interactionSource = interactionPlay)
.clickable(interactionSource = interactionPlay, indication = null, onClick = onPlay),
contentAlignment = Alignment.Center
) { ) {
Icon( Icon(
imageVector = Lucide.Play, imageVector = Lucide.Play,
contentDescription = "Воспроизвести", contentDescription = "Воспроизвести",
tint = MaterialTheme.colorScheme.primary tint = colors.accent,
modifier = Modifier.size(18.dp)
) )
} }
Column( // Метаданные записи
modifier = Modifier.weight(1f) Column(modifier = Modifier.weight(1f)) {
) {
Text( Text(
text = recording.stationName, text = recording.stationName,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.titleMedium,
color = colors.textPrimary,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
if (!recording.trackName.isNullOrBlank()) {
Text( Text(
text = recording.trackName ?: "", text = recording.trackName,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = colors.textSecondary,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
}
Text( Text(
text = "${dateFormat.format(Date(recording.startTime))} $durationText", text = "${dateFormat.format(Date(recording.startTime))} · $durationText",
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = colors.textMuted
) )
} }
// Кнопка удаления
IconButton( IconButton(
onClick = onDelete, onClick = onDelete,
modifier = Modifier.size(40.dp) modifier = Modifier.size(36.dp)
) { ) {
Icon( Icon(
imageVector = Lucide.Trash2, imageVector = Lucide.Trash2,
contentDescription = "Удалить", contentDescription = "Удалить",
tint = Color(0xFFFF5252) tint = colors.live,
modifier = Modifier.size(18.dp)
) )
} }
} }
}
} }

View File

@@ -1,18 +1,35 @@
package com.radiola.ui.settings package com.radiola.ui.settings
import androidx.compose.animation.animateColorAsState
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.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.User
import com.radiola.domain.model.DeeplinkService import com.radiola.domain.model.DeeplinkService
import com.radiola.domain.model.StationTestStatus import com.radiola.domain.model.StationTestStatus
import com.radiola.ui.theme.Motion
import com.radiola.ui.theme.RadiolaTheme
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -33,156 +50,343 @@ fun SettingsScreen(
val currentUser by viewModel.currentUser.collectAsState() val currentUser by viewModel.currentUser.collectAsState()
val presets = listOf("Flat", "Rock", "Pop", "Jazz", "Bass") val presets = listOf("Flat", "Rock", "Pop", "Jazz", "Bass")
var showReport by remember { mutableStateOf(false) } var showReport by remember { mutableStateOf(false) }
val colors = RadiolaTheme.colors
Scaffold(
topBar = {
TopAppBar(
title = { Text("Настройки") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background
)
)
}
) { padding ->
LazyColumn( LazyColumn(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
.padding(padding), .padding(horizontal = 20.dp),
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(20.dp)
) { ) {
item { item {
Text("Профиль", style = MaterialTheme.typography.titleMedium) // Двухцветный заголовок
Spacer(modifier = Modifier.height(8.dp)) Text(
text = buildAnnotatedString {
withStyle(SpanStyle(color = colors.textPrimary)) { append("Нас") }
withStyle(SpanStyle(color = colors.accent)) { append("тройки") }
},
style = MaterialTheme.typography.headlineLarge,
modifier = Modifier.padding(top = 20.dp)
)
}
// --- Профиль ---
item {
SectionLabel("ПРОФИЛЬ")
Spacer(Modifier.height(8.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(colors.surface)
.border(1.dp, colors.border, RoundedCornerShape(16.dp))
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp)
) {
// Аватар-заглушка
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(colors.surface2),
contentAlignment = Alignment.Center
) {
Icon(Lucide.User, null, tint = colors.textMuted, modifier = Modifier.size(22.dp))
}
Column(modifier = Modifier.weight(1f)) {
if (isLoggedIn && currentUser != null) { if (isLoggedIn && currentUser != null) {
Column {
Text( Text(
text = currentUser?.email ?: "", text = currentUser?.email ?: "",
style = MaterialTheme.typography.bodyLarge style = MaterialTheme.typography.titleMedium,
color = colors.textPrimary
) )
Spacer(modifier = Modifier.height(4.dp)) Text(
text = "Аккаунт активен",
style = MaterialTheme.typography.labelMedium,
color = colors.textSecondary
)
} else {
Text(
text = "Вы не вошли",
style = MaterialTheme.typography.titleMedium,
color = colors.textPrimary
)
Text(
text = "Вход не обязателен — для синхронизации",
style = MaterialTheme.typography.labelMedium,
color = colors.textSecondary
)
}
}
if (isLoggedIn && currentUser != null) {
OutlinedButton( OutlinedButton(
onClick = { viewModel.logout() }, onClick = { viewModel.logout() },
modifier = Modifier.fillMaxWidth() colors = ButtonDefaults.outlinedButtonColors(contentColor = colors.textSecondary),
border = androidx.compose.foundation.BorderStroke(1.dp, colors.border)
) { ) {
Text("Выйти") Text("Выйти")
} }
}
} else { } else {
Button( Button(
onClick = onNavigateToAuth, onClick = onNavigateToAuth,
modifier = Modifier.fillMaxWidth() colors = ButtonDefaults.buttonColors(
containerColor = colors.accent,
contentColor = colors.bgBase
),
shape = RoundedCornerShape(10.dp)
) { ) {
Text("Войти") Text("Войти", fontWeight = FontWeight.SemiBold)
}
} }
} }
} }
// --- Таймер сна ---
item { item {
Divider() SectionLabel("ТАЙМЕР СНА")
Spacer(Modifier.height(8.dp))
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(colors.surface)
.border(1.dp, colors.border, RoundedCornerShape(16.dp))
.padding(horizontal = 16.dp, vertical = 12.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Отключить через",
style = MaterialTheme.typography.titleMedium,
color = colors.textPrimary
)
Text(
text = "$sleepTimer мин",
style = MaterialTheme.typography.titleMedium,
color = colors.accent,
fontWeight = FontWeight.SemiBold
)
} }
item {
Text("Таймер сна", style = MaterialTheme.typography.titleMedium)
Slider( Slider(
value = sleepTimer.toFloat(), value = sleepTimer.toFloat(),
onValueChange = { viewModel.setSleepTimer(it.toInt()) }, onValueChange = { viewModel.setSleepTimer(it.toInt()) },
valueRange = 5f..120f, valueRange = 5f..120f,
steps = 22 steps = 22,
colors = SliderDefaults.colors(
thumbColor = colors.accent,
activeTrackColor = colors.accent,
inactiveTrackColor = colors.surface2
)
) )
Text("$sleepTimer мин", style = MaterialTheme.typography.bodyMedium)
} }
}
// --- Эквалайзер ---
item { item {
Text("Эквалайзер", style = MaterialTheme.typography.titleMedium) SectionLabel("ЭКВАЛАЙЗЕР")
SingleChoiceSegmentedButtonRow { Spacer(Modifier.height(8.dp))
presets.forEach { preset -> Row(
SegmentedButton( modifier = Modifier
selected = equalizerPreset == preset, .fillMaxWidth()
onClick = { viewModel.setEqualizerPreset(preset) }, .clip(RoundedCornerShape(16.dp))
shape = MaterialTheme.shapes.small .background(colors.surface)
.border(1.dp, colors.border, RoundedCornerShape(16.dp))
.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) { ) {
Text(preset) presets.forEach { preset ->
val selected = equalizerPreset == preset
val bgColor by animateColorAsState(
targetValue = if (selected) colors.accent else colors.surface2,
animationSpec = tween(Motion.Medium),
label = "eqSegment"
)
val textColor by animateColorAsState(
targetValue = if (selected) colors.bgBase else colors.textSecondary,
animationSpec = tween(Motion.Medium),
label = "eqText"
)
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(8.dp))
.background(bgColor)
.clickable { viewModel.setEqualizerPreset(preset) }
.padding(vertical = 8.dp),
contentAlignment = Alignment.Center
) {
Text(
text = preset,
style = MaterialTheme.typography.labelLarge,
color = textColor,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal
)
} }
} }
} }
} }
// --- Музыкальные сервисы ---
item { item {
Text("Музыкальные сервисы", style = MaterialTheme.typography.titleMedium) SectionLabel("МУЗЫКАЛЬНЫЕ СЕРВИСЫ")
Column { Spacer(Modifier.height(8.dp))
DeeplinkService.entries.forEach { service -> Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(colors.surface)
.border(1.dp, colors.border, RoundedCornerShape(16.dp))
) {
DeeplinkService.entries.forEachIndexed { index, service ->
val checked = service.serviceId in enabledServices val checked = service.serviceId in enabledServices
val trackColor by animateColorAsState(
targetValue = if (checked) colors.accent else colors.surface2,
animationSpec = tween(Motion.Medium),
label = "switchTrack"
)
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { viewModel.toggleService(service.serviceId, !checked) } .clickable { viewModel.toggleService(service.serviceId, !checked) }
.padding(vertical = 12.dp), .padding(horizontal = 16.dp, vertical = 14.dp),
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) { ) {
Text(service.displayName) Text(
text = service.displayName,
style = MaterialTheme.typography.titleMedium,
color = colors.textPrimary
)
Switch( Switch(
checked = checked, checked = checked,
onCheckedChange = { viewModel.toggleService(service.serviceId, it) } onCheckedChange = { viewModel.toggleService(service.serviceId, it) },
colors = SwitchDefaults.colors(
checkedThumbColor = colors.bgBase,
checkedTrackColor = colors.accent,
uncheckedThumbColor = colors.textMuted,
uncheckedTrackColor = colors.surface2
)
)
}
if (index < DeeplinkService.entries.size - 1) {
HorizontalDivider(
color = colors.border,
modifier = Modifier.padding(horizontal = 16.dp)
) )
} }
} }
} }
} }
// --- Запись эфира ---
item { item {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(colors.surface)
.border(1.dp, colors.border, RoundedCornerShape(16.dp))
) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { viewModel.setRecordingEnabled(!isRecordingEnabled) } .clickable { viewModel.setRecordingEnabled(!isRecordingEnabled) }
.padding(vertical = 12.dp), .padding(horizontal = 16.dp, vertical = 14.dp),
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) { ) {
Text("Запись эфира") Column {
Text(
text = "Запись эфира",
style = MaterialTheme.typography.titleMedium,
color = colors.textPrimary
)
Text(
text = "Сохранять в файл при воспроизведении",
style = MaterialTheme.typography.labelMedium,
color = colors.textSecondary
)
}
Switch( Switch(
checked = isRecordingEnabled, checked = isRecordingEnabled,
onCheckedChange = { viewModel.setRecordingEnabled(it) } onCheckedChange = { viewModel.setRecordingEnabled(it) },
colors = SwitchDefaults.colors(
checkedThumbColor = colors.bgBase,
checkedTrackColor = colors.accent,
uncheckedThumbColor = colors.textMuted,
uncheckedTrackColor = colors.surface2
)
) )
} }
} }
item {
Divider()
} }
// --- Тестирование станций ---
item { item {
Text("Тестирование станций", style = MaterialTheme.typography.titleMedium) SectionLabel("ТЕСТИРОВАНИЕ СТАНЦИЙ")
Spacer(modifier = Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(colors.surface)
.border(1.dp, colors.border, RoundedCornerShape(16.dp))
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
if (isTesting) { if (isTesting) {
Column { Text(
text = "Проверено $testProgress из $testTotal",
style = MaterialTheme.typography.bodyMedium,
color = colors.textSecondary
)
LinearProgressIndicator( LinearProgressIndicator(
progress = { if (testTotal > 0) testProgress.toFloat() / testTotal else 0f }, progress = { if (testTotal > 0) testProgress.toFloat() / testTotal else 0f },
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth(),
color = colors.accent,
trackColor = colors.surface2
) )
Spacer(modifier = Modifier.height(4.dp))
Text("Проверено $testProgress из $testTotal")
}
} else if (testResults.isNotEmpty()) { } else if (testResults.isNotEmpty()) {
val ok = testResults.count { it.status == StationTestStatus.OK } val ok = testResults.count { it.status == StationTestStatus.OK }
val okNoMeta = testResults.count { it.status == StationTestStatus.OK_NO_META } val okNoMeta = testResults.count { it.status == StationTestStatus.OK_NO_META }
val offline = testResults.count { it.status == StationTestStatus.OFFLINE } val offline = testResults.count { it.status == StationTestStatus.OFFLINE }
val error = testResults.count { it.status == StationTestStatus.ERROR } val error = testResults.count { it.status == StationTestStatus.ERROR }
Column { Text("Всего: ${testResults.size}", color = colors.textPrimary, style = MaterialTheme.typography.bodyMedium)
Text("Всего: ${testResults.size}") Text("Работают + метаданные: $ok", color = Color(0xFF4CAF50), style = MaterialTheme.typography.bodyMedium)
Text("Работают + метаданные: $ok", color = Color(0xFF4CAF50)) Text("Работают без метаданных: $okNoMeta", color = Color(0xFFFF9800), style = MaterialTheme.typography.bodyMedium)
Text("Работают без метаданных: $okNoMeta", color = Color(0xFFFF9800)) Text("Оффлайн: $offline", color = colors.live, style = MaterialTheme.typography.bodyMedium)
Text("Оффлайн: $offline", color = Color(0xFFFF5252)) Text("Ошибки: $error", color = colors.live, style = MaterialTheme.typography.bodyMedium)
Text("Ошибки: $error", color = Color(0xFFFF5252)) Spacer(Modifier.height(4.dp))
Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { showReport = true }) { Button(
onClick = { showReport = true },
colors = ButtonDefaults.buttonColors(
containerColor = colors.accent,
contentColor = colors.bgBase
)
) {
Text("Подробный отчёт") Text("Подробный отчёт")
} }
OutlinedButton(onClick = { viewModel.clearTestResults() }) { OutlinedButton(
onClick = { viewModel.clearTestResults() },
colors = ButtonDefaults.outlinedButtonColors(contentColor = colors.textSecondary),
border = androidx.compose.foundation.BorderStroke(1.dp, colors.border)
) {
Text("Очистить") Text("Очистить")
} }
} }
}
} else { } else {
Button( OutlinedButton(
onClick = { viewModel.startTesting() }, onClick = { viewModel.startTesting() },
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.outlinedButtonColors(contentColor = colors.accent),
border = androidx.compose.foundation.BorderStroke(1.dp, colors.accent)
) { ) {
Text("Провести тестирование") Text("Провести тестирование")
} }
@@ -191,18 +395,26 @@ fun SettingsScreen(
} }
} }
// Диалог отчёта
if (showReport) { if (showReport) {
AlertDialog( AlertDialog(
onDismissRequest = { showReport = false }, onDismissRequest = { showReport = false },
title = { Text("Результаты тестирования") }, containerColor = colors.elevated,
title = {
Text(
"Результаты тестирования",
color = colors.textPrimary,
style = MaterialTheme.typography.titleLarge
)
},
text = { text = {
LazyColumn(modifier = Modifier.heightIn(max = 400.dp)) { LazyColumn(modifier = Modifier.heightIn(max = 400.dp)) {
items(testResults) { result -> items(testResults) { result ->
val color = when (result.status) { val color = when (result.status) {
StationTestStatus.OK -> Color(0xFF4CAF50) StationTestStatus.OK -> Color(0xFF4CAF50)
StationTestStatus.OK_NO_META -> Color(0xFFFF9800) StationTestStatus.OK_NO_META -> Color(0xFFFF9800)
StationTestStatus.OFFLINE -> Color(0xFFFF5252) StationTestStatus.OFFLINE -> colors.live
StationTestStatus.ERROR -> Color(0xFFFF5252) StationTestStatus.ERROR -> colors.live
} }
Column(modifier = Modifier.padding(vertical = 4.dp)) { Column(modifier = Modifier.padding(vertical = 4.dp)) {
Text( Text(
@@ -219,17 +431,31 @@ fun SettingsScreen(
result.errorMessage?.let { append(" | $it") } result.errorMessage?.let { append(" | $it") }
}, },
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = colors.textMuted
) )
} }
} }
} }
}, },
confirmButton = { confirmButton = {
TextButton(onClick = { showReport = false }) { TextButton(
onClick = { showReport = false },
colors = ButtonDefaults.textButtonColors(contentColor = colors.accent)
) {
Text("Закрыть") Text("Закрыть")
} }
} }
) )
} }
} }
/** Подпись секции: заглавные буквы, textMuted, labelSmall. */
@Composable
private fun SectionLabel(text: String) {
Text(
text = text,
style = MaterialTheme.typography.labelSmall,
color = RadiolaTheme.colors.textMuted,
letterSpacing = 1.sp
)
}

View File

@@ -1,20 +1,26 @@
package com.radiola.ui.stations package com.radiola.ui.stations
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Radio
import com.radiola.domain.model.Station import com.radiola.domain.model.Station
import com.radiola.ui.components.* import com.radiola.ui.components.*
import com.radiola.ui.theme.RadiolaTheme
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun StationsScreen( fun StationsScreen(
onStationClick: (Station) -> Unit, onStationClick: (Station) -> Unit,
@@ -28,67 +34,94 @@ fun StationsScreen(
val isLoading by viewModel.isLoading.collectAsState() val isLoading by viewModel.isLoading.collectAsState()
val error by viewModel.error.collectAsState() val error by viewModel.error.collectAsState()
val favoriteIds by viewModel.favoriteIds.collectAsState() val favoriteIds by viewModel.favoriteIds.collectAsState()
val colors = RadiolaTheme.colors
Column(modifier = modifier.fillMaxSize()) { Column(modifier = modifier.fillMaxSize()) {
TopAppBar( // Двухцветный заголовок экрана
title = { Text("Радио") }, Text(
colors = TopAppBarDefaults.topAppBarColors( text = buildAnnotatedString {
containerColor = MaterialTheme.colorScheme.background withStyle(SpanStyle(color = colors.textPrimary)) { append("Откройте ") }
withStyle(SpanStyle(color = colors.accent)) { append("радио") }
},
style = MaterialTheme.typography.headlineLarge,
modifier = Modifier.padding(start = 20.dp, end = 20.dp, top = 20.dp, bottom = 16.dp)
) )
)
when { // Поиск — всегда виден (в т.ч. когда результатов нет)
isLoading && stations.isEmpty() -> Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
stations.isEmpty() -> Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
EmptyState(message = error ?: "Станции не найдены")
if (selectedTag != null) {
Button(onClick = { viewModel.onTagSelected(null) }) {
Text("Показать все")
}
}
}
}
else -> LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
item(span = { GridItemSpan(maxLineSpan) }) {
SearchBar( SearchBar(
query = searchQuery, query = searchQuery,
onQueryChange = viewModel::onSearchQueryChange, onQueryChange = viewModel::onSearchQueryChange,
modifier = Modifier.padding(horizontal = 20.dp)
) )
} Spacer(Modifier.height(12.dp))
item(span = { GridItemSpan(maxLineSpan) }) {
// Жанры — всегда видны
if (tags.isNotEmpty()) {
FilterChips( FilterChips(
tags = tags, tags = tags,
selectedTag = selectedTag, selectedTag = selectedTag,
onTagSelected = viewModel::onTagSelected, onTagSelected = viewModel::onTagSelected
modifier = Modifier.padding(vertical = 8.dp) )
Spacer(Modifier.height(8.dp))
}
// Область результатов — единственная прокручиваемая зона
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
when {
isLoading && stations.isEmpty() -> {
CircularProgressIndicator(
color = colors.accent,
modifier = Modifier.align(Alignment.Center)
) )
} }
stations.isEmpty() -> {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
EmptyState(
message = error
?: if (searchQuery.isNotBlank() || selectedTag != null)
"Ничего не найдено" else "Станции не найдены",
icon = Lucide.Radio,
modifier = Modifier.wrapContentSize()
)
if (searchQuery.isNotBlank() || selectedTag != null) {
Spacer(Modifier.height(12.dp))
OutlinedButton(
onClick = {
viewModel.onSearchQueryChange("")
viewModel.onTagSelected(null)
},
colors = ButtonDefaults.outlinedButtonColors(contentColor = colors.accent),
border = androidx.compose.foundation.BorderStroke(1.dp, colors.accent)
) {
Text("Сбросить фильтры")
}
}
}
}
else -> LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = 20.dp, end = 20.dp, top = 4.dp, bottom = 20.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
items(stations, key = { it.id }) { station -> items(stations, key = { it.id }) { station ->
StationCard( StationCard(
station = station, station = station,
isFavorite = favoriteIds.contains(station.id), isFavorite = favoriteIds.contains(station.id),
onClick = { onStationClick(station) }, onClick = { onStationClick(station) },
onFavoriteClick = { viewModel.toggleFavorite(station) } onFavoriteClick = { viewModel.toggleFavorite(station) },
modifier = Modifier.animateItemPlacement()
) )
} }
} }
} }
} }
}
} }

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
) )
) )

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M23.994 6.124a9.23 9.23 0 00-.24-2.19c-.317-1.31-1.062-2.31-2.18-3.043a5.022 5.022 0 00-1.877-.726 10.496 10.496 0 00-1.564-.15c-.04-.003-.083-.01-.124-.013H5.986c-.152.01-.303.017-.455.026-.747.043-1.49.123-2.193.4-1.336.53-2.3 1.452-2.865 2.78-.192.448-.292.925-.363 1.408-.056.392-.088.785-.1 1.18 0 .032-.007.062-.01.093v12.223c.01.14.017.283.027.424.05.815.154 1.624.497 2.373.65 1.42 1.738 2.353 3.234 2.801.42.127.856.187 1.293.228.555.053 1.11.06 1.667.06h11.03a12.5 12.5 0 001.57-.1c.822-.106 1.596-.35 2.295-.81a5.046 5.046 0 001.88-2.207c.186-.42.293-.87.37-1.324.113-.675.138-1.358.137-2.04-.002-3.8 0-7.595-.003-11.393zm-6.423 3.99v5.712c0 .417-.058.827-.244 1.206-.29.59-.76.962-1.388 1.14-.35.1-.706.157-1.07.173-.95.045-1.773-.6-1.943-1.536a1.88 1.88 0 011.038-2.022c.323-.16.67-.25 1.018-.324.378-.082.758-.153 1.134-.24.274-.063.457-.23.51-.516a.904.904 0 00.02-.193c0-1.815 0-3.63-.002-5.443a.725.725 0 00-.026-.185c-.04-.15-.15-.243-.304-.234-.16.01-.318.035-.475.066-.76.15-1.52.303-2.28.456l-2.325.47-1.374.278c-.016.003-.032.01-.048.013-.277.077-.377.203-.39.49-.002.042 0 .086 0 .13-.002 2.602 0 5.204-.003 7.805 0 .42-.047.836-.215 1.227-.278.64-.77 1.04-1.434 1.233-.35.1-.71.16-1.075.172-.96.036-1.755-.6-1.92-1.544-.14-.812.23-1.685 1.154-2.075.357-.15.73-.232 1.108-.31.287-.06.575-.116.86-.177.383-.083.583-.323.6-.714v-.15c0-2.96 0-5.922.002-8.882 0-.123.013-.25.042-.37.07-.285.273-.448.546-.518.255-.066.515-.112.774-.165.733-.15 1.466-.296 2.2-.444l2.27-.46c.67-.134 1.34-.27 2.01-.403.22-.043.442-.088.663-.106.31-.025.523.17.554.482.008.073.012.148.012.223.002 1.91.002 3.822 0 5.732z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M.693 10.024c.381 0 .693-1.256.693-2.807 0-1.55-.312-2.807-.693-2.807C.312 4.41 0 5.666 0 7.217s.312 2.808.693 2.808ZM21.038 1.56c-.364 0-.684.805-.91 2.096C19.765 1.446 19.184 0 18.526 0c-.78 0-1.464 2.036-1.784 5-.312-2.158-.788-3.536-1.325-3.536-.745 0-1.386 2.704-1.62 6.472-.442-1.932-1.083-3.145-1.793-3.145s-1.35 1.213-1.793 3.145c-.242-3.76-.874-6.463-1.628-6.463-.537 0-1.013 1.378-1.325 3.535C6.938 2.036 6.262 0 5.474 0c-.658 0-1.247 1.447-1.602 3.665-.217-1.291-.546-2.105-.91-2.105-.675 0-1.221 2.807-1.221 6.272 0 3.466.546 6.273 1.221 6.273.277 0 .537-.476.736-1.273.32 2.928.996 4.938 1.776 4.938.606 0 1.143-1.204 1.507-3.11.251 3.622.875 6.195 1.602 6.195.46 0 .875-1.023 1.187-2.677C10.142 21.6 11 24 12.004 24c1.005 0 1.863-2.4 2.235-5.822.312 1.654.727 2.677 1.186 2.677.728 0 1.352-2.573 1.603-6.195.364 1.906.9 3.11 1.507 3.11.78 0 1.455-2.01 1.775-4.938.208.797.46 1.273.737 1.273.675 0 1.22-2.807 1.22-6.273-.008-3.457-.553-6.272-1.23-6.272ZM23.307 10.024c.381 0 .693-1.256.693-2.807 0-1.55-.312-2.807-.693-2.807-.381 0-.693 1.256-.693 2.807s.312 2.808.693 2.808Z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M12.012 3.992L8.008 7.996 4.004 3.992 0 7.996 4.004 12l4.004-4.004L12.012 12l-4.004 4.004 4.004 4.004 4.004-4.004L12.012 12l4.004-4.004-4.004-4.004zM16.042 7.996l3.979-3.979L24 7.996l-3.979 3.979z" />
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:fillType="evenOdd"
android:pathData="M13.162 18.994c-6.842 0-10.745-4.691-10.908-12.493h3.427c.113 5.726 2.633 8.147 4.629 8.646V6.501h3.225v4.943c1.972-.212 4.043-2.46 4.742-4.943h3.225c-.537 2.063-2.786 4.31-4.386 5.232 1.6.749 4.162 2.71 5.135 5.261h-3.551c-.762-2.371-2.656-4.204-4.665-4.452v4.452z" />
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="192"
android:viewportHeight="192">
<path
android:fillColor="#FFFFFFFF"
android:fillType="evenOdd"
android:pathData="M33.983 16.865a82 82 0 0 1 41.44-15.783v24.744a57.4 57.4 0 1 0 63.681 45.747l20.847-16.914c5.609 15.43 6.383 32.29 2.334 48.252a82.002 82.002 0 0 1-156.724 7.364 82 82 0 0 1 28.422-93.41Z M144.615 28.68s-10.808 16.62-14.198 22.005a57.69 57.69 0 0 0-19.735-18.12v50.187c0 15.398-12.482 27.88-27.88 27.88-15.397 0-27.88-12.482-27.88-27.88s12.483-27.88 27.88-27.88a27.75 27.75 0 0 1 15.58 4.756V2.23c18.101 3.482 34.484 13.182 46.233 26.45z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M12 0C5.376 0 0 5.376 0 12s5.376 12 12 12 12-5.376 12-12S18.624 0 12 0zm0 19.104c-3.924 0-7.104-3.18-7.104-7.104S8.076 4.896 12 4.896s7.104 3.18 7.104 7.104-3.18 7.104-7.104 7.104zm0-13.332c-3.432 0-6.228 2.796-6.228 6.228S8.568 18.228 12 18.228s6.228-2.796 6.228-6.228S15.432 5.772 12 5.772zM9.684 15.54V8.46L15.816 12l-6.132 3.54z" />
</vector>