Compare commits

..

8 Commits

Author SHA1 Message Date
nk
a4af72a6e6 polish(ui): плавные обложки, бегущая строка длинных названий, тактильный отклик
- Coil crossfade для всех обложек (Images.crossfadeModel) — без «моргания» при загрузке
- basicMarquee для длинных названий трека (плеер и мини-плеер) вместо обрезки
- haptic feedback на play/pause и добавление в избранное (плеер, мини-плеер, карточка)
2026-06-02 22:55:18 +03:00
nk
310d6c3177 fix(player): отображение трека и обложки — объединение REST и socket now-playing
REST-поллинг (refreshNowPlaying -> api.getNowPlaying, 200 OK) писал данные в
_nowPlaying, который нигде не читался; getNowPlaying() брал только сокет (пустой).
Теперь getNowPlaying/getAllNowPlaying объединяют оба источника (socket ?: REST),
поэтому название трека, обложка и deep-link сервисов работают.
2026-06-02 22:55:18 +03:00
nk
cefd0be009 fix(ui): отступы под системную навигацию + подписи сервисов + краш навбара
- навбар и мини-плеер: navigationBarsPadding — не налезают на системные кнопки
- плеер: navigationBarsPadding снизу, ряд сервисов не уходит под системную панель
- подписи сервисов без обрезки слов (Яндекс / ВК Музыка / YT Music и т.д.)
- фикс NPE при холодном старте: навбар обращается к NavDestinations напрямую,
  не к companion-списку (порядок инициализации Kotlin)
2026-06-02 22:55:17 +03:00
nk
af13272852 fix(ui): единый скролл на экране станций + всегда видимый навбар
- StationsScreen: закреплённые заголовок/поиск/жанры, одна прокручиваемая
  сетка станций; поиск и фильтры больше не исчезают при пустом результате
  (+ кнопка «Сбросить фильтры»)
- таб-бар показывается без обязательного входа (скрыт только на экране входа)
- старт сразу со «Станций» — авторизация необязательна, вход из Настроек
2026-06-02 22:55:17 +03:00
nk
220d1d6fa1 feat(ui): мягкие переходы между экранами (fade + лёгкий slide) 2026-06-02 22:55:17 +03:00
nk
f604ad42e8 feat(ui): рестайл всех экранов + плеер + официальные mono-логотипы сервисов
- экраны (Станции/Избранное/История/Записи/Настройки/Вход): двухцветные
  заголовки, токены темы, EmptyState, анимации появления и перестановки
- AuthScreen: брендовый локап (AppMark + RadiolaWordmark)
- PlayerBottomSheet: живой эфир — LiveEqualizer вместо перемотки,
  Crossfade трека и play/pause, pressScale, анимация избранного/записи
- кнопки музыкальных сервисов: монохромные официальные логотипы
  (vector drawable из Simple Icons CC0 + Yandex), маппинг serviceLogoRes
