diff --git a/app/src/main/java/com/radiola/data/remote/RadiolaApi.kt b/app/src/main/java/com/radiola/data/remote/RadiolaApi.kt index 7a40e49..c1604c8 100644 --- a/app/src/main/java/com/radiola/data/remote/RadiolaApi.kt +++ b/app/src/main/java/com/radiola/data/remote/RadiolaApi.kt @@ -4,6 +4,7 @@ import com.radiola.data.remote.dto.AuthResponseDto import com.radiola.data.remote.dto.BackendNowPlayingDto import com.radiola.data.remote.dto.BackendStationDto 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.MagicLinkRequestDto import com.radiola.data.remote.dto.MagicLinkVerifyDto @@ -58,9 +59,13 @@ interface RadiolaApi { @GET("charts/tracks") suspend fun getCharts( @Query("period") period: String, - @Query("limit") limit: Int = 100 + @Query("limit") limit: Int = 100, + @Query("genre") genre: String? = null ): ChartsResponseDto + @GET("charts/genres") + suspend fun getGenres(): GenresResponseDto + @GET("charts/tracks/{trackId}") suspend fun getTrackStats(@Path("trackId") trackId: String): TrackStatsDto diff --git a/app/src/main/java/com/radiola/data/remote/dto/ChartsDto.kt b/app/src/main/java/com/radiola/data/remote/dto/ChartsDto.kt index 3ea8ce3..9f4af83 100644 --- a/app/src/main/java/com/radiola/data/remote/dto/ChartsDto.kt +++ b/app/src/main/java/com/radiola/data/remote/dto/ChartsDto.kt @@ -8,6 +8,12 @@ data class ChartsResponseDto( val items: List = emptyList() ) +/** DTO списка доступных жанров для фильтра. */ +@Serializable +data class GenresResponseDto( + val genres: List = emptyList() +) + /** Одна позиция в чарте. */ @Serializable data class ChartEntryDto( @@ -16,6 +22,10 @@ data class ChartEntryDto( val artist: String, val song: String, val coverUrl: String? = null, + val genre: String? = null, + val styles: List = emptyList(), + val label: String? = null, + val year: Int? = null, val plays: Int = 0, val stationsCount: Int = 0, val likes: Int = 0, @@ -32,6 +42,10 @@ data class TrackStatsDto( val song: String, val album: String? = null, val coverUrl: String? = null, + val genre: String? = null, + val styles: List = emptyList(), + val label: String? = null, + val year: Int? = null, val releaseDate: String? = null, val firstSeen: String? = null, val totalPlays: Int = 0, diff --git a/app/src/main/java/com/radiola/data/repository/ChartsRepositoryImpl.kt b/app/src/main/java/com/radiola/data/repository/ChartsRepositoryImpl.kt index fd67859..add132d 100644 --- a/app/src/main/java/com/radiola/data/repository/ChartsRepositoryImpl.kt +++ b/app/src/main/java/com/radiola/data/repository/ChartsRepositoryImpl.kt @@ -25,28 +25,29 @@ class ChartsRepositoryImpl @Inject constructor( private val api: RadiolaApi ) : ChartsRepository { - // TODO: убрать превью, когда бэкенд отдаёт charts - override suspend fun getCharts(period: ChartPeriod): List { + override suspend fun getCharts(period: ChartPeriod, genre: String?): List { return try { - val response = api.getCharts(period.apiValue) + val response = api.getCharts(period.apiValue, genre = genre) response.items.map { it.toDomain() } } catch (e: Exception) { - Log.w("ChartsRepository", "Эндпоинт charts недоступен, возвращаем превью-данные: ${e.message}") - SAMPLE_CHARTS + Log.w("ChartsRepository", "Ошибка загрузки чартов: ${e.message}") + emptyList() } } - // TODO: убрать превью, когда бэкенд отдаёт charts - override suspend fun getTrackStats(trackId: String): TrackStats { + override suspend fun getGenres(): List { return try { - val dto = api.getTrackStats(trackId) - dto.toDomain() + api.getGenres().genres } catch (e: Exception) { - Log.w("ChartsRepository", "Эндпоинт getTrackStats недоступен, возвращаем превью-данные: ${e.message}") - sampleStats(trackId) + Log.w("ChartsRepository", "Ошибка загрузки жанров: ${e.message}") + emptyList() } } + override suspend fun getTrackStats(trackId: String): TrackStats { + return api.getTrackStats(trackId).toDomain() + } + override suspend fun setLiked(trackId: String, liked: Boolean) { try { if (liked) api.likeTrack(trackId) else api.unlikeTrack(trackId) @@ -63,6 +64,10 @@ class ChartsRepositoryImpl @Inject constructor( artist = artist, song = song, coverUrl = coverUrl, + genre = genre, + styles = styles, + label = label, + year = year, plays = plays, stationsCount = stationsCount, likes = likes, @@ -81,6 +86,10 @@ class ChartsRepositoryImpl @Inject constructor( song = song, album = album, coverUrl = coverUrl, + genre = genre, + styles = styles, + label = label, + year = year, releaseDate = releaseDate, firstSeen = firstSeen, totalPlays = totalPlays, @@ -111,87 +120,4 @@ class ChartsRepositoryImpl @Inject constructor( } 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 = (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 = 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() diff --git a/app/src/main/java/com/radiola/domain/model/ChartEntry.kt b/app/src/main/java/com/radiola/domain/model/ChartEntry.kt index b9420fd..8f79673 100644 --- a/app/src/main/java/com/radiola/domain/model/ChartEntry.kt +++ b/app/src/main/java/com/radiola/domain/model/ChartEntry.kt @@ -18,6 +18,10 @@ data class ChartEntry( val artist: String, val song: String, val coverUrl: String?, + val genre: String?, + val styles: List, + val label: String?, + val year: Int?, val plays: Int, val stationsCount: Int, val likes: Int, diff --git a/app/src/main/java/com/radiola/domain/model/TrackStats.kt b/app/src/main/java/com/radiola/domain/model/TrackStats.kt index dce94bd..0fa218a 100644 --- a/app/src/main/java/com/radiola/domain/model/TrackStats.kt +++ b/app/src/main/java/com/radiola/domain/model/TrackStats.kt @@ -21,6 +21,10 @@ data class TrackStats( val song: String, val album: String?, val coverUrl: String?, + val genre: String?, + val styles: List, + val label: String?, + val year: Int?, val releaseDate: String?, val firstSeen: String?, val totalPlays: Int, diff --git a/app/src/main/java/com/radiola/domain/repository/ChartsRepository.kt b/app/src/main/java/com/radiola/domain/repository/ChartsRepository.kt index 794c972..1d8c32a 100644 --- a/app/src/main/java/com/radiola/domain/repository/ChartsRepository.kt +++ b/app/src/main/java/com/radiola/domain/repository/ChartsRepository.kt @@ -5,7 +5,8 @@ import com.radiola.domain.model.ChartPeriod import com.radiola.domain.model.TrackStats interface ChartsRepository { - suspend fun getCharts(period: ChartPeriod): List + suspend fun getCharts(period: ChartPeriod, genre: String? = null): List + suspend fun getGenres(): List suspend fun getTrackStats(trackId: String): TrackStats suspend fun setLiked(trackId: String, liked: Boolean) } diff --git a/app/src/main/java/com/radiola/ui/charts/ChartsScreen.kt b/app/src/main/java/com/radiola/ui/charts/ChartsScreen.kt index e4a6f4d..c3ea091 100644 --- a/app/src/main/java/com/radiola/ui/charts/ChartsScreen.kt +++ b/app/src/main/java/com/radiola/ui/charts/ChartsScreen.kt @@ -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, + 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) { + 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 diff --git a/app/src/main/java/com/radiola/ui/charts/ChartsViewModel.kt b/app/src/main/java/com/radiola/ui/charts/ChartsViewModel.kt index 1dd49fd..29f2c61 100644 --- a/app/src/main/java/com/radiola/ui/charts/ChartsViewModel.kt +++ b/app/src/main/java/com/radiola/ui/charts/ChartsViewModel.kt @@ -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>(emptyList()) val charts: StateFlow> = _charts.asStateFlow() - /** true — данные из превью (бэкенд ещё не готов). */ - private val _isPreview = MutableStateFlow(false) - val isPreview: StateFlow = _isPreview.asStateFlow() + /** Доступные жанры для фильтра (с бэкенда). */ + private val _genres = MutableStateFlow>(emptyList()) + val genres: StateFlow> = _genres.asStateFlow() + + /** Выбранный жанр (null — «Все»). */ + private val _selectedGenre = MutableStateFlow(null) + val selectedGenre: StateFlow = _selectedGenre.asStateFlow() private val _isLoadingCharts = MutableStateFlow(false) val isLoadingCharts: StateFlow = _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() + } + } }