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.DeeplinkService
|
||||||
import com.radiola.domain.model.StatPoint
|
import com.radiola.domain.model.StatPoint
|
||||||
import com.radiola.domain.model.TrackStats
|
import com.radiola.domain.model.TrackStats
|
||||||
|
import com.radiola.ui.components.CategoryPicker
|
||||||
import com.radiola.ui.components.EmptyState
|
import com.radiola.ui.components.EmptyState
|
||||||
import com.radiola.ui.components.PopularityChart
|
import com.radiola.ui.components.PopularityChart
|
||||||
import com.radiola.ui.components.crossfadeModel
|
import com.radiola.ui.components.crossfadeModel
|
||||||
@@ -101,12 +102,22 @@ fun ChartsScreen(
|
|||||||
// Фильтр по жанру (если бэкенд уже накопил жанры)
|
// Фильтр по жанру (если бэкенд уже накопил жанры)
|
||||||
if (genres.isNotEmpty()) {
|
if (genres.isNotEmpty()) {
|
||||||
Spacer(Modifier.height(10.dp))
|
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(
|
GenreSelector(
|
||||||
genres = genres,
|
genres = genres,
|
||||||
selected = selectedGenre,
|
selected = selectedGenre,
|
||||||
onSelect = viewModel::selectGenre
|
onSelect = viewModel::selectGenre,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|
||||||
@@ -213,9 +224,11 @@ private fun PeriodChip(label: String, selected: Boolean, onClick: () -> Unit) {
|
|||||||
private fun GenreSelector(
|
private fun GenreSelector(
|
||||||
genres: List<String>,
|
genres: List<String>,
|
||||||
selected: String?,
|
selected: String?,
|
||||||
onSelect: (String?) -> Unit
|
onSelect: (String?) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
LazyRow(
|
LazyRow(
|
||||||
|
modifier = modifier,
|
||||||
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||||
contentPadding = PaddingValues(horizontal = 20.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
|
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.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.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextField
|
import androidx.compose.material3.TextField
|
||||||
import androidx.compose.material3.TextFieldDefaults
|
import androidx.compose.material3.TextFieldDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
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 androidx.compose.ui.unit.dp
|
||||||
import com.composables.icons.lucide.Lucide
|
import com.composables.icons.lucide.Lucide
|
||||||
import com.composables.icons.lucide.Search
|
import com.composables.icons.lucide.Search
|
||||||
|
import com.composables.icons.lucide.X
|
||||||
import com.radiola.ui.theme.RadiolaTheme
|
import com.radiola.ui.theme.RadiolaTheme
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -22,6 +37,7 @@ fun SearchBar(
|
|||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val colors = RadiolaTheme.colors
|
val colors = RadiolaTheme.colors
|
||||||
|
val haptics = LocalHapticFeedback.current
|
||||||
TextField(
|
TextField(
|
||||||
value = query,
|
value = query,
|
||||||
onValueChange = onQueryChange,
|
onValueChange = onQueryChange,
|
||||||
@@ -29,6 +45,31 @@ fun SearchBar(
|
|||||||
shape = RoundedCornerShape(14.dp),
|
shape = RoundedCornerShape(14.dp),
|
||||||
placeholder = { Text(placeholder, color = colors.textMuted) },
|
placeholder = { Text(placeholder, color = colors.textMuted) },
|
||||||
leadingIcon = { Icon(Lucide.Search, contentDescription = null, tint = 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,
|
singleLine = true,
|
||||||
colors = TextFieldDefaults.colors(
|
colors = TextFieldDefaults.colors(
|
||||||
focusedContainerColor = colors.surface,
|
focusedContainerColor = colors.surface,
|
||||||
|
|||||||
@@ -178,13 +178,23 @@ fun StationsScreen(
|
|||||||
)
|
)
|
||||||
.padding(top = 2.dp, bottom = 12.dp)
|
.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(
|
FilterChips(
|
||||||
tags = tags,
|
tags = tags,
|
||||||
selectedTag = selectedTag,
|
selectedTag = selectedTag,
|
||||||
onTagSelected = viewModel::onTagSelected
|
onTagSelected = viewModel::onTagSelected,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user