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:
@@ -4,6 +4,7 @@ import com.radiola.data.remote.dto.AuthResponseDto
|
|||||||
import com.radiola.data.remote.dto.BackendNowPlayingDto
|
import com.radiola.data.remote.dto.BackendNowPlayingDto
|
||||||
import com.radiola.data.remote.dto.BackendStationDto
|
import com.radiola.data.remote.dto.BackendStationDto
|
||||||
import com.radiola.data.remote.dto.ChartsResponseDto
|
import com.radiola.data.remote.dto.ChartsResponseDto
|
||||||
|
import com.radiola.data.remote.dto.GenresResponseDto
|
||||||
import com.radiola.data.remote.dto.HistoryResponseDto
|
import com.radiola.data.remote.dto.HistoryResponseDto
|
||||||
import com.radiola.data.remote.dto.MagicLinkRequestDto
|
import com.radiola.data.remote.dto.MagicLinkRequestDto
|
||||||
import com.radiola.data.remote.dto.MagicLinkVerifyDto
|
import com.radiola.data.remote.dto.MagicLinkVerifyDto
|
||||||
@@ -58,9 +59,13 @@ interface RadiolaApi {
|
|||||||
@GET("charts/tracks")
|
@GET("charts/tracks")
|
||||||
suspend fun getCharts(
|
suspend fun getCharts(
|
||||||
@Query("period") period: String,
|
@Query("period") period: String,
|
||||||
@Query("limit") limit: Int = 100
|
@Query("limit") limit: Int = 100,
|
||||||
|
@Query("genre") genre: String? = null
|
||||||
): ChartsResponseDto
|
): ChartsResponseDto
|
||||||
|
|
||||||
|
@GET("charts/genres")
|
||||||
|
suspend fun getGenres(): GenresResponseDto
|
||||||
|
|
||||||
@GET("charts/tracks/{trackId}")
|
@GET("charts/tracks/{trackId}")
|
||||||
suspend fun getTrackStats(@Path("trackId") trackId: String): TrackStatsDto
|
suspend fun getTrackStats(@Path("trackId") trackId: String): TrackStatsDto
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ data class ChartsResponseDto(
|
|||||||
val items: List<ChartEntryDto> = emptyList()
|
val items: List<ChartEntryDto> = emptyList()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** DTO списка доступных жанров для фильтра. */
|
||||||
|
@Serializable
|
||||||
|
data class GenresResponseDto(
|
||||||
|
val genres: List<String> = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
/** Одна позиция в чарте. */
|
/** Одна позиция в чарте. */
|
||||||
@Serializable
|
@Serializable
|
||||||
data class ChartEntryDto(
|
data class ChartEntryDto(
|
||||||
@@ -16,6 +22,10 @@ data class ChartEntryDto(
|
|||||||
val artist: String,
|
val artist: String,
|
||||||
val song: String,
|
val song: String,
|
||||||
val coverUrl: String? = null,
|
val coverUrl: String? = null,
|
||||||
|
val genre: String? = null,
|
||||||
|
val styles: List<String> = emptyList(),
|
||||||
|
val label: String? = null,
|
||||||
|
val year: Int? = null,
|
||||||
val plays: Int = 0,
|
val plays: Int = 0,
|
||||||
val stationsCount: Int = 0,
|
val stationsCount: Int = 0,
|
||||||
val likes: Int = 0,
|
val likes: Int = 0,
|
||||||
@@ -32,6 +42,10 @@ data class TrackStatsDto(
|
|||||||
val song: String,
|
val song: String,
|
||||||
val album: String? = null,
|
val album: String? = null,
|
||||||
val coverUrl: String? = null,
|
val coverUrl: String? = null,
|
||||||
|
val genre: String? = null,
|
||||||
|
val styles: List<String> = emptyList(),
|
||||||
|
val label: String? = null,
|
||||||
|
val year: Int? = null,
|
||||||
val releaseDate: String? = null,
|
val releaseDate: String? = null,
|
||||||
val firstSeen: String? = null,
|
val firstSeen: String? = null,
|
||||||
val totalPlays: Int = 0,
|
val totalPlays: Int = 0,
|
||||||
|
|||||||
@@ -25,28 +25,29 @@ class ChartsRepositoryImpl @Inject constructor(
|
|||||||
private val api: RadiolaApi
|
private val api: RadiolaApi
|
||||||
) : ChartsRepository {
|
) : ChartsRepository {
|
||||||
|
|
||||||
// TODO: убрать превью, когда бэкенд отдаёт charts
|
override suspend fun getCharts(period: ChartPeriod, genre: String?): List<ChartEntry> {
|
||||||
override suspend fun getCharts(period: ChartPeriod): List<ChartEntry> {
|
|
||||||
return try {
|
return try {
|
||||||
val response = api.getCharts(period.apiValue)
|
val response = api.getCharts(period.apiValue, genre = genre)
|
||||||
response.items.map { it.toDomain() }
|
response.items.map { it.toDomain() }
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w("ChartsRepository", "Эндпоинт charts недоступен, возвращаем превью-данные: ${e.message}")
|
Log.w("ChartsRepository", "Ошибка загрузки чартов: ${e.message}")
|
||||||
SAMPLE_CHARTS
|
emptyList()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: убрать превью, когда бэкенд отдаёт charts
|
override suspend fun getGenres(): List<String> {
|
||||||
override suspend fun getTrackStats(trackId: String): TrackStats {
|
|
||||||
return try {
|
return try {
|
||||||
val dto = api.getTrackStats(trackId)
|
api.getGenres().genres
|
||||||
dto.toDomain()
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w("ChartsRepository", "Эндпоинт getTrackStats недоступен, возвращаем превью-данные: ${e.message}")
|
Log.w("ChartsRepository", "Ошибка загрузки жанров: ${e.message}")
|
||||||
sampleStats(trackId)
|
emptyList()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun getTrackStats(trackId: String): TrackStats {
|
||||||
|
return api.getTrackStats(trackId).toDomain()
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun setLiked(trackId: String, liked: Boolean) {
|
override suspend fun setLiked(trackId: String, liked: Boolean) {
|
||||||
try {
|
try {
|
||||||
if (liked) api.likeTrack(trackId) else api.unlikeTrack(trackId)
|
if (liked) api.likeTrack(trackId) else api.unlikeTrack(trackId)
|
||||||
@@ -63,6 +64,10 @@ class ChartsRepositoryImpl @Inject constructor(
|
|||||||
artist = artist,
|
artist = artist,
|
||||||
song = song,
|
song = song,
|
||||||
coverUrl = coverUrl,
|
coverUrl = coverUrl,
|
||||||
|
genre = genre,
|
||||||
|
styles = styles,
|
||||||
|
label = label,
|
||||||
|
year = year,
|
||||||
plays = plays,
|
plays = plays,
|
||||||
stationsCount = stationsCount,
|
stationsCount = stationsCount,
|
||||||
likes = likes,
|
likes = likes,
|
||||||
@@ -81,6 +86,10 @@ class ChartsRepositoryImpl @Inject constructor(
|
|||||||
song = song,
|
song = song,
|
||||||
album = album,
|
album = album,
|
||||||
coverUrl = coverUrl,
|
coverUrl = coverUrl,
|
||||||
|
genre = genre,
|
||||||
|
styles = styles,
|
||||||
|
label = label,
|
||||||
|
year = year,
|
||||||
releaseDate = releaseDate,
|
releaseDate = releaseDate,
|
||||||
firstSeen = firstSeen,
|
firstSeen = firstSeen,
|
||||||
totalPlays = totalPlays,
|
totalPlays = totalPlays,
|
||||||
@@ -111,87 +120,4 @@ class ChartsRepositoryImpl @Inject constructor(
|
|||||||
}
|
}
|
||||||
return StatPoint(epochMs, value)
|
return StatPoint(epochMs, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Превью-данные (используются пока бэкенд не готов) ----
|
|
||||||
// TODO: убрать превью, когда бэкенд отдаёт charts
|
|
||||||
|
|
||||||
private fun sampleStats(trackId: String): TrackStats {
|
|
||||||
val entry = SAMPLE_CHARTS.firstOrNull { it.trackId == trackId }
|
|
||||||
val now = System.currentTimeMillis()
|
|
||||||
val dayMs = 86_400_000L
|
|
||||||
|
|
||||||
// 30 точек с плавным ростом + шум
|
|
||||||
fun timeline(base: Int): List<StatPoint> = (0 until 30).map { i ->
|
|
||||||
val noise = (-base / 20..base / 20).random()
|
|
||||||
StatPoint(
|
|
||||||
date = now - (29 - i) * dayMs,
|
|
||||||
value = (base * (0.4 + i * 0.02) + noise).toInt().coerceAtLeast(0)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return TrackStats(
|
|
||||||
trackId = trackId,
|
|
||||||
artist = entry?.artist ?: "Неизвестный исполнитель",
|
|
||||||
song = entry?.song ?: "Неизвестный трек",
|
|
||||||
album = sampleAlbum(trackId),
|
|
||||||
coverUrl = entry?.coverUrl,
|
|
||||||
releaseDate = sampleReleaseDate(trackId),
|
|
||||||
firstSeen = "2024-01-15",
|
|
||||||
totalPlays = entry?.plays ?: 12_000,
|
|
||||||
totalLikes = entry?.likes ?: 430,
|
|
||||||
isLiked = false,
|
|
||||||
currentRank = entry?.rank,
|
|
||||||
peakRank = (entry?.rank ?: 5).coerceAtMost(3),
|
|
||||||
stations = listOf(
|
|
||||||
StationPlays(1, "Европа Плюс", 3_200),
|
|
||||||
StationPlays(2, "Авторадио", 2_800),
|
|
||||||
StationPlays(3, "Радио Energy", 1_900)
|
|
||||||
),
|
|
||||||
playsTimeline = timeline(entry?.plays ?: 12_000),
|
|
||||||
likesTimeline = timeline(entry?.likes ?: 430)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun sampleAlbum(trackId: String): String? = when (trackId) {
|
|
||||||
"track_01" -> "Чёрный альбом"
|
|
||||||
"track_02" -> "Танцы на стёклах"
|
|
||||||
"track_03" -> "Dangerous"
|
|
||||||
"track_04" -> "Воздух"
|
|
||||||
"track_05" -> "÷ (Divide)"
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun sampleReleaseDate(trackId: String): String? = when (trackId) {
|
|
||||||
"track_01" -> "2023-09-22"
|
|
||||||
"track_02" -> "2022-06-15"
|
|
||||||
"track_03" -> "1991-11-26"
|
|
||||||
"track_04" -> "2023-03-10"
|
|
||||||
"track_05" -> "2017-03-03"
|
|
||||||
else -> "2024-01-01"
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
// TODO: убрать превью, когда бэкенд отдаёт charts
|
|
||||||
val SAMPLE_CHARTS: List<ChartEntry> = listOf(
|
|
||||||
ChartEntry(1, "track_01", "Монеточка", "Каждый раз", null, 48_200, 12, 1_840, 2, ChartTrend.UP),
|
|
||||||
ChartEntry(2, "track_02", "Земфира", "Хочешь?", null, 43_100, 15, 2_100, 1, ChartTrend.DOWN),
|
|
||||||
ChartEntry(3, "track_03", "Michael Jackson", "Billie Jean", null, 41_500, 18, 3_200, null, ChartTrend.NEW),
|
|
||||||
ChartEntry(4, "track_04", "Баста", "Моя игра", null, 38_900, 11, 1_420, 5, ChartTrend.UP),
|
|
||||||
ChartEntry(5, "track_05", "Ed Sheeran", "Shape of You", null, 36_400, 20, 2_870, 4, ChartTrend.DOWN),
|
|
||||||
ChartEntry(6, "track_06", "Ленинград", "Экспонат", null, 34_700, 9, 980, 6, ChartTrend.SAME),
|
|
||||||
ChartEntry(7, "track_07", "Imagine Dragons", "Believer", null, 33_100, 16, 1_650, 8, ChartTrend.UP),
|
|
||||||
ChartEntry(8, "track_08", "Нервы", "Друг", null, 31_500, 7, 760, 7, ChartTrend.DOWN),
|
|
||||||
ChartEntry(9, "track_09", "The Weeknd", "Blinding Lights", null, 29_800, 22, 2_440, null, ChartTrend.NEW),
|
|
||||||
ChartEntry(10, "track_10", "Сплин", "Романс", null, 27_300, 10, 690, 10, ChartTrend.SAME),
|
|
||||||
ChartEntry(11, "track_11", "Dua Lipa", "Levitating", null, 25_600, 14, 1_110, 12, ChartTrend.UP),
|
|
||||||
ChartEntry(12, "track_12", "Кино", "Группа крови", null, 23_900, 8, 870, 11, ChartTrend.DOWN),
|
|
||||||
ChartEntry(13, "track_13", "Bruno Mars", "Uptown Funk", null, 22_100, 19, 1_320, null, ChartTrend.NEW),
|
|
||||||
ChartEntry(14, "track_14", "Мумий Тролль", "Невеста", null, 20_400, 6, 510, 14, ChartTrend.SAME),
|
|
||||||
ChartEntry(15, "track_15", "Coldplay", "Yellow", null, 18_700, 13, 780, 15, ChartTrend.SAME)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Расширение для случайного числа в диапазоне (используется в preview). */
|
|
||||||
private fun IntRange.random(): Int =
|
|
||||||
if (first >= last) first else first + (Math.random() * (last - first + 1)).toInt()
|
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ data class ChartEntry(
|
|||||||
val artist: String,
|
val artist: String,
|
||||||
val song: String,
|
val song: String,
|
||||||
val coverUrl: String?,
|
val coverUrl: String?,
|
||||||
|
val genre: String?,
|
||||||
|
val styles: List<String>,
|
||||||
|
val label: String?,
|
||||||
|
val year: Int?,
|
||||||
val plays: Int,
|
val plays: Int,
|
||||||
val stationsCount: Int,
|
val stationsCount: Int,
|
||||||
val likes: Int,
|
val likes: Int,
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ data class TrackStats(
|
|||||||
val song: String,
|
val song: String,
|
||||||
val album: String?,
|
val album: String?,
|
||||||
val coverUrl: String?,
|
val coverUrl: String?,
|
||||||
|
val genre: String?,
|
||||||
|
val styles: List<String>,
|
||||||
|
val label: String?,
|
||||||
|
val year: Int?,
|
||||||
val releaseDate: String?,
|
val releaseDate: String?,
|
||||||
val firstSeen: String?,
|
val firstSeen: String?,
|
||||||
val totalPlays: Int,
|
val totalPlays: Int,
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import com.radiola.domain.model.ChartPeriod
|
|||||||
import com.radiola.domain.model.TrackStats
|
import com.radiola.domain.model.TrackStats
|
||||||
|
|
||||||
interface ChartsRepository {
|
interface ChartsRepository {
|
||||||
suspend fun getCharts(period: ChartPeriod): List<ChartEntry>
|
suspend fun getCharts(period: ChartPeriod, genre: String? = null): List<ChartEntry>
|
||||||
|
suspend fun getGenres(): List<String>
|
||||||
suspend fun getTrackStats(trackId: String): TrackStats
|
suspend fun getTrackStats(trackId: String): TrackStats
|
||||||
suspend fun setLiked(trackId: String, liked: Boolean)
|
suspend fun setLiked(trackId: String, liked: Boolean)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import androidx.compose.foundation.ExperimentalFoundationApi
|
|||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.basicMarquee
|
import androidx.compose.foundation.basicMarquee
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
@@ -61,7 +63,8 @@ fun ChartsScreen(
|
|||||||
val colors = RadiolaTheme.colors
|
val colors = RadiolaTheme.colors
|
||||||
val period by viewModel.period.collectAsState()
|
val period by viewModel.period.collectAsState()
|
||||||
val charts by viewModel.charts.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 isLoadingCharts by viewModel.isLoadingCharts.collectAsState()
|
||||||
val selectedStats by viewModel.selectedTrackStats.collectAsState()
|
val selectedStats by viewModel.selectedTrackStats.collectAsState()
|
||||||
val isLoadingStats by viewModel.isLoadingStats.collectAsState()
|
val isLoadingStats by viewModel.isLoadingStats.collectAsState()
|
||||||
@@ -95,35 +98,18 @@ fun ChartsScreen(
|
|||||||
onSelect = viewModel::selectPeriod
|
onSelect = viewModel::selectPeriod
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(Modifier.height(12.dp))
|
// Фильтр по жанру (если бэкенд уже накопил жанры)
|
||||||
|
if (genres.isNotEmpty()) {
|
||||||
// Плашка «Демо-данные»
|
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(10.dp))
|
Spacer(Modifier.height(10.dp))
|
||||||
|
GenreSelector(
|
||||||
|
genres = genres,
|
||||||
|
selected = selectedGenre,
|
||||||
|
onSelect = viewModel::selectGenre
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|
||||||
// Список чартов
|
// Список чартов
|
||||||
if (isLoadingCharts) {
|
if (isLoadingCharts) {
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
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)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@@ -436,15 +451,32 @@ private fun TrackDetailSheet(
|
|||||||
modifier = Modifier.padding(top = 2.dp)
|
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(
|
||||||
text = "Вышел: ${stats.releaseDate}",
|
text = metaLine,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = colors.textMuted,
|
color = colors.textMuted,
|
||||||
modifier = Modifier.padding(top = 2.dp)
|
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))
|
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
|
@Composable
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package com.radiola.ui.charts
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.radiola.data.repository.ChartsRepositoryImpl
|
|
||||||
import com.radiola.domain.model.ChartEntry
|
import com.radiola.domain.model.ChartEntry
|
||||||
import com.radiola.domain.model.ChartPeriod
|
import com.radiola.domain.model.ChartPeriod
|
||||||
import com.radiola.domain.model.TrackStats
|
import com.radiola.domain.model.TrackStats
|
||||||
@@ -26,9 +25,13 @@ class ChartsViewModel @Inject constructor(
|
|||||||
private val _charts = MutableStateFlow<List<ChartEntry>>(emptyList())
|
private val _charts = MutableStateFlow<List<ChartEntry>>(emptyList())
|
||||||
val charts: StateFlow<List<ChartEntry>> = _charts.asStateFlow()
|
val charts: StateFlow<List<ChartEntry>> = _charts.asStateFlow()
|
||||||
|
|
||||||
/** true — данные из превью (бэкенд ещё не готов). */
|
/** Доступные жанры для фильтра (с бэкенда). */
|
||||||
private val _isPreview = MutableStateFlow(false)
|
private val _genres = MutableStateFlow<List<String>>(emptyList())
|
||||||
val isPreview: StateFlow<Boolean> = _isPreview.asStateFlow()
|
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)
|
private val _isLoadingCharts = MutableStateFlow(false)
|
||||||
val isLoadingCharts: StateFlow<Boolean> = _isLoadingCharts.asStateFlow()
|
val isLoadingCharts: StateFlow<Boolean> = _isLoadingCharts.asStateFlow()
|
||||||
@@ -41,6 +44,7 @@ class ChartsViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
loadCharts()
|
loadCharts()
|
||||||
|
loadGenres()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun selectPeriod(newPeriod: ChartPeriod) {
|
fun selectPeriod(newPeriod: ChartPeriod) {
|
||||||
@@ -49,6 +53,12 @@ class ChartsViewModel @Inject constructor(
|
|||||||
loadCharts()
|
loadCharts()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun selectGenre(genre: String?) {
|
||||||
|
if (_selectedGenre.value == genre) return
|
||||||
|
_selectedGenre.value = genre
|
||||||
|
loadCharts()
|
||||||
|
}
|
||||||
|
|
||||||
fun selectTrack(trackId: String) {
|
fun selectTrack(trackId: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_isLoadingStats.value = true
|
_isLoadingStats.value = true
|
||||||
@@ -91,18 +101,19 @@ class ChartsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_isLoadingCharts.value = true
|
_isLoadingCharts.value = true
|
||||||
try {
|
try {
|
||||||
val result = chartsRepository.getCharts(_period.value)
|
_charts.value = chartsRepository.getCharts(_period.value, _selectedGenre.value)
|
||||||
_charts.value = result
|
|
||||||
// Определяем, это превью или реальные данные.
|
|
||||||
// TODO: убрать проверку, когда бэкенд отдаёт charts.
|
|
||||||
// Если список совпадает с образцом — это превью.
|
|
||||||
_isPreview.value = result == ChartsRepositoryImpl.SAMPLE_CHARTS
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e("ChartsViewModel", "Ошибка загрузки чартов", e)
|
Log.e("ChartsViewModel", "Ошибка загрузки чартов", e)
|
||||||
_isPreview.value = true
|
_charts.value = emptyList()
|
||||||
} finally {
|
} finally {
|
||||||
_isLoadingCharts.value = false
|
_isLoadingCharts.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun loadGenres() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_genres.value = chartsRepository.getGenres()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user