feat(ui): рестайл всех экранов + плеер + официальные mono-логотипы сервисов

- экраны (Станции/Избранное/История/Записи/Настройки/Вход): двухцветные
  заголовки, токены темы, EmptyState, анимации появления и перестановки
- AuthScreen: брендовый локап (AppMark + RadiolaWordmark)
- PlayerBottomSheet: живой эфир — LiveEqualizer вместо перемотки,
  Crossfade трека и play/pause, pressScale, анимация избранного/записи
- кнопки музыкальных сервисов: монохромные официальные логотипы
  (vector drawable из Simple Icons CC0 + Yandex), маппинг serviceLogoRes
- DeeplinkBottomSheet: сетка сервисов с логотипами
This commit is contained in:
nk
2026-06-02 21:31:16 +03:00
parent d652dc399a
commit f604ad42e8
16 changed files with 1195 additions and 499 deletions

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

@@ -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,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,7 +1,12 @@
package com.radiola.ui.player
import android.util.Log
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.lazy.LazyRow
import androidx.compose.foundation.lazy.items
@@ -12,27 +17,34 @@ 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.platform.LocalContext
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
@Composable
fun PlayerBottomSheet(
@@ -51,176 +63,250 @@ fun PlayerBottomSheet(
) {
val context = LocalContext.current
val enabledServices by viewModel.enabledServices.collectAsState()
val colors = RadiolaTheme.colors
Column(
modifier = modifier
.fillMaxWidth()
.background(
brush = Brush.verticalGradient(
colors = listOf(Color(0xFF1a1a2e), Color(0xFF121212))
)
)
.padding(24.dp),
.background(colors.bgBase)
.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 = 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,
overflow = TextOverflow.Ellipsis,
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
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 = 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, onClick = 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()
}
}
/** Монохромная кнопка сервиса для поиска трека (без официальных логотипов). */
@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 = service.displayName.take(8),
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,5 +1,9 @@
package com.radiola.ui.stations
import androidx.compose.animation.AnimatedVisibility
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.GridItemSpan
@@ -9,12 +13,16 @@ 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.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,43 +36,63 @@ 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
)
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)
)
when {
isLoading && stations.isEmpty() -> Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
CircularProgressIndicator(color = colors.accent)
}
stations.isEmpty() -> Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp)
stations.isEmpty() -> {
AnimatedVisibility(
visible = true,
enter = fadeIn() + slideInVertically()
) {
EmptyState(message = error ?: "Станции не найдены")
if (selectedTag != null) {
Button(onClick = { viewModel.onTagSelected(null) }) {
Text("Показать все")
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
EmptyState(message = error ?: "Станции не найдены")
if (selectedTag != null) {
OutlinedButton(
onClick = { 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)
contentPadding = PaddingValues(bottom = 16.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
item(span = { GridItemSpan(maxLineSpan) }) {
SearchBar(
@@ -85,7 +113,8 @@ fun StationsScreen(
station = station,
isFavorite = favoriteIds.contains(station.id),
onClick = { onStationClick(station) },
onFavoriteClick = { viewModel.toggleFavorite(station) }
onFavoriteClick = { viewModel.toggleFavorite(station) },
modifier = Modifier.animateItemPlacement()
)
}
}

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>