Compare commits

...

4 Commits

Author SHA1 Message Date
nk
a3f3494da2 polish(ui): плавные обложки, бегущая строка длинных названий, тактильный отклик
- Coil crossfade для всех обложек (Images.crossfadeModel) — без «моргания» при загрузке
- basicMarquee для длинных названий трека (плеер и мини-плеер) вместо обрезки
- haptic feedback на play/pause и добавление в избранное (плеер, мини-плеер, карточка)
2026-06-02 22:33:33 +03:00
nk
8a951dd4c5 fix(player): отображение трека и обложки — объединение REST и socket now-playing
REST-поллинг (refreshNowPlaying -> api.getNowPlaying, 200 OK) писал данные в
_nowPlaying, который нигде не читался; getNowPlaying() брал только сокет (пустой).
Теперь getNowPlaying/getAllNowPlaying объединяют оба источника (socket ?: REST),
поэтому название трека, обложка и deep-link сервисов работают.
2026-06-02 22:23:37 +03:00
nk
58f735823e fix(ui): отступы под системную навигацию + подписи сервисов + краш навбара
- навбар и мини-плеер: navigationBarsPadding — не налезают на системные кнопки
- плеер: navigationBarsPadding снизу, ряд сервисов не уходит под системную панель
- подписи сервисов без обрезки слов (Яндекс / ВК Музыка / YT Music и т.д.)
- фикс NPE при холодном старте: навбар обращается к NavDestinations напрямую,
  не к companion-списку (порядок инициализации Kotlin)
2026-06-02 22:13:10 +03:00
nk
9e9f4c8009 fix(ui): единый скролл на экране станций + всегда видимый навбар
- StationsScreen: закреплённые заголовок/поиск/жанры, одна прокручиваемая
  сетка станций; поиск и фильтры больше не исчезают при пустом результате
  (+ кнопка «Сбросить фильтры»)
- таб-бар показывается без обязательного входа (скрыт только на экране входа)
- старт сразу со «Станций» — авторизация необязательна, вход из Настроек
2026-06-02 21:58:11 +03:00
9 changed files with 182 additions and 93 deletions

View File

