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:
nk
2026-06-03 13:55:35 +03:00
parent b0c3dae20a
commit 99503fc77a
8 changed files with 157 additions and 136 deletions

View File

@@ -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

View File

@@ -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()
}
}
}