- DeeplinkBottomSheet: сетка сервисов с логотипами
2026-06-02 22:55:17 +03:00
nk
d652dc399a feat(ui): рестайл общих компонентов под дизайн-систему
- StationCard: обложка/иконка-заглушка, анимированное сердечко, pressScale
- MiniPlayer: elevated-бар, метка «СЕЙЧАС ИГРАЕТ», Crossfade play/pause
- SearchBar: surface-поле, акцентный курсор, скругление 14
- FilterChips: акцентный активный чип с анимацией цвета
- EmptyState: иконка-плашка + текст
- TrackListItem: thumb-заглушка, pressScale
2026-06-02 22:55:17 +03:00
nk
ae406554de feat(ui): дизайн-система radiOLA — палитра, тема, типографика, бренд, motion, pill таб-бар
- цветовые токены тёмно-зелёной темы + RadiolaColors (CompositionLocal)
- darkColorScheme + всегда тёмная тема, фирменные shapes
- типографика с весами/размерами под макет
- Brand: AppMark (градиентный R), RadiolaWordmark, MonoMark
- Motion: спеки движения, pressScale, живой эквалайзер
- pill-таб-бар с анимированной активной вкладкой
2026-06-02 22:55:17 +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.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.lifecycle.lifecycleScope
import com.radiola.data.local.TokenDataStore
@@ -65,23 +67,30 @@ class MainActivity : ComponentActivity() {
val isRecording by playerViewModel.isRecording.collectAsState()
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(
bottomBar = {
Column {
if (currentStation != null) {
MiniPlayer(
stationName = currentStation!!.name,
track = currentTrack,
isPlaying = isPlaying,
onClick = { showPlayer = true },
onPlayPause = { playerViewModel.togglePlayPause() }
)
}
if (isLoggedIn) {
if (showChrome) {
Column(Modifier.navigationBarsPadding()) {
if (currentStation != null) {
MiniPlayer(
stationName = currentStation!!.name,
track = currentTrack,
isPlaying = isPlaying,
onClick = { showPlayer = true },
onPlayPause = { playerViewModel.togglePlayPause() }
)
Spacer(Modifier.height(8.dp))
}
// Навигация доступна и без входа — приложением можно
// пользоваться анонимно.
BottomNavBar(navController)
}
}
@@ -90,7 +99,16 @@ class MainActivity : ComponentActivity() {
NavHost(
navController = navController,
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) {
StationsScreen(

View File

@@ -7,7 +7,7 @@ import com.radiola.domain.model.Track
import com.radiola.domain.repository.NowPlayingRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.combine
import javax.inject.Inject
class NowPlayingRepositoryImpl @Inject constructor(
@@ -21,11 +21,19 @@ class NowPlayingRepositoryImpl @Inject constructor(
socketClient.connect()
}
// Объединяем два источника: сокет (реалтайм, приоритет) и REST-поллинг
// (refreshNowPlaying). Раньше REST-данные писались в _nowPlaying, но никем
// не читались — из-за этого трек и обложка не отображались.
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> {
return try {

View File

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

View File

@@ -1,19 +1,29 @@
package com.radiola.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.grid.GridCells
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.runtime.*
import androidx.compose.ui.Alignment
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.hilt.navigation.compose.hiltViewModel
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Music
import com.radiola.deeplink.DeeplinkNavigator
import com.radiola.domain.model.DeeplinkService
import com.radiola.domain.model.Track
import com.radiola.ui.player.PlayerViewModel
import com.radiola.ui.theme.RadiolaTheme
import com.radiola.ui.theme.pressScale
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -25,21 +35,44 @@ fun DeeplinkBottomSheet(
) {
val context = androidx.compose.ui.platform.LocalContext.current
val enabledServices by viewModel.enabledServices.collectAsState()
val colors = RadiolaTheme.colors
ModalBottomSheet(
onDismissRequest = onDismiss,
modifier = modifier
modifier = modifier,
containerColor = colors.elevated
) {
Text(
text = "Найти трек",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp)
)
LazyColumn {
Column(modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp)) {
Text(
text = "Найти трек",
style = MaterialTheme.typography.titleLarge,
color = colors.textPrimary
)
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 ->
ListItem(
headlineContent = { Text(service.displayName) },
modifier = Modifier.clickable {
ServiceGridBtn(
service = service,
onClick = {
DeeplinkNavigator.openSearch(context, track, service)
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
import androidx.compose.foundation.background
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.runtime.Composable
import androidx.compose.ui.Alignment
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 com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Radio
import com.radiola.ui.theme.RadiolaTheme
@Composable
fun EmptyState(
message: String,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
icon: ImageVector = Lucide.Radio
) {
val colors = RadiolaTheme.colors
Box(
modifier = modifier.fillMaxSize(),
modifier = modifier.fillMaxSize().padding(32.dp),
contentAlignment = Alignment.Center
) {
Text(
text = message,
style = MaterialTheme.typography.bodyLarge,
color = Color(0xFF888888)
)
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 = message,
style = androidx.compose.material3.MaterialTheme.typography.bodyLarge,
color = colors.textSecondary,
textAlign = TextAlign.Center
)
}
}
}

View File

@@ -1,13 +1,28 @@
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.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyRow
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.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.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.radiola.ui.theme.Motion
import com.radiola.ui.theme.RadiolaTheme
@Composable
fun FilterChips(
@@ -18,22 +33,49 @@ fun FilterChips(
) {
LazyRow(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(9.dp),
contentPadding = PaddingValues(horizontal = 16.dp)
) {
item {
FilterChip(
selected = selectedTag == null,
onClick = { onTagSelected(null) },
label = { Text("Все") }
)
Chip(label = "Все", selected = selectedTag == null) { onTagSelected(null) }
}
items(tags) { tag ->
FilterChip(
selected = selectedTag == tag,
onClick = { onTagSelected(tag) },
label = { Text(tag) }
)
Chip(label = tag, selected = selectedTag == tag) { onTagSelected(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
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.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.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.sp
import coil.compose.AsyncImage
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Pause
import com.composables.icons.lucide.Play
import com.composables.icons.lucide.Radio
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
fun MiniPlayer(
stationName: String,
@@ -29,42 +43,79 @@ fun MiniPlayer(
onPlayPause: () -> Unit,
modifier: Modifier = Modifier
) {
val colors = RadiolaTheme.colors
val haptics = LocalHapticFeedback.current
Row(
modifier = modifier
.fillMaxWidth()
.height(64.dp)
.background(Color(0xFF1E1E1E))
.padding(horizontal = 16.dp)
.clip(RoundedCornerShape(18.dp))
.background(colors.elevated)
.border(1.dp, colors.border, RoundedCornerShape(18.dp))
.clickable(onClick = onClick)
.padding(horizontal = 16.dp),
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
AsyncImage(
model = track?.coverUrl,
contentDescription = null,
Box(
modifier = Modifier
.size(48.dp)
.clip(RoundedCornerShape(6.dp))
)
Spacer(modifier = Modifier.width(12.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
)
} else {
Icon(Lucide.Radio, null, tint = colors.textSecondary, modifier = Modifier.size(20.dp))
}
}
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = stationName,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1
text = "СЕЙЧАС ИГРАЕТ",
color = colors.accent,
fontSize = 9.sp,
fontWeight = FontWeight.SemiBold,
letterSpacing = 1.sp
)
Spacer(Modifier.height(2.dp))
Text(
text = track?.let { "${it.artist}${it.song}" } ?: "",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
maxLines = 1
text = track?.let { "${it.artist}${it.song}" } ?: stationName,
style = androidx.compose.material3.MaterialTheme.typography.titleMedium,
color = colors.textPrimary,
maxLines = 1,
modifier = Modifier.basicMarquee()
)
}
IconButton(onClick = onPlayPause) {
Icon(
imageVector = if (isPlaying) Lucide.Pause else Lucide.Play,
contentDescription = if (isPlaying) "Pause" else "Play",
tint = Color.White
)
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(
imageVector = if (playing) Lucide.Pause else Lucide.Play,
contentDescription = if (playing) "Пауза" else "Играть",
tint = colors.bgBase,
modifier = Modifier.size(20.dp)
)
}
}
}
}

View File

@@ -1,10 +1,8 @@
package com.radiola.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
@@ -14,6 +12,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Search
import com.radiola.ui.theme.RadiolaTheme
@Composable
fun SearchBar(
@@ -22,22 +21,23 @@ fun SearchBar(
placeholder: String = "Поиск станции...",
modifier: Modifier = Modifier
) {
val colors = RadiolaTheme.colors
TextField(
value = query,
onValueChange = onQueryChange,
modifier = modifier
.fillMaxWidth()
.background(Color(0xFF2A2A2A), RoundedCornerShape(8.dp)),
placeholder = { Text(placeholder, color = Color(0xFF888888)) },
leadingIcon = { Icon(Lucide.Search, contentDescription = null, tint = Color(0xFF888888)) },
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
placeholder = { Text(placeholder, color = colors.textMuted) },
leadingIcon = { Icon(Lucide.Search, contentDescription = null, tint = colors.textMuted) },
singleLine = true,
colors = TextFieldDefaults.colors(
focusedContainerColor = Color(0xFF2A2A2A),
unfocusedContainerColor = Color(0xFF2A2A2A),
focusedContainerColor = colors.surface,
unfocusedContainerColor = colors.surface,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
focusedTextColor = Color.White,
unfocusedTextColor = Color.White
cursorColor = colors.accent,
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
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
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.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
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 coil.compose.AsyncImage
import com.composables.icons.lucide.Heart
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Radio
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
fun StationCard(
@@ -31,61 +37,80 @@ fun StationCard(
onFavoriteClick: () -> Unit,
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
.aspectRatio(1f)
.clickable(onClick = onClick),
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFF1E1E1E))
.pressScale(interactionSource = interaction)
.clickable(interactionSource = interaction, indication = null, onClick = onClick)
) {
Box(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize()) {
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
.background(
Brush.linearGradient(
colors = listOf(
Color(0xFF667eea),
Color(0xFF764ba2)
)
)
)
) {
AsyncImage(
model = station.coverUrl,
contentDescription = station.name,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
}
Text(
text = station.name,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(12.dp),
maxLines = 1
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.clip(RoundedCornerShape(16.dp))
.background(colors.surface2)
) {
if (!station.coverUrl.isNullOrBlank()) {
AsyncImage(
model = crossfadeModel(station.coverUrl),
contentDescription = station.name,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else {
Icon(
Lucide.Radio,
contentDescription = null,
tint = colors.textMuted,
modifier = Modifier.align(Alignment.Center).size(34.dp)
)
}
IconButton(
onClick = onFavoriteClick,
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(4.dp)
.padding(10.dp)
.size(32.dp)
.background(
color = Color.Black.copy(alpha = 0.4f),
shape = RoundedCornerShape(8.dp)
)
.clip(RoundedCornerShape(16.dp))
.background(androidx.compose.ui.graphics.Color.Black.copy(alpha = 0.4f))
.clickable {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
onFavoriteClick()
},
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Lucide.Heart,
contentDescription = if (isFavorite) "В избранном" else "Добавить в избранное",
tint = if (isFavorite) Color(0xFFFF4081) else Color.White,
modifier = Modifier.size(18.dp)
contentDescription = if (isFavorite) "В избранном" else "В избранное",
tint = heartTint,
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
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
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.ui.theme.RadiolaTheme
import com.radiola.ui.theme.pressScale
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@@ -23,38 +32,57 @@ fun TrackListItem(
onClick: () -> Unit = {},
modifier: Modifier = Modifier
) {
val colors = RadiolaTheme.colors
val interaction = remember { MutableInteractionSource() }
Row(
modifier = modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 10.dp),
.pressScale(interactionSource = interaction)
.clickable(interactionSource = interaction, indication = null, onClick = onClick)
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
AsyncImage(
model = track.coverUrl,
contentDescription = null,
Box(
modifier = Modifier
.size(48.dp)
.clip(RoundedCornerShape(8.dp))
)
.size(44.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))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "${track.artist}${track.song}",
style = MaterialTheme.typography.bodyMedium,
maxLines = 1
style = androidx.compose.material3.MaterialTheme.typography.titleMedium,
color = colors.textPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = track.stationName,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
style = androidx.compose.material3.MaterialTheme.typography.labelMedium,
color = colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
timestamp?.let {
Spacer(Modifier.width(8.dp))
Text(
text = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(it)),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f)
style = androidx.compose.material3.MaterialTheme.typography.labelMedium,
color = colors.textMuted
)
}
}

View File

@@ -1,5 +1,10 @@
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.lazy.grid.GridCells
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.runtime.*
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.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.ui.components.EmptyState
import com.radiola.ui.components.StationCard
import com.radiola.ui.theme.RadiolaTheme
import androidx.compose.foundation.layout.Arrangement
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun FavoritesScreen(
onStationClick: (Station) -> Unit,
@@ -23,40 +34,69 @@ fun FavoritesScreen(
) {
val favorites by viewModel.favorites.collectAsState()
val favoriteIds by viewModel.favoriteIds.collectAsState()
val colors = RadiolaTheme.colors
Scaffold(
topBar = {
TopAppBar(
title = { Text("Избранное") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background
Column(
modifier = modifier
.fillMaxSize()
.padding(horizontal = 20.dp)
) {
// Заголовок с двухцветным текстом и счётчиком
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()) {
EmptyState(
message = "Нет избранных станций",
modifier = Modifier.fillMaxSize().padding(padding)
)
} else {
LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = modifier
.fillMaxSize()
.padding(padding),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(favorites, key = { it.id }) { station ->
StationCard(
station = station,
isFavorite = favoriteIds.contains(station.id),
onClick = { onStationClick(station) },
onFavoriteClick = { viewModel.toggleFavorite(station) }
Crossfade(
targetState = favorites.isEmpty(),
label = "favoritesState"
) { isEmpty ->
if (isEmpty) {
AnimatedVisibility(
visible = true,
enter = fadeIn() + slideInVertically()
) {
EmptyState(
message = "Нет избранных станций",
icon = Lucide.Heart,
modifier = Modifier.fillMaxSize()
)
}
} else {
LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 16.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
items(favorites, key = { it.id }) { station ->
StationCard(
station = station,
isFavorite = favoriteIds.contains(station.id),
onClick = { onStationClick(station) },
onFavoriteClick = { viewModel.toggleFavorite(station) },
modifier = Modifier.animateItemPlacement()
)
}
}
}
}
}

View File

@@ -1,20 +1,31 @@
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
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.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.ui.components.DeeplinkBottomSheet
import com.radiola.ui.components.EmptyState
import com.radiola.ui.components.SearchBar
import com.radiola.ui.components.TrackListItem
import com.radiola.ui.theme.RadiolaTheme
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun HistoryScreen(
modifier: Modifier = Modifier,
@@ -23,41 +34,60 @@ fun HistoryScreen(
val history by viewModel.history.collectAsState()
val searchQuery by viewModel.searchQuery.collectAsState()
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(
modifier = modifier
.fillMaxSize()
.padding(padding)
) {
SearchBar(
query = searchQuery,
onQueryChange = viewModel::onSearchQueryChange,
placeholder = "Поиск в истории...",
modifier = Modifier.padding(16.dp)
)
if (history.isEmpty()) {
EmptyState(message = "История пуста", modifier = Modifier.fillMaxSize())
Column(
modifier = modifier
.fillMaxSize()
.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(
query = searchQuery,
onQueryChange = viewModel::onSearchQueryChange,
placeholder = "Поиск в истории...",
modifier = Modifier.padding(bottom = 16.dp)
)
Crossfade(
targetState = history.isEmpty(),
label = "historyState"
) { isEmpty ->
if (isEmpty) {
AnimatedVisibility(
visible = true,
enter = fadeIn() + slideInVertically()
) {
EmptyState(
message = "История пуста",
icon = Lucide.History,
modifier = Modifier.fillMaxSize()
)
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp)
contentPadding = PaddingValues(bottom = 16.dp)
) {
items(history) { track ->
TrackListItem(
track = 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
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkHorizontally
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import androidx.navigation.compose.currentBackStackEntryAsState
import com.radiola.ui.theme.Motion
import com.radiola.ui.theme.RadiolaTheme
@Composable
fun BottomNavBar(navController: NavController) {
val colors = RadiolaTheme.colors
val currentRoute = navController.currentBackStackEntryAsState().value?.destination?.route
NavigationBar {
NavDestinations.items.filter { it.showInBottomBar }.forEach { destination ->
NavigationBarItem(
icon = { Icon(destination.icon, contentDescription = destination.labelRes) },
label = { Text(destination.labelRes) },
selected = currentRoute == destination.route,
// Обращаемся к объектам напрямую: companion-список NavDestinations.items
// при холодном старте может содержать null (порядок инициализации Kotlin).
val items = listOf(
NavDestinations.Stations,
NavDestinations.Favorites,
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 = {
if (currentRoute != 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
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.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
@@ -12,28 +20,38 @@ import androidx.compose.runtime.*
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 android.util.Log
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
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.sp
import androidx.hilt.navigation.compose.hiltViewModel
import coil.compose.AsyncImage
import com.composables.icons.lucide.Heart
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.Play
import com.composables.icons.lucide.Radio
import com.composables.icons.lucide.SkipBack
import com.composables.icons.lucide.SkipForward
import com.composables.icons.lucide.Circle
import com.composables.icons.lucide.Square
import com.radiola.deeplink.DeeplinkNavigator
import com.radiola.domain.model.DeeplinkService
import com.radiola.domain.model.Station
import com.radiola.deeplink.DeeplinkNavigator
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
fun PlayerBottomSheet(
station: Station?,
@@ -51,176 +69,274 @@ fun PlayerBottomSheet(
) {
val context = LocalContext.current
val enabledServices by viewModel.enabledServices.collectAsState()
val colors = RadiolaTheme.colors
val haptics = LocalHapticFeedback.current
Column(
modifier = modifier
.fillMaxWidth()
.background(
brush = Brush.verticalGradient(
colors = listOf(Color(0xFF1a1a2e), Color(0xFF121212))
)
)
.padding(24.dp),
.background(colors.bgBase)
.navigationBarsPadding()
.padding(horizontal = 24.dp, vertical = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
station?.let { s ->
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = s.name,
style = MaterialTheme.typography.labelMedium,
color = Color(0xFF888888)
)
IconButton(
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))
// Метка «В ЭФИРЕ»
Text(
text = "В ЭФИРЕ",
style = MaterialTheme.typography.labelSmall,
color = colors.accent,
letterSpacing = 2.sp,
fontWeight = FontWeight.SemiBold
)
Spacer(Modifier.height(16.dp))
// Обложка станции/трека
Box(
modifier = Modifier
.size(240.dp)
.clip(RoundedCornerShape(20.dp))
.background(
Brush.linearGradient(listOf(Color(0xFF667eea), Color(0xFF764ba2)))
.size(220.dp)
.clip(RoundedCornerShape(24.dp))
.background(colors.surface2),
contentAlignment = Alignment.Center
) {
val coverModel = track?.coverUrl ?: station?.coverUrl
if (!coverModel.isNullOrBlank()) {
AsyncImage(
model = com.radiola.ui.components.crossfadeModel(coverModel),
contentDescription = station?.name,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
) {
AsyncImage(
model = track?.coverUrl ?: station?.coverUrl,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
}
Spacer(modifier = Modifier.height(24.dp))
Text(
text = track?.song ?: "",
style = MaterialTheme.typography.headlineMedium,
maxLines = 1
)
Text(
text = track?.artist ?: "",
style = MaterialTheme.typography.bodyLarge,
color = Color(0xFF888888),
maxLines = 1
)
Spacer(modifier = Modifier.height(20.dp))
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")
}
} else {
Icon(
imageVector = Lucide.Radio,
contentDescription = null,
tint = colors.textMuted,
modifier = Modifier.size(56.dp)
)
}
}
Spacer(modifier = Modifier.height(30.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 = 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,
color = colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
}
}
Spacer(Modifier.height(20.dp))
// Живой эквалайзер — вместо прогресс-бара (эфир нельзя перематывать)
LiveEqualizer(
modifier = Modifier
.fillMaxWidth()
.height(40.dp),
playing = isPlaying,
color = colors.accent
)
Spacer(Modifier.height(24.dp))
// Управление воспроизведением
Row(
horizontalArrangement = Arrangement.spacedBy(24.dp),
verticalAlignment = Alignment.CenterVertically
) {
ControlButton(
size = 56.dp,
icon = Lucide.SkipBack,
onClick = onPrevious
// Кнопка избранного
val heartTint by animateColorAsState(
targetValue = if (isFavorite) colors.accent else colors.textSecondary,
animationSpec = tween(Motion.Medium),
label = "heartTint"
)
ControlButton(
size = 72.dp,
isPlay = true,
isPlaying = isPlaying,
onClick = onPlayPause
)
ControlButton(
size = 56.dp,
icon = Lucide.SkipForward,
onClick = onNext
PlayerIconBtn(size = 44.dp) {
IconButton(
onClick = {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
onToggleFavorite()
},
modifier = Modifier.size(44.dp)
) {
Icon(Lucide.Heart, "Избранное", tint = heartTint, modifier = Modifier.size(22.dp))
}
}
// Кнопка «предыдущая станция»
PlayerIconBtn(size = 48.dp) {
IconButton(onClick = onPrevious, modifier = Modifier.size(48.dp)) {
Icon(Lucide.SkipBack, "Предыдущая", tint = colors.textPrimary, modifier = Modifier.size(24.dp))
}
}
// Главная кнопка play/pause
val playInteraction = remember { MutableInteractionSource() }
Box(
modifier = Modifier
.size(68.dp)
.clip(CircleShape)
.background(colors.accent)
.pressScale(interactionSource = playInteraction)
.clickable(interactionSource = playInteraction, indication = null) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
onPlayPause()
},
contentAlignment = Alignment.Center
) {
Crossfade(
targetState = isPlaying,
animationSpec = tween(Motion.Fast),
label = "playPause"
) { playing ->
Icon(
imageVector = if (playing) Lucide.Pause else Lucide.Play,
contentDescription = if (playing) "Пауза" else "Воспроизвести",
tint = colors.bgBase,
modifier = Modifier.size(30.dp)
)
}
}
// Кнопка «следующая станция»
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(
imageVector = if (recording) Lucide.MicOff else Lucide.Mic,
contentDescription = if (recording) "Остановить запись" else "Запись",
tint = recordTint,
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 DeeplinkButton(
service: DeeplinkService,
onClick: () -> Unit,
modifier: Modifier = Modifier
private fun PlayerIconBtn(
size: Dp,
content: @Composable () -> Unit
) {
Box(
modifier = modifier
.size(48.dp)
.clip(RoundedCornerShape(12.dp))
.background(Color(0xFF2A2A2A))
.clickable(onClick = onClick),
contentAlignment = Alignment.Center
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 = service.displayName.take(2),
text = serviceShortName(service),
style = MaterialTheme.typography.labelSmall,
color = Color(0xFF888888)
color = colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
@Composable
private fun ControlButton(
size: androidx.compose.ui.unit.Dp,
isPlay: Boolean = false,
isPlaying: Boolean = false,
icon: androidx.compose.ui.graphics.vector.ImageVector? = null,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.size(size)
.clip(CircleShape)
.background(if (isPlay) Color.White else Color(0xFF2A2A2A))
.clickable(onClick = onClick),
contentAlignment = Alignment.Center
) {
if (isPlay) {
Icon(
imageVector = if (isPlaying) Lucide.Pause else Lucide.Play,
contentDescription = if (isPlaying) "Pause" else "Play",
tint = Color.Black,
modifier = Modifier.size(size * 0.4f)
)
} else if (icon != null) {
Icon(
imageVector = icon,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(size * 0.4f)
)
}
}
}

View File

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

View File

@@ -1,18 +1,35 @@
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.layout.*
import androidx.compose.foundation.lazy.LazyColumn
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.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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.sp
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.StationTestStatus
import com.radiola.ui.theme.Motion
import com.radiola.ui.theme.RadiolaTheme
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -33,156 +50,343 @@ fun SettingsScreen(
val currentUser by viewModel.currentUser.collectAsState()
val presets = listOf("Flat", "Rock", "Pop", "Jazz", "Bass")
var showReport by remember { mutableStateOf(false) }
val colors = RadiolaTheme.colors
Scaffold(
topBar = {
TopAppBar(
title = { Text("Настройки") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background
)
LazyColumn(
modifier = modifier
.fillMaxSize()
.padding(horizontal = 20.dp),
contentPadding = PaddingValues(bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
item {
// Двухцветный заголовок
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)
)
}
) { padding ->
LazyColumn(
modifier = modifier
.fillMaxSize()
.padding(padding),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
item {
Text("Профиль", style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(8.dp))
if (isLoggedIn && currentUser != null) {
Column {
// --- Профиль ---
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) {
Text(
text = currentUser?.email ?: "",
style = MaterialTheme.typography.bodyLarge
style = MaterialTheme.typography.titleMedium,
color = colors.textPrimary
)
Spacer(modifier = Modifier.height(4.dp))
OutlinedButton(
onClick = { viewModel.logout() },
modifier = Modifier.fillMaxWidth()
) {
Text("Выйти")
}
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(
onClick = { viewModel.logout() },
colors = ButtonDefaults.outlinedButtonColors(contentColor = colors.textSecondary),
border = androidx.compose.foundation.BorderStroke(1.dp, colors.border)
) {
Text("Выйти")
}
} else {
Button(
onClick = onNavigateToAuth,
modifier = Modifier.fillMaxWidth()
colors = ButtonDefaults.buttonColors(
containerColor = colors.accent,
contentColor = colors.bgBase
),
shape = RoundedCornerShape(10.dp)
) {
Text("Войти")
Text("Войти", fontWeight = FontWeight.SemiBold)
}
}
}
}
item {
Divider()
}
item {
Text("Таймер сна", style = MaterialTheme.typography.titleMedium)
// --- Таймер сна ---
item {
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
)
}
Slider(
value = sleepTimer.toFloat(),
onValueChange = { viewModel.setSleepTimer(it.toInt()) },
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 {
Text("Эквалайзер", style = MaterialTheme.typography.titleMedium)
SingleChoiceSegmentedButtonRow {
presets.forEach { preset ->
SegmentedButton(
selected = equalizerPreset == preset,
onClick = { viewModel.setEqualizerPreset(preset) },
shape = MaterialTheme.shapes.small
) {
Text(preset)
}
}
// --- Эквалайзер ---
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(12.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
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 {
Text("Музыкальные сервисы", style = MaterialTheme.typography.titleMedium)
Column {
DeeplinkService.entries.forEach { service ->
val checked = service.serviceId in enabledServices
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { viewModel.toggleService(service.serviceId, !checked) }
.padding(vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(service.displayName)
Switch(
checked = checked,
onCheckedChange = { viewModel.toggleService(service.serviceId, it) }
}
// --- Музыкальные сервисы ---
item {
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))
) {
DeeplinkService.entries.forEachIndexed { index, service ->
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(
modifier = Modifier
.fillMaxWidth()
.clickable { viewModel.toggleService(service.serviceId, !checked) }
.padding(horizontal = 16.dp, vertical = 14.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = service.displayName,
style = MaterialTheme.typography.titleMedium,
color = colors.textPrimary
)
Switch(
checked = checked,
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(
modifier = Modifier
.fillMaxWidth()
.clickable { viewModel.setRecordingEnabled(!isRecordingEnabled) }
.padding(vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween
.padding(horizontal = 16.dp, vertical = 14.dp),
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(
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 {
Text("Тестирование станций", style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(8.dp))
}
// --- Тестирование станций ---
item {
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(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
if (isTesting) {
Column {
LinearProgressIndicator(
progress = { if (testTotal > 0) testProgress.toFloat() / testTotal else 0f },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(4.dp))
Text("Проверено $testProgress из $testTotal")
}
Text(
text = "Проверено $testProgress из $testTotal",
style = MaterialTheme.typography.bodyMedium,
color = colors.textSecondary
)
LinearProgressIndicator(
progress = { if (testTotal > 0) testProgress.toFloat() / testTotal else 0f },
modifier = Modifier.fillMaxWidth(),
color = colors.accent,
trackColor = colors.surface2
)
} else if (testResults.isNotEmpty()) {
val ok = testResults.count { it.status == StationTestStatus.OK }
val okNoMeta = testResults.count { it.status == StationTestStatus.OK_NO_META }
val offline = testResults.count { it.status == StationTestStatus.OFFLINE }
val error = testResults.count { it.status == StationTestStatus.ERROR }
Column {
Text("Всего: ${testResults.size}")
Text("Работают + метаданные: $ok", color = Color(0xFF4CAF50))
Text("Работают без метаданных: $okNoMeta", color = Color(0xFFFF9800))
Text("Оффлайн: $offline", color = Color(0xFFFF5252))
Text("Ошибки: $error", color = Color(0xFFFF5252))
Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { showReport = true }) {
Text("Подробный отчёт")
}
OutlinedButton(onClick = { viewModel.clearTestResults() }) {
Text("Очистить")
}
Text("Всего: ${testResults.size}", color = colors.textPrimary, style = MaterialTheme.typography.bodyMedium)
Text("Работают + метаданные: $ok", color = Color(0xFF4CAF50), style = MaterialTheme.typography.bodyMedium)
Text("Работают без метаданных: $okNoMeta", color = Color(0xFFFF9800), style = MaterialTheme.typography.bodyMedium)
Text("Оффлайн: $offline", color = colors.live, style = MaterialTheme.typography.bodyMedium)
Text("Ошибки: $error", color = colors.live, style = MaterialTheme.typography.bodyMedium)
Spacer(Modifier.height(4.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = { showReport = true },
colors = ButtonDefaults.buttonColors(
containerColor = colors.accent,
contentColor = colors.bgBase
)
) {
Text("Подробный отчёт")
}
OutlinedButton(
onClick = { viewModel.clearTestResults() },
colors = ButtonDefaults.outlinedButtonColors(contentColor = colors.textSecondary),
border = androidx.compose.foundation.BorderStroke(1.dp, colors.border)
) {
Text("Очистить")
}
}
} else {
Button(
OutlinedButton(
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("Провести тестирование")
}
@@ -191,18 +395,26 @@ fun SettingsScreen(
}
}
// Диалог отчёта
if (showReport) {
AlertDialog(
onDismissRequest = { showReport = false },
title = { Text("Результаты тестирования") },
containerColor = colors.elevated,
title = {
Text(
"Результаты тестирования",
color = colors.textPrimary,
style = MaterialTheme.typography.titleLarge
)
},
text = {
LazyColumn(modifier = Modifier.heightIn(max = 400.dp)) {
items(testResults) { result ->
val color = when (result.status) {
StationTestStatus.OK -> Color(0xFF4CAF50)
StationTestStatus.OK_NO_META -> Color(0xFFFF9800)
StationTestStatus.OFFLINE -> Color(0xFFFF5252)
StationTestStatus.ERROR -> Color(0xFFFF5252)
StationTestStatus.OFFLINE -> colors.live
StationTestStatus.ERROR -> colors.live
}
Column(modifier = Modifier.padding(vertical = 4.dp)) {
Text(
@@ -219,17 +431,31 @@ fun SettingsScreen(
result.errorMessage?.let { append(" | $it") }
},
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
color = colors.textMuted
)
}
}
}
},
confirmButton = {
TextButton(onClick = { showReport = false }) {
TextButton(
onClick = { showReport = false },
colors = ButtonDefaults.textButtonColors(contentColor = colors.accent)
) {
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
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.*
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.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
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.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.ui.components.*
import com.radiola.ui.theme.RadiolaTheme
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun StationsScreen(
onStationClick: (Station) -> Unit,
@@ -28,65 +34,92 @@ fun StationsScreen(
val isLoading by viewModel.isLoading.collectAsState()
val error by viewModel.error.collectAsState()
val favoriteIds by viewModel.favoriteIds.collectAsState()
val colors = RadiolaTheme.colors
Column(modifier = modifier.fillMaxSize()) {
TopAppBar(
title = { Text("Радио") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background
)
// Двухцветный заголовок экрана
Text(
text = buildAnnotatedString {
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("Показать все")
// Поиск — всегда виден (в т.ч. когда результатов нет)
SearchBar(
query = searchQuery,
onQueryChange = viewModel::onSearchQueryChange,
modifier = Modifier.padding(horizontal = 20.dp)
)
Spacer(Modifier.height(12.dp))
// Жанры — всегда видны
if (tags.isNotEmpty()) {
FilterChips(
tags = tags,
selectedTag = selectedTag,
onTagSelected = viewModel::onTagSelected
)
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 = 16.dp, end = 16.dp, top = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
item(span = { GridItemSpan(maxLineSpan) }) {
SearchBar(
query = searchQuery,
onQueryChange = viewModel::onSearchQueryChange,
)
}
item(span = { GridItemSpan(maxLineSpan) }) {
FilterChips(
tags = tags,
selectedTag = selectedTag,
onTagSelected = viewModel::onTagSelected,
modifier = Modifier.padding(vertical = 8.dp)
)
}
items(stations, key = { it.id }) { station ->
StationCard(
station = station,
isFavorite = favoriteIds.contains(station.id),
onClick = { onStationClick(station) },
onFavoriteClick = { viewModel.toggleFavorite(station) }
)
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 ->
StationCard(
station = station,
isFavorite = favoriteIds.contains(station.id),
onClick = { onStationClick(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
val Primary = Color(0xFF6200EE)
val PrimaryDark = Color(0xFF3700B3)
val Secondary = Color(0xFF03DAC6)
val Background = Color(0xFF121212)
val Surface = Color(0xFF1E1E1E)
val OnPrimary = Color.White
val OnSecondary = Color.Black
val OnBackground = Color.White
val OnSurface = Color.White
// Базовая палитра radiOLA (тёмно-зелёная тема)
val BgBase = Color(0xFF0C1410)
val BgSurface = Color(0xFF16201A)
val BgSurface2 = Color(0xFF1E2A23)
val BgElevated = Color(0xFF243029)
val Accent = Color(0xFFA8E05F)
val AccentDim = Color(0xFF6FA53C)
val TextPrimary = Color(0xFFFFFFFF)
val TextSecondary = Color(0xFF8FA396)
val TextMuted = Color(0xFF5C6E63)
val BorderColor = Color(0xFF2A352E)
val LiveRed = Color(0xFFFF5252)
val LiveRedSoft = Color(0xFFFF6B6B)
// Брендовый градиент (иконка приложения, акцентные элементы)
val BrandGradientStart = Color(0xFFC2F25B)
val BrandGradientEnd = Color(0xFF6FA53C)

View File

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

View File

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

View File

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

View File

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

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>