@@ -9,9 +9,11 @@ import androidx.compose.foundation.layout.*
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.radiola.data.local.TokenDataStore import com.radiola.data.local.TokenDataStore
@@ -65,23 +67,30 @@ class MainActivity : ComponentActivity() {
val isRecording by playerViewModel.isRecording.collectAsState() val isRecording by playerViewModel.isRecording.collectAsState()
val isLoggedIn by tokenDataStore.isLoggedIn.collectAsState(initial = false) val isLoggedIn by tokenDataStore.isLoggedIn.collectAsState(initial = false)
val startDestination = remember(isLoggedIn) { // Авторизация необязательна — всегда стартуем со станций.
if (isLoggedIn) NavDestinations.Stations.route else NavDestinations.Auth.route // Вход доступен из Настроек.
} val startDestination = NavDestinations.Stations.route
val currentRoute = navController
.currentBackStackEntryAsState().value?.destination?.route
val showChrome = currentRoute != NavDestinations.Auth.route
Scaffold( Scaffold(
bottomBar = { bottomBar = {
Column { if (showChrome) {
if (currentStation != null) { Column(Modifier.navigationBarsPadding()) {
MiniPlayer( if (currentStation != null) {
stationName = currentStation!!.name, MiniPlayer(
track = currentTrack, stationName = currentStation!!.name,
isPlaying = isPlaying, track = currentTrack,
onClick = { showPlayer = true }, isPlaying = isPlaying,
onPlayPause = { playerViewModel.togglePlayPause() } onClick = { showPlayer = true },
) onPlayPause = { playerViewModel.togglePlayPause() }
} )
if (isLoggedIn) { Spacer(Modifier.height(8.dp))
}
// Навигация доступна и без входа — приложением можно
// пользоваться анонимно.
BottomNavBar(navController) BottomNavBar(navController)
} }
} }

View File

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

View File

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

View File

@@ -2,6 +2,10 @@ package com.radiola.ui.components
import androidx.compose.animation.Crossfade import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.basicMarquee
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@@ -29,6 +33,7 @@ import com.radiola.ui.theme.Motion
import com.radiola.ui.theme.RadiolaTheme import com.radiola.ui.theme.RadiolaTheme
import com.radiola.ui.theme.pressScale import com.radiola.ui.theme.pressScale
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun MiniPlayer( fun MiniPlayer(
stationName: String, stationName: String,
@@ -39,6 +44,7 @@ fun MiniPlayer(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val colors = RadiolaTheme.colors val colors = RadiolaTheme.colors
val haptics = LocalHapticFeedback.current
Row( Row(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
@@ -59,7 +65,7 @@ fun MiniPlayer(
) { ) {
if (track?.coverUrl != null) { if (track?.coverUrl != null) {
AsyncImage( AsyncImage(
model = track.coverUrl, model = crossfadeModel(track.coverUrl),
contentDescription = null, contentDescription = null,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop contentScale = ContentScale.Crop
@@ -83,7 +89,7 @@ fun MiniPlayer(
style = androidx.compose.material3.MaterialTheme.typography.titleMedium, style = androidx.compose.material3.MaterialTheme.typography.titleMedium,
color = colors.textPrimary, color = colors.textPrimary,
maxLines = 1, maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis modifier = Modifier.basicMarquee()
) )
} }
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
@@ -95,9 +101,11 @@ fun MiniPlayer(
.background(colors.accent) .background(colors.accent)
.clickable( .clickable(
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
indication = null, indication = null
onClick = onPlayPause ) {
), haptics.performHapticFeedback(HapticFeedbackType.LongPress)
onPlayPause()
},
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Crossfade(targetState = isPlaying, animationSpec = tween(Motion.Fast), label = "miniPlay") { playing -> Crossfade(targetState = isPlaying, animationSpec = tween(Motion.Fast), label = "miniPlay") { playing ->

View File

@@ -15,7 +15,9 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage import coil.compose.AsyncImage
@@ -36,6 +38,7 @@ fun StationCard(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val colors = RadiolaTheme.colors val colors = RadiolaTheme.colors
val haptics = LocalHapticFeedback.current
val interaction = remember { MutableInteractionSource() } val interaction = remember { MutableInteractionSource() }
val heartTint by animateColorAsState( val heartTint by animateColorAsState(
targetValue = if (isFavorite) colors.accent else colors.textPrimary, targetValue = if (isFavorite) colors.accent else colors.textPrimary,
@@ -57,7 +60,7 @@ fun StationCard(
) { ) {
if (!station.coverUrl.isNullOrBlank()) { if (!station.coverUrl.isNullOrBlank()) {
AsyncImage( AsyncImage(
model = station.coverUrl, model = crossfadeModel(station.coverUrl),
contentDescription = station.name, contentDescription = station.name,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop contentScale = ContentScale.Crop
@@ -77,7 +80,10 @@ fun StationCard(
.size(32.dp) .size(32.dp)
.clip(RoundedCornerShape(16.dp)) .clip(RoundedCornerShape(16.dp))
.background(androidx.compose.ui.graphics.Color.Black.copy(alpha = 0.4f)) .background(androidx.compose.ui.graphics.Color.Black.copy(alpha = 0.4f))
.clickable(onClick = onFavoriteClick), .clickable {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
onFavoriteClick()
},
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon( Icon(

View File

@@ -51,7 +51,7 @@ fun TrackListItem(
) { ) {
if (!track.coverUrl.isNullOrBlank()) { if (!track.coverUrl.isNullOrBlank()) {
AsyncImage( AsyncImage(
model = track.coverUrl, model = crossfadeModel(track.coverUrl),
contentDescription = null, contentDescription = null,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop contentScale = ContentScale.Crop

View File

@@ -37,7 +37,15 @@ import com.radiola.ui.theme.RadiolaTheme
fun BottomNavBar(navController: NavController) { fun BottomNavBar(navController: NavController) {
val colors = RadiolaTheme.colors val colors = RadiolaTheme.colors
val currentRoute = navController.currentBackStackEntryAsState().value?.destination?.route val currentRoute = navController.currentBackStackEntryAsState().value?.destination?.route
val items = NavDestinations.items.filter { it.showInBottomBar } // Обращаемся к объектам напрямую: companion-список NavDestinations.items
// при холодном старте может содержать null (порядок инициализации Kotlin).
val items = listOf(
NavDestinations.Stations,
NavDestinations.Favorites,
NavDestinations.History,
NavDestinations.Recordings,
NavDestinations.Settings
)
Row( Row(
modifier = Modifier modifier = Modifier

View File

@@ -1,6 +1,8 @@
package com.radiola.ui.player package com.radiola.ui.player
import android.util.Log import android.util.Log
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.basicMarquee
import androidx.compose.animation.Crossfade import androidx.compose.animation.Crossfade
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
@@ -8,6 +10,7 @@ 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.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
@@ -19,7 +22,9 @@ 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.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
@@ -46,6 +51,7 @@ import com.radiola.ui.theme.Motion
import com.radiola.ui.theme.RadiolaTheme import com.radiola.ui.theme.RadiolaTheme
import com.radiola.ui.theme.pressScale import com.radiola.ui.theme.pressScale
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun PlayerBottomSheet( fun PlayerBottomSheet(
station: Station?, station: Station?,
@@ -64,11 +70,13 @@ fun PlayerBottomSheet(
val context = LocalContext.current val context = LocalContext.current
val enabledServices by viewModel.enabledServices.collectAsState() val enabledServices by viewModel.enabledServices.collectAsState()
val colors = RadiolaTheme.colors val colors = RadiolaTheme.colors
val haptics = LocalHapticFeedback.current
Column( Column(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.background(colors.bgBase) .background(colors.bgBase)
.navigationBarsPadding()
.padding(horizontal = 24.dp, vertical = 20.dp), .padding(horizontal = 24.dp, vertical = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
@@ -93,7 +101,7 @@ fun PlayerBottomSheet(
val coverModel = track?.coverUrl ?: station?.coverUrl val coverModel = track?.coverUrl ?: station?.coverUrl
if (!coverModel.isNullOrBlank()) { if (!coverModel.isNullOrBlank()) {
AsyncImage( AsyncImage(
model = coverModel, model = com.radiola.ui.components.crossfadeModel(coverModel),
contentDescription = station?.name, contentDescription = station?.name,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop contentScale = ContentScale.Crop
@@ -121,8 +129,8 @@ fun PlayerBottomSheet(
style = MaterialTheme.typography.headlineLarge, style = MaterialTheme.typography.headlineLarge,
color = colors.textPrimary, color = colors.textPrimary,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, textAlign = androidx.compose.ui.text.style.TextAlign.Center,
textAlign = androidx.compose.ui.text.style.TextAlign.Center modifier = Modifier.basicMarquee()
) )
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
Text( Text(
@@ -159,7 +167,13 @@ fun PlayerBottomSheet(
label = "heartTint" label = "heartTint"
) )
PlayerIconBtn(size = 44.dp) { PlayerIconBtn(size = 44.dp) {
IconButton(onClick = onToggleFavorite, modifier = Modifier.size(44.dp)) { IconButton(
onClick = {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
onToggleFavorite()
},
modifier = Modifier.size(44.dp)
) {
Icon(Lucide.Heart, "Избранное", tint = heartTint, modifier = Modifier.size(22.dp)) Icon(Lucide.Heart, "Избранное", tint = heartTint, modifier = Modifier.size(22.dp))
} }
} }
@@ -179,7 +193,10 @@ fun PlayerBottomSheet(
.clip(CircleShape) .clip(CircleShape)
.background(colors.accent) .background(colors.accent)
.pressScale(interactionSource = playInteraction) .pressScale(interactionSource = playInteraction)
.clickable(interactionSource = playInteraction, indication = null, onClick = onPlayPause), .clickable(interactionSource = playInteraction, indication = null) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
onPlayPause()
},
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Crossfade( Crossfade(
@@ -262,7 +279,20 @@ private fun PlayerIconBtn(
} }
} }
/** Монохромная кнопка сервиса для поиска трека (без официальных логотипов). */ /** Короткая подпись сервиса под кнопкой (без обрезки слов). */
private fun serviceShortName(service: DeeplinkService): String = when (service.serviceId) {
"yandex" -> "Яндекс"
"vk" -> "ВК Музыка"
"boom" -> "BOOM"
"spotify" -> "Spotify"
"apple" -> "Apple Music"
"youtube" -> "YT Music"
"tidal" -> "Tidal"
"deezer" -> "Deezer"
else -> service.displayName
}
/** Монохромная кнопка сервиса для поиска трека. */
@Composable @Composable
private fun ServiceDeeplinkBtn( private fun ServiceDeeplinkBtn(
service: DeeplinkService, service: DeeplinkService,
@@ -302,7 +332,7 @@ private fun ServiceDeeplinkBtn(
} }
} }
Text( Text(
text = service.displayName.take(8), text = serviceShortName(service),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = colors.textSecondary, color = colors.textSecondary,
maxLines = 1, maxLines = 1,

View File

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