feat(filters): быстрый выбор категории + очистка поиска
- Кнопка-«категории» (круглая, акцентная рамка, иконка SlidersHorizontal) СЛЕВА от чипа «Все» — на экранах Радио и Чарты. Открывает шторку со списком всех категорий (Радио — жанры, Чарты — стили) + поиск, чтобы не листать чипы. CategoryPicker — переиспользуемый компонент с поиском и отметкой выбранного. - SearchBar: анимированная кнопка очистки (X, scale+fade появление, haptic) при непустом запросе.
This commit is contained in:
@@ -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)
|
||||
) {
|
||||
|
||||
183
app/src/main/java/com/radiola/ui/components/CategoryPicker.kt
Normal file
183
app/src/main/java/com/radiola/ui/components/CategoryPicker.kt
Normal 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user