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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,9 @@
package com.radiola.ui.stations 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.layout.*
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.GridItemSpan
@@ -9,12 +13,16 @@ import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.radiola.domain.model.Station import com.radiola.domain.model.Station
import com.radiola.ui.components.* import com.radiola.ui.components.*
import com.radiola.ui.theme.RadiolaTheme
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun StationsScreen( fun StationsScreen(
onStationClick: (Station) -> Unit, onStationClick: (Station) -> Unit,
@@ -28,43 +36,63 @@ fun StationsScreen(
val isLoading by viewModel.isLoading.collectAsState() val isLoading by viewModel.isLoading.collectAsState()
val error by viewModel.error.collectAsState() val error by viewModel.error.collectAsState()
val favoriteIds by viewModel.favoriteIds.collectAsState() val favoriteIds by viewModel.favoriteIds.collectAsState()
val colors = RadiolaTheme.colors
Column(modifier = modifier.fillMaxSize()) { Column(
TopAppBar( modifier = modifier
title = { Text("Радио") }, .fillMaxSize()
colors = TopAppBarDefaults.topAppBarColors( .padding(horizontal = 20.dp)
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(top = 20.dp, bottom = 16.dp)
) )
when { when {
isLoading && stations.isEmpty() -> Box( isLoading && stations.isEmpty() -> Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
CircularProgressIndicator() CircularProgressIndicator(color = colors.accent)
} }
stations.isEmpty() -> Box(
modifier = Modifier.fillMaxSize(), stations.isEmpty() -> {
contentAlignment = Alignment.Center AnimatedVisibility(
visible = true,
enter = fadeIn() + slideInVertically()
) { ) {
Column( Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
EmptyState(message = error ?: "Станции не найдены") EmptyState(message = error ?: "Станции не найдены")
if (selectedTag != null) { if (selectedTag != null) {
Button(onClick = { viewModel.onTagSelected(null) }) { OutlinedButton(
onClick = { viewModel.onTagSelected(null) },
colors = ButtonDefaults.outlinedButtonColors(
contentColor = colors.accent
),
border = androidx.compose.foundation.BorderStroke(1.dp, colors.accent)
) {
Text("Показать все") Text("Показать все")
} }
} }
} }
} }
}
else -> LazyVerticalGrid( else -> LazyVerticalGrid(
columns = GridCells.Fixed(2), columns = GridCells.Fixed(2),
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp), contentPadding = PaddingValues(bottom = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(14.dp)
) { ) {
item(span = { GridItemSpan(maxLineSpan) }) { item(span = { GridItemSpan(maxLineSpan) }) {
SearchBar( SearchBar(
@@ -85,7 +113,8 @@ fun StationsScreen(
station = station, station = station,
isFavorite = favoriteIds.contains(station.id), isFavorite = favoriteIds.contains(station.id),
onClick = { onStationClick(station) }, onClick = { onStationClick(station) },
onFavoriteClick = { viewModel.toggleFavorite(station) } onFavoriteClick = { viewModel.toggleFavorite(station) },
modifier = Modifier.animateItemPlacement()
) )
} }
} }

View File

@@ -0,0 +1,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>