Files
radiola-android/app/src/main/java/com/radiola/ui/stations/StationsScreen.kt
nk 87dca7a6df feat(filters): чипы растворяются под кнопкой-категорией (fade left edge)
Кнопка-категории теперь ПОВЕРХ чипов (Box-оверлей), чипы идут во всю ширину с
отступом слева под кнопку. У левого края — затухание прозрачности (Modifier.
fadingStartEdge: graphicsLayer Offscreen + horizontalGradient BlendMode.DstIn), так
чипы при прокрутке влево красиво уплывают под кнопку и растворяются, а не обрезаются.
FilterChips/GenreSelector получили параметр contentPadding. Экраны Радио и Чарты.
2026-06-07 17:33:35 +03:00

208 lines
10 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)
) {
Box(modifier = Modifier.fillMaxWidth().height(44.dp)) {
// Чипы во всю ширину, но с отступом слева под кнопку; у левого
// края — затухание прозрачности (чипы «уплывают» под кнопку).
FilterChips(
tags = tags,
selectedTag = selectedTag,
onTagSelected = viewModel::onTagSelected,
contentPadding = PaddingValues(start = 66.dp, end = 16.dp),
modifier = Modifier
.fillMaxWidth()
.align(Alignment.Center)
.fadingStartEdge(60.dp)
)
// Кнопка-категории — поверх чипов, слева.
CategoryPicker(
title = "Категории",
items = tags,
selected = selectedTag,
onSelect = viewModel::onTagSelected,
modifier = Modifier.align(Alignment.CenterStart).padding(start = 16.dp)
)
}
}
}
}
}
}