feat(charts): фильтр по жанру + жанр/стиль/лейбл/год на детальной трека
Подтягиваем обогащённые данные с бэкенда (Discogs): genre/styles/label/year в чартах и детальной странице. - ChartEntry/TrackStats + DTO: добавлены genre/styles/label/year - RadiolaApi: getCharts(?genre=), новый getGenres() - ChartsViewModel: состояние выбранного жанра + список жанров, перезагрузка - ChartsScreen: ряд чипов-фильтров по жанру (Все + жанры), жанр/стили чипами и «Лейбл · Год» на детальной - убран демо-fallback (SAMPLE_CHARTS) — бэкенд живой Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,9 @@ import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.basicMarquee
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
@@ -61,7 +63,8 @@ fun ChartsScreen(
|
||||
val colors = RadiolaTheme.colors
|
||||
val period by viewModel.period.collectAsState()
|
||||
val charts by viewModel.charts.collectAsState()
|
||||
val isPreview by viewModel.isPreview.collectAsState()
|
||||
val genres by viewModel.genres.collectAsState()
|
||||
val selectedGenre by viewModel.selectedGenre.collectAsState()
|
||||
val isLoadingCharts by viewModel.isLoadingCharts.collectAsState()
|
||||
val selectedStats by viewModel.selectedTrackStats.collectAsState()
|
||||
val isLoadingStats by viewModel.isLoadingStats.collectAsState()
|
||||
@@ -95,35 +98,18 @@ fun ChartsScreen(
|
||||
onSelect = viewModel::selectPeriod
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// Плашка «Демо-данные»
|
||||
if (isPreview) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 20.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(colors.surface2)
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Lucide.Info,
|
||||
contentDescription = null,
|
||||
tint = colors.textMuted,
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
Text(
|
||||
text = "Демо-данные — бэкенд ещё не готов",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = colors.textMuted
|
||||
)
|
||||
}
|
||||
// Фильтр по жанру (если бэкенд уже накопил жанры)
|
||||
if (genres.isNotEmpty()) {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
GenreSelector(
|
||||
genres = genres,
|
||||
selected = selectedGenre,
|
||||
onSelect = viewModel::selectGenre
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// Список чартов
|
||||
if (isLoadingCharts) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
@@ -221,6 +207,35 @@ private fun PeriodChip(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||
)
|
||||
}
|
||||
|
||||
// ---- Селектор жанра ----
|
||||
|
||||
@Composable
|
||||
private fun GenreSelector(
|
||||
genres: List<String>,
|
||||
selected: String?,
|
||||
onSelect: (String?) -> Unit
|
||||
) {
|
||||
LazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||
contentPadding = PaddingValues(horizontal = 20.dp)
|
||||
) {
|
||||
item {
|
||||
PeriodChip(
|
||||
label = "Все",
|
||||
selected = selected == null,
|
||||
onClick = { onSelect(null) }
|
||||
)
|
||||
}
|
||||
items(genres) { genre ->
|
||||
PeriodChip(
|
||||
label = genre,
|
||||
selected = selected == genre,
|
||||
onClick = { onSelect(genre) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Строка чарта ----
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@@ -436,15 +451,32 @@ private fun TrackDetailSheet(
|
||||
modifier = Modifier.padding(top = 2.dp)
|
||||
)
|
||||
}
|
||||
if (stats.releaseDate != null) {
|
||||
// Лейбл · Год (либо дата релиза как запасной вариант)
|
||||
val metaLine = buildList {
|
||||
stats.label?.let { add(it) }
|
||||
stats.year?.let { add(it.toString()) }
|
||||
}.joinToString(" · ").ifEmpty {
|
||||
stats.releaseDate?.let { "Вышел: $it" } ?: ""
|
||||
}
|
||||
if (metaLine.isNotEmpty()) {
|
||||
Text(
|
||||
text = "Вышел: ${stats.releaseDate}",
|
||||
text = metaLine,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = colors.textMuted,
|
||||
modifier = Modifier.padding(top = 2.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// Жанр + стили
|
||||
val genreTags = buildList {
|
||||
stats.genre?.let { add(it) }
|
||||
addAll(stats.styles)
|
||||
}.distinct()
|
||||
if (genreTags.isNotEmpty()) {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
GenreTags(tags = genreTags)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Ряд метрик
|
||||
@@ -548,6 +580,30 @@ private fun TrackDetailSheet(
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Чипы жанра/стилей на детальной ----
|
||||
|
||||
@Composable
|
||||
private fun GenreTags(tags: List<String>) {
|
||||
val colors = RadiolaTheme.colors
|
||||
Row(
|
||||
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
tags.forEach { tag ->
|
||||
Text(
|
||||
text = tag,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = colors.accent,
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(colors.accent.copy(alpha = 0.12f))
|
||||
.padding(horizontal = 10.dp, vertical = 5.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Метрики трека ----
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -3,7 +3,6 @@ package com.radiola.ui.charts
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.radiola.data.repository.ChartsRepositoryImpl
|
||||
import com.radiola.domain.model.ChartEntry
|
||||
import com.radiola.domain.model.ChartPeriod
|
||||
import com.radiola.domain.model.TrackStats
|
||||
@@ -26,9 +25,13 @@ class ChartsViewModel @Inject constructor(
|
||||
private val _charts = MutableStateFlow<List<ChartEntry>>(emptyList())
|
||||
val charts: StateFlow<List<ChartEntry>> = _charts.asStateFlow()
|
||||
|
||||
/** true — данные из превью (бэкенд ещё не готов). */
|
||||
private val _isPreview = MutableStateFlow(false)
|
||||
val isPreview: StateFlow<Boolean> = _isPreview.asStateFlow()
|
||||
/** Доступные жанры для фильтра (с бэкенда). */
|
||||
private val _genres = MutableStateFlow<List<String>>(emptyList())
|
||||
val genres: StateFlow<List<String>> = _genres.asStateFlow()
|
||||
|
||||
/** Выбранный жанр (null — «Все»). */
|
||||
private val _selectedGenre = MutableStateFlow<String?>(null)
|
||||
val selectedGenre: StateFlow<String?> = _selectedGenre.asStateFlow()
|
||||
|
||||
private val _isLoadingCharts = MutableStateFlow(false)
|
||||
val isLoadingCharts: StateFlow<Boolean> = _isLoadingCharts.asStateFlow()
|
||||
@@ -41,6 +44,7 @@ class ChartsViewModel @Inject constructor(
|
||||
|
||||
init {
|
||||
loadCharts()
|
||||
loadGenres()
|
||||
}
|
||||
|
||||
fun selectPeriod(newPeriod: ChartPeriod) {
|
||||
@@ -49,6 +53,12 @@ class ChartsViewModel @Inject constructor(
|
||||
loadCharts()
|
||||
}
|
||||
|
||||
fun selectGenre(genre: String?) {
|
||||
if (_selectedGenre.value == genre) return
|
||||
_selectedGenre.value = genre
|
||||
loadCharts()
|
||||
}
|
||||
|
||||
fun selectTrack(trackId: String) {
|
||||
viewModelScope.launch {
|
||||
_isLoadingStats.value = true
|
||||
@@ -91,18 +101,19 @@ class ChartsViewModel @Inject constructor(
|
||||
viewModelScope.launch {
|
||||
_isLoadingCharts.value = true
|
||||
try {
|
||||
val result = chartsRepository.getCharts(_period.value)
|
||||
_charts.value = result
|
||||
// Определяем, это превью или реальные данные.
|
||||
// TODO: убрать проверку, когда бэкенд отдаёт charts.
|
||||
// Если список совпадает с образцом — это превью.
|
||||
_isPreview.value = result == ChartsRepositoryImpl.SAMPLE_CHARTS
|
||||
_charts.value = chartsRepository.getCharts(_period.value, _selectedGenre.value)
|
||||
} catch (e: Exception) {
|
||||
Log.e("ChartsViewModel", "Ошибка загрузки чартов", e)
|
||||
_isPreview.value = true
|
||||
_charts.value = emptyList()
|
||||
} finally {
|
||||
_isLoadingCharts.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadGenres() {
|
||||
viewModelScope.launch {
|
||||
_genres.value = chartsRepository.getGenres()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user