From 78282e97caa792a61b4236c6022169857a92ee7a Mon Sep 17 00:00:00 2001 From: nk Date: Sun, 7 Jun 2026 17:25:12 +0300 Subject: [PATCH] =?UTF-8?q?feat(filters):=20=D0=B1=D1=8B=D1=81=D1=82=D1=80?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=20=D0=BA=D0=B0?= =?UTF-8?q?=D1=82=D0=B5=D0=B3=D0=BE=D1=80=D0=B8=D0=B8=20+=20=D0=BE=D1=87?= =?UTF-8?q?=D0=B8=D1=81=D1=82=D0=BA=D0=B0=20=D0=BF=D0=BE=D0=B8=D1=81=D0=BA?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Кнопка-«категории» (круглая, акцентная рамка, иконка SlidersHorizontal) СЛЕВА от чипа «Все» — на экранах Радио и Чарты. Открывает шторку со списком всех категорий (Радио — жанры, Чарты — стили) + поиск, чтобы не листать чипы. CategoryPicker — переиспользуемый компонент с поиском и отметкой выбранного. - SearchBar: анимированная кнопка очистки (X, scale+fade появление, haptic) при непустом запросе. --- .../com/radiola/ui/charts/ChartsScreen.kt | 25 ++- .../radiola/ui/components/CategoryPicker.kt | 183 ++++++++++++++++++ .../com/radiola/ui/components/SearchBar.kt | 41 ++++ .../com/radiola/ui/stations/StationsScreen.kt | 20 +- 4 files changed, 258 insertions(+), 11 deletions(-) create mode 100644 app/src/main/java/com/radiola/ui/components/CategoryPicker.kt diff --git a/app/src/main/java/com/radiola/ui/charts/ChartsScreen.kt b/app/src/main/java/com/radiola/ui/charts/ChartsScreen.kt index c3ea091..232e4ba 100644 --- a/app/src/main/java/com/radiola/ui/charts/ChartsScreen.kt +++ b/app/src/main/java/com/radiola/ui/charts/ChartsScreen.kt @@ -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,11 +102,21 @@ fun ChartsScreen( // Фильтр по жанру (если бэкенд уже накопил жанры) if (genres.isNotEmpty()) { Spacer(Modifier.height(10.dp)) - GenreSelector( - genres = genres, - selected = selectedGenre, - onSelect = viewModel::selectGenre - ) + 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, + 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, selected: String?, - onSelect: (String?) -> Unit + onSelect: (String?) -> Unit, + modifier: Modifier = Modifier ) { LazyRow( + modifier = modifier, horizontalArrangement = Arrangement.spacedBy(9.dp), contentPadding = PaddingValues(horizontal = 20.dp) ) { diff --git a/app/src/main/java/com/radiola/ui/components/CategoryPicker.kt b/app/src/main/java/com/radiola/ui/components/CategoryPicker.kt new file mode 100644 index 0000000..9237412 --- /dev/null +++ b/app/src/main/java/com/radiola/ui/components/CategoryPicker.kt @@ -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, + 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, + 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) + ) + } + } +} diff --git a/app/src/main/java/com/radiola/ui/components/SearchBar.kt b/app/src/main/java/com/radiola/ui/components/SearchBar.kt index d9ba97d..ce71fca 100644 --- a/app/src/main/java/com/radiola/ui/components/SearchBar.kt +++ b/app/src/main/java/com/radiola/ui/components/SearchBar.kt @@ -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, diff --git a/app/src/main/java/com/radiola/ui/stations/StationsScreen.kt b/app/src/main/java/com/radiola/ui/stations/StationsScreen.kt index 81d4878..ac412ff 100644 --- a/app/src/main/java/com/radiola/ui/stations/StationsScreen.kt +++ b/app/src/main/java/com/radiola/ui/stations/StationsScreen.kt @@ -178,11 +178,21 @@ fun StationsScreen( ) .padding(top = 2.dp, bottom = 12.dp) ) { - FilterChips( - tags = tags, - selectedTag = selectedTag, - onTagSelected = viewModel::onTagSelected - ) + 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, + modifier = Modifier.weight(1f) + ) + } } } }