Files
radiola-android/app/src/main/java/com/radiola/ui/stations/StationsScreen.kt
nk 06cb6c16f1 feat(orientation): полноценная поддержка альбомной ориентации
- боковой nav-rail слева вместо нижнего бара в альбоме (SideNavRail)
- мини-плеер уезжает под контент в альбомной раскладке
- плеер эфира: двухпанельный (обложка слева, инфо/эквалайзер/контролы справа)
- плеер записи: слева управление, справа прокручиваемый список треков
- сетки станций и избранного: 4 колонки в альбоме вместо 2
- хелпер isLandscape() через LocalConfiguration

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 20:19:47 +03:00

191 lines
9.0 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
)
}
}
}
}
}