feat(filters): быстрый выбор категории + очистка поиска

- Кнопка-«категории» (круглая, акцентная рамка, иконка SlidersHorizontal) СЛЕВА от
  чипа «Все» — на экранах Радио и Чарты. Открывает шторку со списком всех категорий
  (Радио — жанры, Чарты — стили) + поиск, чтобы не листать чипы. CategoryPicker —
  переиспользуемый компонент с поиском и отметкой выбранного.
- SearchBar: анимированная кнопка очистки (X, scale+fade появление, haptic) при
  непустом запросе.
This commit is contained in:
nk
2026-06-07 17:25:12 +03:00
parent 645c2f14db
commit 78282e97ca
4 changed files with 258 additions and 11 deletions

View File

@@ -44,6 +44,7 @@ import com.radiola.domain.model.ChartTrend
import com.radiola.domain.model.DeeplinkService
import com.radiola.domain.model.StatPoint
import com.radiola.domain.model.TrackStats
import com.radiola.ui.components.CategoryPicker
import com.radiola.ui.components.EmptyState
import com.radiola.ui.components.PopularityChart
import com.radiola.ui.components.crossfadeModel
@@ -101,12 +102,22 @@ fun ChartsScreen(
// Фильтр по жанру (если бэкенд уже накопил жанры)
if (genres.isNotEmpty()) {
Spacer(Modifier.height(10.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
CategoryPicker(
title = "Стиль музыки",
items = genres,
selected = selectedGenre,
onSelect = viewModel::selectGenre,
modifier = Modifier.padding(start = 20.dp)
)
GenreSelector(
genres = genres,
selected = selectedGenre,
onSelect = viewModel::selectGenre
onSelect = viewModel::selectGenre,
modifier = Modifier.weight(1f)
)
}
}
Spacer(Modifier.height(12.dp))
@@ -213,9 +224,11 @@ private fun PeriodChip(label: String, selected: Boolean, onClick: () -> Unit) {
private fun GenreSelector(
genres: List<String>,
selected: String?,
onSelect: (String?) -> Unit
onSelect: (String?) -> Unit,
modifier: Modifier = Modifier
) {
LazyRow(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(9.dp),
contentPadding = PaddingValues(horizontal = 20.dp)
) {

View File

@@ -0,0 +1,183 @@
package com.radiola.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.composables.icons.lucide.Check
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.SlidersHorizontal
import com.radiola.ui.theme.RadiolaTheme
/**
* Кнопка-«быстрый выбор категории» рядом с чипами: круглая (визуально отличается от
* чипов-пилюль), по нажатию открывает шторку со ПОЛНЫМ списком категорий + поиском,
* чтобы не листать чипы. Drop-in: сам держит состояние шторки.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CategoryPicker(
title: String,
items: List<String>,
selected: String?,
onSelect: (String?) -> Unit,
modifier: Modifier = Modifier,
allLabel: String = "Все"
) {
val colors = RadiolaTheme.colors
var show by remember { mutableStateOf(false) }
Box(
modifier = modifier
.size(38.dp)
.clip(CircleShape)
.background(colors.surface2)
.border(1.5.dp, colors.accent, CircleShape)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = { show = true }
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Lucide.SlidersHorizontal,
contentDescription = title,
tint = colors.accent,
modifier = Modifier.size(18.dp)
)
}
if (show) {
CategorySheet(
title = title,
items = items,
selected = selected,
allLabel = allLabel,
onSelect = onSelect,
onDismiss = { show = false }
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CategorySheet(
title: String,
items: List<String>,
selected: String?,
allLabel: String,
onSelect: (String?) -> Unit,
onDismiss: () -> Unit
) {
val colors = RadiolaTheme.colors
var query by remember { mutableStateOf("") }
val filtered = remember(items, query) {
val q = query.trim()
if (q.isBlank()) items else items.filter { it.contains(q, ignoreCase = true) }
}
ModalBottomSheet(
onDismissRequest = onDismiss,
containerColor = colors.elevated,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
) {
Column(
modifier = Modifier
.padding(horizontal = 20.dp)
.padding(bottom = 24.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.headlineSmall,
color = colors.textPrimary,
modifier = Modifier.padding(bottom = 14.dp)
)
SearchBar(
query = query,
onQueryChange = { query = it },
placeholder = "Поиск…",
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(12.dp))
LazyColumn(modifier = Modifier.heightIn(max = 460.dp)) {
if (query.isBlank()) {
item {
CategoryRow(allLabel, selected == null) {
onSelect(null); onDismiss()
}
}
}
items(filtered) { item ->
CategoryRow(item, selected == item) {
onSelect(item); onDismiss()
}
}
}
}
}
}
@Composable
private fun CategoryRow(label: String, selected: Boolean, onClick: () -> Unit) {
val colors = RadiolaTheme.colors
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onClick
)
.padding(horizontal = 14.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = label,
style = MaterialTheme.typography.titleMedium,
color = if (selected) colors.accent else colors.textPrimary,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
modifier = Modifier.weight(1f)
)
if (selected) {
Icon(
imageVector = Lucide.Check,
contentDescription = null,
tint = colors.accent,
modifier = Modifier.size(18.dp)
)
}
}
}

View File

@@ -1,17 +1,32 @@
package com.radiola.ui.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.dp
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Search
import com.composables.icons.lucide.X
import com.radiola.ui.theme.RadiolaTheme
@Composable
@@ -22,6 +37,7 @@ fun SearchBar(
modifier: Modifier = Modifier
) {
val colors = RadiolaTheme.colors
val haptics = LocalHapticFeedback.current
TextField(
value = query,
onValueChange = onQueryChange,
@@ -29,6 +45,31 @@ fun SearchBar(
shape = RoundedCornerShape(14.dp),
placeholder = { Text(placeholder, color = colors.textMuted) },
leadingIcon = { Icon(Lucide.Search, contentDescription = null, tint = colors.textMuted) },
// Кнопка «очистить» — появляется/исчезает с анимацией (scale + fade).
trailingIcon = {
AnimatedVisibility(
visible = query.isNotEmpty(),
enter = scaleIn() + fadeIn(),
exit = scaleOut() + fadeOut()
) {
Icon(
imageVector = Lucide.X,
contentDescription = "Очистить",
tint = colors.textSecondary,
modifier = Modifier
.size(34.dp)
.padding(7.dp)
.clip(CircleShape)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) {
haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
onQueryChange("")
}
)
}
},
singleLine = true,
colors = TextFieldDefaults.colors(
focusedContainerColor = colors.surface,

View File

@@ -178,13 +178,23 @@ fun StationsScreen(
)
.padding(top = 2.dp, bottom = 12.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
CategoryPicker(
title = "Категории",
items = tags,
selected = selectedTag,
onSelect = viewModel::onTagSelected,
modifier = Modifier.padding(start = 16.dp)
)
FilterChips(
tags = tags,
selectedTag = selectedTag,
onTagSelected = viewModel::onTagSelected
onTagSelected = viewModel::onTagSelected,
modifier = Modifier.weight(1f)
)
}
}
}
}
}
}