- боковой nav-rail слева вместо нижнего бара в альбоме (SideNavRail) - мини-плеер уезжает под контент в альбомной раскладке - плеер эфира: двухпанельный (обложка слева, инфо/эквалайзер/контролы справа) - плеер записи: слева управление, справа прокручиваемый список треков - сетки станций и избранного: 4 колонки в альбоме вместо 2 - хелпер isLandscape() через LocalConfiguration Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
191 lines
9.0 KiB
Kotlin
191 lines
9.0 KiB
Kotlin
package com.radiola.ui.stations
|
||
|
||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||
import androidx.compose.foundation.background
|
||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||
import androidx.compose.foundation.layout.*
|
||
import androidx.compose.foundation.lazy.grid.GridCells
|
||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||
import androidx.compose.foundation.lazy.grid.items
|
||
import androidx.compose.material3.*
|
||
import androidx.compose.runtime.*
|
||
import androidx.compose.ui.Alignment
|
||
import androidx.compose.ui.Modifier
|
||
import androidx.compose.ui.graphics.Brush
|
||
import androidx.compose.ui.graphics.Color
|
||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||
import androidx.compose.ui.input.pointer.pointerInput
|
||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||
import androidx.compose.ui.text.SpanStyle
|
||
import androidx.compose.ui.text.buildAnnotatedString
|
||
import androidx.compose.ui.text.withStyle
|
||
import androidx.compose.ui.unit.dp
|
||
import androidx.hilt.navigation.compose.hiltViewModel
|
||
import com.composables.icons.lucide.Lucide
|
||
import com.composables.icons.lucide.Radio
|
||
import com.radiola.domain.model.Station
|
||
import com.radiola.ui.components.*
|
||
import com.radiola.ui.theme.RadiolaTheme
|
||
|
||
@OptIn(ExperimentalFoundationApi::class)
|
||
@Composable
|
||
fun StationsScreen(
|
||
onStationClick: (Station) -> Unit,
|
||
modifier: Modifier = Modifier,
|
||
viewModel: StationsViewModel = hiltViewModel()
|
||
) {
|
||
val stations by viewModel.stations.collectAsState()
|
||
val tags by viewModel.tags.collectAsState()
|
||
val searchQuery by viewModel.searchQuery.collectAsState()
|
||
val selectedTag by viewModel.selectedTag.collectAsState()
|
||
val isLoading by viewModel.isLoading.collectAsState()
|
||
val error by viewModel.error.collectAsState()
|
||
val favoriteIds by viewModel.favoriteIds.collectAsState()
|
||
val nowPlaying by viewModel.nowPlaying.collectAsState()
|
||
val playingStationId by viewModel.playingStationId.collectAsState()
|
||
val isPlaying by viewModel.isPlaying.collectAsState()
|
||
val colors = RadiolaTheme.colors
|
||
val haptics = LocalHapticFeedback.current
|
||
// В альбоме шире окно — больше колонок, иначе карточки растягиваются.
|
||
val gridColumns = if (com.radiola.ui.util.isLandscape()) 4 else 2
|
||
|
||
// Полный порядок фильтров: «Все» (null) + жанры. Свайп листает по нему.
|
||
val orderedTags = remember(tags) { listOf<String?>(null) + tags }
|
||
fun switchTag(forward: Boolean) {
|
||
if (orderedTags.size <= 1) return
|
||
val idx = orderedTags.indexOf(selectedTag).coerceAtLeast(0)
|
||
val newIdx = idx + if (forward) 1 else -1
|
||
if (newIdx in orderedTags.indices) {
|
||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||
viewModel.onTagSelected(orderedTags[newIdx])
|
||
}
|
||
}
|
||
|
||
Column(modifier = modifier.fillMaxSize()) {
|
||
// Двухцветный заголовок экрана
|
||
Text(
|
||
text = buildAnnotatedString {
|
||
withStyle(SpanStyle(color = colors.textPrimary)) { append("Выберите ") }
|
||
withStyle(SpanStyle(color = colors.accent)) { append("радиостанцию") }
|
||
},
|
||
style = MaterialTheme.typography.headlineLarge,
|
||
modifier = Modifier.padding(start = 20.dp, end = 20.dp, top = 20.dp, bottom = 16.dp)
|
||
)
|
||
|
||
// Поиск — всегда виден (в т.ч. когда результатов нет)
|
||
SearchBar(
|
||
query = searchQuery,
|
||
onQueryChange = viewModel::onSearchQueryChange,
|
||
modifier = Modifier.padding(horizontal = 20.dp)
|
||
)
|
||
Spacer(Modifier.height(12.dp))
|
||
|
||
// Область результатов — единственная прокручиваемая зона.
|
||
// Горизонтальный свайп листает фильтры-чипы (вертикаль остаётся у грида).
|
||
Box(
|
||
modifier = Modifier
|
||
.weight(1f)
|
||
.fillMaxWidth()
|
||
.pointerInput(orderedTags, selectedTag) {
|
||
var totalDx = 0f
|
||
detectHorizontalDragGestures(
|
||
onDragStart = { totalDx = 0f },
|
||
onDragEnd = {
|
||
val threshold = 56.dp.toPx()
|
||
when {
|
||
totalDx <= -threshold -> switchTag(forward = true)
|
||
totalDx >= threshold -> switchTag(forward = false)
|
||
}
|
||
}
|
||
) { _, dragAmount -> totalDx += dragAmount }
|
||
}
|
||
) {
|
||
when {
|
||
isLoading && stations.isEmpty() -> {
|
||
CircularProgressIndicator(
|
||
color = colors.accent,
|
||
modifier = Modifier.align(Alignment.Center)
|
||
)
|
||
}
|
||
|
||
stations.isEmpty() -> {
|
||
Column(
|
||
modifier = Modifier.fillMaxSize(),
|
||
horizontalAlignment = Alignment.CenterHorizontally,
|
||
verticalArrangement = Arrangement.Center
|
||
) {
|
||
EmptyState(
|
||
message = error
|
||
?: if (searchQuery.isNotBlank() || selectedTag != null)
|
||
"Ничего не найдено" else "Станции не найдены",
|
||
icon = Lucide.Radio,
|
||
modifier = Modifier.wrapContentSize()
|
||
)
|
||
if (searchQuery.isNotBlank() || selectedTag != null) {
|
||
Spacer(Modifier.height(12.dp))
|
||
OutlinedButton(
|
||
onClick = {
|
||
viewModel.onSearchQueryChange("")
|
||
viewModel.onTagSelected(null)
|
||
},
|
||
colors = ButtonDefaults.outlinedButtonColors(contentColor = colors.accent),
|
||
border = androidx.compose.foundation.BorderStroke(1.dp, colors.accent)
|
||
) {
|
||
Text("Сбросить фильтры")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
else -> LazyVerticalGrid(
|
||
columns = GridCells.Fixed(gridColumns),
|
||
modifier = Modifier.fillMaxSize(),
|
||
// top = высота чипов: грид уходит ПОД них, свечение верхнего ряда
|
||
// не обрезается и проступает за чипами.
|
||
contentPadding = PaddingValues(start = 20.dp, end = 20.dp, top = 54.dp, bottom = 20.dp),
|
||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||
) {
|
||
items(stations, key = { it.id }) { station ->
|
||
StationCard(
|
||
station = station,
|
||
isFavorite = favoriteIds.contains(station.id),
|
||
onClick = { onStationClick(station) },
|
||
onFavoriteClick = { viewModel.toggleFavorite(station) },
|
||
nowTrack = nowPlaying[station.id],
|
||
isCurrent = station.id == playingStationId,
|
||
isPlaying = isPlaying,
|
||
modifier = Modifier.animateItemPlacement()
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Чипы-фильтры поверх грида. Фон-градиент: вверху непрозрачный
|
||
// (маскирует прокручиваемые карточки), книзу прозрачный — свечение
|
||
// верхнего ряда станций проступает ИЗ-ПОД чипов.
|
||
if (tags.isNotEmpty()) {
|
||
Box(
|
||
modifier = Modifier
|
||
.align(Alignment.TopStart)
|
||
.fillMaxWidth()
|
||
.background(
|
||
Brush.verticalGradient(
|
||
0f to colors.bgBase,
|
||
0.55f to colors.bgBase,
|
||
1f to Color.Transparent
|
||
)
|
||
)
|
||
.padding(top = 2.dp, bottom = 12.dp)
|
||
) {
|
||
FilterChips(
|
||
tags = tags,
|
||
selectedTag = selectedTag,
|
||
onTagSelected = viewModel::onTagSelected
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|