feat(charts): раздел «Чарты» (клиент) + детальная страница трека с графиком

- вкладка «Чарты» в навигации; экран: периоды (День/Неделя/Месяц/Всё),
  ранжированный список треков (ранг, обложка, проигрывания, тренд)
- детальная карточка трека: метрики, график популярности (Canvas), лайк,
  кнопки музыкальных сервисов, кнопка «Текст песни» (ссылка на лицензированный
  Musixmatch — полный текст не встраиваем, авторское право)
- ChartsRepository/LyricsRepository + эндпоинты charts/* в RadiolaApi (DTO)
- превью-данные пока бэкенд не отдаёт charts (помечено TODO)
This commit is contained in:
nk
2026-06-02 23:24:42 +03:00
parent a4af72a6e6
commit d0e5f4e8c5
15 changed files with 1346 additions and 1 deletions

View File

@@ -18,6 +18,7 @@ import androidx.navigation.compose.rememberNavController
import androidx.lifecycle.lifecycleScope
import com.radiola.data.local.TokenDataStore
import com.radiola.ui.auth.AuthScreen
import com.radiola.ui.charts.ChartsScreen
import com.radiola.ui.components.MiniPlayer
import com.radiola.ui.favorites.FavoritesScreen
import com.radiola.ui.favorites.FavoritesViewModel
@@ -118,6 +119,9 @@ class MainActivity : ComponentActivity() {
}
)
}
composable(NavDestinations.Charts.route) {
ChartsScreen()
}
composable(NavDestinations.Favorites.route) {
FavoritesScreen(
onStationClick = { station ->

View File

@@ -2,9 +2,11 @@ package com.radiola.data.remote
import com.radiola.data.remote.dto.AuthResponseDto
import com.radiola.data.remote.dto.BackendStationDto
import com.radiola.data.remote.dto.ChartsResponseDto
import com.radiola.data.remote.dto.HistoryResponseDto
import com.radiola.data.remote.dto.MagicLinkRequestDto
import com.radiola.data.remote.dto.MagicLinkVerifyDto
import com.radiola.data.remote.dto.TrackStatsDto
import com.radiola.data.remote.dto.UserSettingsDto
import kotlinx.serialization.json.JsonObject
import retrofit2.http.Body
@@ -13,6 +15,7 @@ import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.PATCH
import retrofit2.http.Path
import retrofit2.http.Query
interface RadiolaApi {
@@ -45,4 +48,21 @@ interface RadiolaApi {
@POST("users/me/history/{stationId}")
suspend fun addHistory(@Path("stationId") stationId: String): JsonObject
// --- Чарты ---
@GET("charts/tracks")
suspend fun getCharts(
@Query("period") period: String,
@Query("limit") limit: Int = 100
): ChartsResponseDto
@GET("charts/tracks/{trackId}")
suspend fun getTrackStats(@Path("trackId") trackId: String): TrackStatsDto
@POST("charts/tracks/{trackId}/like")
suspend fun likeTrack(@Path("trackId") trackId: String): JsonObject
@DELETE("charts/tracks/{trackId}/like")
suspend fun unlikeTrack(@Path("trackId") trackId: String): JsonObject
}

View File

@@ -0,0 +1,60 @@
package com.radiola.data.remote.dto
import kotlinx.serialization.Serializable
/** DTO ответа чартов — список позиций. */
@Serializable
data class ChartsResponseDto(
val items: List<ChartEntryDto> = emptyList()
)
/** Одна позиция в чарте. */
@Serializable
data class ChartEntryDto(
val rank: Int,
val trackId: String,
val artist: String,
val song: String,
val coverUrl: String? = null,
val plays: Int = 0,
val stationsCount: Int = 0,
val likes: Int = 0,
val prevRank: Int? = null,
/** Направление: up | down | new | same */
val trend: String = "same"
)
/** Подробная статистика трека. */
@Serializable
data class TrackStatsDto(
val trackId: String,
val artist: String,
val song: String,
val album: String? = null,
val coverUrl: String? = null,
val releaseDate: String? = null,
val firstSeen: String? = null,
val totalPlays: Int = 0,
val totalLikes: Int = 0,
val isLiked: Boolean = false,
val currentRank: Int? = null,
val peakRank: Int? = null,
val stations: List<StationPlaysDto> = emptyList(),
val playsTimeline: List<PointDto> = emptyList(),
val likesTimeline: List<PointDto> = emptyList()
)
/** Проигрывания на конкретной станции. */
@Serializable
data class StationPlaysDto(
val stationId: Int,
val name: String,
val plays: Int
)
/** Одна точка тайм-лайна. */
@Serializable
data class PointDto(
val date: String,
val value: Int
)

View File

@@ -0,0 +1,197 @@
package com.radiola.data.repository
import android.util.Log
import com.radiola.data.remote.RadiolaApi
import com.radiola.data.remote.dto.ChartEntryDto
import com.radiola.data.remote.dto.PointDto
import com.radiola.data.remote.dto.StationPlaysDto
import com.radiola.data.remote.dto.TrackStatsDto
import com.radiola.domain.model.ChartEntry
import com.radiola.domain.model.ChartPeriod
import com.radiola.domain.model.ChartTrend
import com.radiola.domain.model.StatPoint
import com.radiola.domain.model.StationPlays
import com.radiola.domain.model.TrackStats
import com.radiola.domain.repository.ChartsRepository
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ChartsRepositoryImpl @Inject constructor(
private val api: RadiolaApi
) : ChartsRepository {
// TODO: убрать превью, когда бэкенд отдаёт charts
override suspend fun getCharts(period: ChartPeriod): List<ChartEntry> {
return try {
val response = api.getCharts(period.apiValue)
response.items.map { it.toDomain() }
} catch (e: Exception) {
Log.w("ChartsRepository", "Эндпоинт charts недоступен, возвращаем превью-данные: ${e.message}")
SAMPLE_CHARTS
}
}
// TODO: убрать превью, когда бэкенд отдаёт charts
override suspend fun getTrackStats(trackId: String): TrackStats {
return try {
val dto = api.getTrackStats(trackId)
dto.toDomain()
} catch (e: Exception) {
Log.w("ChartsRepository", "Эндпоинт getTrackStats недоступен, возвращаем превью-данные: ${e.message}")
sampleStats(trackId)
}
}
override suspend fun setLiked(trackId: String, liked: Boolean) {
try {
if (liked) api.likeTrack(trackId) else api.unlikeTrack(trackId)
} catch (e: Exception) {
Log.w("ChartsRepository", "Ошибка лайка трека $trackId: ${e.message}")
}
}
// ---- Маппинг DTO → Domain ----
private fun ChartEntryDto.toDomain() = ChartEntry(
rank = rank,
trackId = trackId,
artist = artist,
song = song,
coverUrl = coverUrl,
plays = plays,
stationsCount = stationsCount,
likes = likes,
prevRank = prevRank,
trend = when (trend) {
"up" -> ChartTrend.UP
"down" -> ChartTrend.DOWN
"new" -> ChartTrend.NEW
else -> ChartTrend.SAME
}
)
private fun TrackStatsDto.toDomain() = TrackStats(
trackId = trackId,
artist = artist,
song = song,
album = album,
coverUrl = coverUrl,
releaseDate = releaseDate,
firstSeen = firstSeen,
totalPlays = totalPlays,
totalLikes = totalLikes,
isLiked = isLiked,
currentRank = currentRank,
peakRank = peakRank,
stations = stations.map { it.toDomain() },
playsTimeline = playsTimeline.map { it.toDomain() },
likesTimeline = likesTimeline.map { it.toDomain() }
)
private fun StationPlaysDto.toDomain() = StationPlays(stationId, name, plays)
private fun PointDto.toDomain(): StatPoint {
val epochMs = try {
Instant.parse(date).toEpochMilli()
} catch (e: Exception) {
// Попробуем как yyyy-MM-dd
try {
LocalDate.parse(date, DateTimeFormatter.ISO_LOCAL_DATE)
.atStartOfDay(ZoneOffset.UTC)
.toInstant()
.toEpochMilli()
} catch (e2: Exception) {
System.currentTimeMillis()
}
}
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()

View File

@@ -0,0 +1,21 @@
package com.radiola.data.repository
import com.radiola.domain.repository.LyricsRepository
import java.net.URLEncoder
import javax.inject.Inject
import javax.inject.Singleton
// Тексты песен — авторское право. Показываем ссылку на лицензированный сервис,
// полный текст не храним/не встраиваем.
// Для сниппета подключить официальный Musixmatch API (с атрибуцией).
@Singleton
class LyricsRepositoryImpl @Inject constructor() : LyricsRepository {
override fun providerUrl(artist: String, song: String): String {
val query = URLEncoder.encode("$artist $song", "UTF-8")
return "https://www.musixmatch.com/search/$query"
}
// TODO: подключить официальный Musixmatch API (с атрибуцией) и вернуть реальный сниппет.
override suspend fun snippet(artist: String, song: String): String? = null
}

View File

@@ -11,7 +11,9 @@ import com.radiola.data.remote.AuthInterceptor
import com.radiola.data.remote.RecordApi
import com.radiola.data.remote.RadiolaApi
import com.radiola.data.repository.AuthRepositoryImpl
import com.radiola.data.repository.ChartsRepositoryImpl
import com.radiola.data.repository.FavoritesRepositoryImpl
import com.radiola.data.repository.LyricsRepositoryImpl
import com.radiola.data.repository.NowPlayingRepositoryImpl
import com.radiola.data.repository.RecordingRepositoryImpl
import com.radiola.data.repository.SettingsRepositoryImpl
@@ -19,7 +21,9 @@ import com.radiola.data.repository.StationRepositoryImpl
import com.radiola.data.repository.SyncRepositoryImpl
import com.radiola.data.repository.TrackHistoryRepositoryImpl
import com.radiola.domain.repository.AuthRepository
import com.radiola.domain.repository.ChartsRepository
import com.radiola.domain.repository.FavoritesRepository
import com.radiola.domain.repository.LyricsRepository
import com.radiola.domain.repository.SyncRepository
import com.radiola.domain.repository.NowPlayingRepository
import com.radiola.domain.repository.RecordingRepository
@@ -145,4 +149,12 @@ object AppModule {
@Provides
@Singleton
fun provideSyncRepository(impl: SyncRepositoryImpl): SyncRepository = impl
@Provides
@Singleton
fun provideChartsRepository(impl: ChartsRepositoryImpl): ChartsRepository = impl
@Provides
@Singleton
fun provideLyricsRepository(impl: LyricsRepositoryImpl): LyricsRepository = impl
}

View File

@@ -0,0 +1,26 @@
package com.radiola.domain.model
/** Период чарта, выбираемый пользователем. */
enum class ChartPeriod(val apiValue: String, val label: String) {
DAY("day", "День"),
WEEK("week", "Неделя"),
MONTH("month", "Месяц"),
ALL("all", "Всё время")
}
/** Направление движения позиции в чарте. */
enum class ChartTrend { UP, DOWN, NEW, SAME }
/** Одна позиция в чарте. */
data class ChartEntry(
val rank: Int,
val trackId: String,
val artist: String,
val song: String,
val coverUrl: String?,
val plays: Int,
val stationsCount: Int,
val likes: Int,
val prevRank: Int?,
val trend: ChartTrend
)

View File

@@ -0,0 +1,34 @@
package com.radiola.domain.model
/** Одна точка на графике популярности. */
data class StatPoint(
/** Метка времени в epoch-миллисекундах. */
val date: Long,
val value: Int
)
/** Проигрывания на конкретной станции. */
data class StationPlays(
val stationId: Int,
val name: String,
val plays: Int
)
/** Полная статистика трека (детальная карточка). */
data class TrackStats(
val trackId: String,
val artist: String,
val song: String,
val album: String?,
val coverUrl: String?,
val releaseDate: String?,
val firstSeen: String?,
val totalPlays: Int,
val totalLikes: Int,
val isLiked: Boolean,
val currentRank: Int?,
val peakRank: Int?,
val stations: List<StationPlays>,
val playsTimeline: List<StatPoint>,
val likesTimeline: List<StatPoint>
)

View File

@@ -0,0 +1,11 @@
package com.radiola.domain.repository
import com.radiola.domain.model.ChartEntry
import com.radiola.domain.model.ChartPeriod
import com.radiola.domain.model.TrackStats
interface ChartsRepository {
suspend fun getCharts(period: ChartPeriod): List<ChartEntry>
suspend fun getTrackStats(trackId: String): TrackStats
suspend fun setLiked(trackId: String, liked: Boolean)
}

View File

@@ -0,0 +1,15 @@
package com.radiola.domain.repository
// Тексты песен — авторское право. Показываем ссылку на лицензированный сервис,
// полный текст не храним/не встраиваем.
// Для сниппета подключить официальный Musixmatch API (с атрибуцией).
interface LyricsRepository {
/** URL поиска на лицензированном сервисе Musixmatch. */
fun providerUrl(artist: String, song: String): String
/**
* Лицензированный сниппет текста.
* TODO: подключить официальный Musixmatch API (с атрибуцией) и вернуть реальный сниппет.
*/
suspend fun snippet(artist: String, song: String): String? = null
}

View File

@@ -0,0 +1,701 @@
package com.radiola.ui.charts
import android.content.Intent
import android.net.Uri
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import coil.compose.AsyncImage
import com.composables.icons.lucide.*
import com.radiola.domain.model.ChartEntry
import com.radiola.domain.model.ChartPeriod
import com.radiola.domain.model.ChartTrend
import com.radiola.domain.model.DeeplinkService
import com.radiola.domain.model.StatPoint
import com.radiola.domain.model.TrackStats
import com.radiola.ui.components.EmptyState
import com.radiola.ui.components.PopularityChart
import com.radiola.ui.components.crossfadeModel
import com.radiola.ui.components.serviceLogoRes
import com.radiola.ui.theme.Motion
import com.radiola.ui.theme.RadiolaTheme
import com.radiola.ui.theme.pressScale
import java.text.DecimalFormat
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Composable
fun ChartsScreen(
modifier: Modifier = Modifier,
viewModel: ChartsViewModel = hiltViewModel()
) {
val colors = RadiolaTheme.colors
val period by viewModel.period.collectAsState()
val charts by viewModel.charts.collectAsState()
val isPreview by viewModel.isPreview.collectAsState()
val isLoadingCharts by viewModel.isLoadingCharts.collectAsState()
val selectedStats by viewModel.selectedTrackStats.collectAsState()
val isLoadingStats by viewModel.isLoadingStats.collectAsState()
Column(
modifier = modifier
.fillMaxSize()
.background(colors.bgBase)
) {
// Двухцветный заголовок
Column(modifier = Modifier.padding(horizontal = 20.dp)) {
Text(
text = buildAnnotatedString {
withStyle(SpanStyle(color = colors.textPrimary)) { append("Ча") }
withStyle(SpanStyle(color = colors.accent)) { append("рты") }
},
style = MaterialTheme.typography.headlineLarge,
modifier = Modifier.padding(top = 20.dp, bottom = 2.dp)
)
Text(
text = "Популярное на всех станциях",
style = MaterialTheme.typography.bodyMedium,
color = colors.textSecondary,
modifier = Modifier.padding(bottom = 16.dp)
)
}
// Сегменты периода (стиль FilterChips)
PeriodSelector(
selected = period,
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
)
}
Spacer(Modifier.height(10.dp))
}
// Список чартов
if (isLoadingCharts) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = colors.accent)
}
} else if (charts.isEmpty()) {
EmptyState(
message = "Чарты пока недоступны",
icon = Lucide.TrendingUp
)
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 88.dp)
) {
itemsIndexed(
items = charts,
key = { _, entry -> entry.trackId }
) { _, entry ->
ChartRow(
entry = entry,
modifier = Modifier.animateItemPlacement(),
onClick = { viewModel.selectTrack(entry.trackId) }
)
HorizontalDivider(
color = colors.border.copy(alpha = 0.4f),
thickness = 0.5.dp,
modifier = Modifier.padding(horizontal = 20.dp)
)
}
}
}
}
// Детальная карточка — ModalBottomSheet
if (selectedStats != null || isLoadingStats) {
TrackDetailSheet(
stats = selectedStats,
isLoading = isLoadingStats,
onDismiss = viewModel::clearSelection,
onToggleLike = { viewModel.toggleLike(it) },
onLyricsClick = { artist, song ->
// Строим URL Musixmatch и открываем в браузере
}
)
}
}
// ---- Селектор периода ----
@Composable
private fun PeriodSelector(
selected: ChartPeriod,
onSelect: (ChartPeriod) -> Unit
) {
val colors = RadiolaTheme.colors
LazyRow(
horizontalArrangement = Arrangement.spacedBy(9.dp),
contentPadding = PaddingValues(horizontal = 20.dp)
) {
items(ChartPeriod.entries) { p ->
PeriodChip(
label = p.label,
selected = selected == p,
onClick = { onSelect(p) }
)
}
}
}
@Composable
private fun PeriodChip(label: String, selected: Boolean, onClick: () -> Unit) {
val colors = RadiolaTheme.colors
val bg by animateColorAsState(
targetValue = if (selected) colors.accent else colors.surface2,
animationSpec = tween(Motion.Medium),
label = "periodChipBg"
)
val fg by animateColorAsState(
targetValue = if (selected) colors.bgBase else colors.textSecondary,
animationSpec = tween(Motion.Medium),
label = "periodChipFg"
)
Text(
text = label,
color = fg,
fontWeight = FontWeight.SemiBold,
style = MaterialTheme.typography.labelLarge,
modifier = Modifier
.clip(RoundedCornerShape(18.dp))
.background(bg)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onClick
)
.padding(horizontal = 16.dp, vertical = 9.dp)
)
}
// ---- Строка чарта ----
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun ChartRow(
entry: ChartEntry,
modifier: Modifier = Modifier,
onClick: () -> Unit
) {
val colors = RadiolaTheme.colors
val interaction = remember { MutableInteractionSource() }
Row(
modifier = modifier
.fillMaxWidth()
.pressScale(interactionSource = interaction)
.clickable(interactionSource = interaction, indication = null, onClick = onClick)
.padding(horizontal = 20.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
// Номер ранга
val rankColor = when (entry.rank) {
1, 2, 3 -> colors.accent
else -> colors.textMuted
}
Text(
text = entry.rank.toString(),
style = MaterialTheme.typography.titleMedium.copy(
fontWeight = FontWeight.Bold,
fontSize = 18.sp
),
color = rankColor,
modifier = Modifier.width(30.dp)
)
// Обложка трека
Box(
modifier = Modifier
.size(50.dp)
.clip(RoundedCornerShape(8.dp))
.background(colors.surface2)
) {
if (entry.coverUrl != null) {
AsyncImage(
model = crossfadeModel(entry.coverUrl),
contentDescription = "${entry.artist}${entry.song}",
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
)
} else {
Icon(
imageVector = Lucide.Music,
contentDescription = null,
tint = colors.textMuted,
modifier = Modifier
.size(22.dp)
.align(Alignment.Center)
)
}
}
// Название + исполнитель
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = entry.song,
style = MaterialTheme.typography.titleMedium,
color = colors.textPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = entry.artist,
style = MaterialTheme.typography.bodyMedium,
color = colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
// Правая часть: иконка тренда + число проигрываний
Column(
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
TrendIcon(trend = entry.trend)
Text(
text = formatPlays(entry.plays),
style = MaterialTheme.typography.labelSmall,
color = colors.textMuted
)
}
}
}
@Composable
private fun TrendIcon(trend: ChartTrend) {
val colors = RadiolaTheme.colors
when (trend) {
ChartTrend.UP -> Icon(
imageVector = Lucide.TrendingUp,
contentDescription = "Рост",
tint = colors.accent,
modifier = Modifier.size(16.dp)
)
ChartTrend.DOWN -> Icon(
imageVector = Lucide.TrendingDown,
contentDescription = "Падение",
tint = colors.live,
modifier = Modifier.size(16.dp)
)
ChartTrend.NEW -> Icon(
imageVector = Lucide.Sparkles,
contentDescription = "Новинка",
tint = colors.accent,
modifier = Modifier.size(16.dp)
)
ChartTrend.SAME -> Icon(
imageVector = Lucide.Minus,
contentDescription = "Без изменений",
tint = colors.textMuted,
modifier = Modifier.size(16.dp)
)
}
}
// ---- Детальная карточка трека ----
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
private fun TrackDetailSheet(
stats: TrackStats?,
isLoading: Boolean,
onDismiss: () -> Unit,
onToggleLike: (String) -> Unit,
onLyricsClick: (artist: String, song: String) -> Unit
) {
val colors = RadiolaTheme.colors
val context = LocalContext.current
val haptic = LocalHapticFeedback.current
ModalBottomSheet(
onDismissRequest = onDismiss,
containerColor = colors.elevated,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
) {
if (isLoading || stats == null) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(280.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(color = colors.accent)
}
} else {
LazyColumn(
contentPadding = PaddingValues(bottom = 32.dp)
) {
item {
// Большая обложка
Box(
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
.background(colors.surface2)
) {
if (stats.coverUrl != null) {
AsyncImage(
model = crossfadeModel(stats.coverUrl),
contentDescription = "${stats.artist}${stats.song}",
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
)
} else {
Icon(
imageVector = Lucide.Music,
contentDescription = null,
tint = colors.textMuted,
modifier = Modifier
.size(48.dp)
.align(Alignment.Center)
)
}
}
Spacer(Modifier.height(16.dp))
// Название + исполнитель
Column(modifier = Modifier.padding(horizontal = 20.dp)) {
Text(
text = stats.song,
style = MaterialTheme.typography.headlineLarge,
color = colors.textPrimary,
maxLines = 1,
modifier = Modifier.basicMarquee()
)
Text(
text = stats.artist,
style = MaterialTheme.typography.titleLarge,
color = colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (stats.album != null) {
Text(
text = stats.album,
style = MaterialTheme.typography.bodyMedium,
color = colors.textMuted,
modifier = Modifier.padding(top = 2.dp)
)
}
if (stats.releaseDate != null) {
Text(
text = "Вышел: ${stats.releaseDate}",
style = MaterialTheme.typography.labelSmall,
color = colors.textMuted,
modifier = Modifier.padding(top = 2.dp)
)
}
Spacer(Modifier.height(16.dp))
// Ряд метрик
MetricsRow(stats)
Spacer(Modifier.height(20.dp))
// График популярности
if (stats.playsTimeline.size >= 2) {
Text(
text = "Популярность за 30 дней",
style = MaterialTheme.typography.labelLarge,
color = colors.textSecondary,
modifier = Modifier.padding(bottom = 8.dp)
)
PopularityChart(
points = stats.playsTimeline,
modifier = Modifier
.fillMaxWidth()
.height(90.dp)
.clip(RoundedCornerShape(8.dp))
.background(colors.surface2)
.padding(horizontal = 8.dp, vertical = 12.dp)
)
Spacer(Modifier.height(20.dp))
}
// Кнопка лайка
LikeButton(
isLiked = stats.isLiked,
likesCount = stats.totalLikes,
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onToggleLike(stats.trackId)
}
)
Spacer(Modifier.height(20.dp))
// Кнопка «Текст песни»
OutlinedButton(
onClick = {
// Строим URL Musixmatch и открываем в браузере
val query = java.net.URLEncoder.encode(
"${stats.artist} ${stats.song}", "UTF-8"
)
val url = "https://www.musixmatch.com/search/$query"
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
context.startActivity(intent)
},
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = colors.textSecondary
),
border = androidx.compose.foundation.BorderStroke(
1.dp, colors.border
)
) {
Icon(
imageVector = Lucide.FileText,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(Modifier.width(8.dp))
Text("Текст песни")
}
Spacer(Modifier.height(20.dp))
}
// Кнопки музыкальных сервисов
Text(
text = "Слушать в сервисе",
style = MaterialTheme.typography.labelLarge,
color = colors.textSecondary,
modifier = Modifier.padding(horizontal = 20.dp, vertical = 0.dp)
)
Spacer(Modifier.height(12.dp))
}
// Сетка сервисов
items(DeeplinkService.entries) { service ->
ServiceRow(
service = service,
onClick = {
val url = service.buildSearchUrl(stats.artist, stats.song)
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
context.startActivity(Intent.createChooser(intent, "Открыть в..."))
}
)
}
}
}
}
}
// ---- Метрики трека ----
@Composable
private fun MetricsRow(stats: TrackStats) {
val colors = RadiolaTheme.colors
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth()
) {
MetricChip(
label = formatPlays(stats.totalPlays),
description = "проигрываний",
modifier = Modifier.weight(1f)
)
MetricChip(
label = formatPlays(stats.totalLikes),
description = "лайков",
modifier = Modifier.weight(1f)
)
if (stats.peakRank != null) {
MetricChip(
label = "#${stats.peakRank}",
description = "пик",
modifier = Modifier.weight(1f)
)
}
}
}
@Composable
private fun MetricChip(label: String, description: String, modifier: Modifier = Modifier) {
val colors = RadiolaTheme.colors
Column(
modifier = modifier
.clip(RoundedCornerShape(10.dp))
.background(colors.surface2)
.padding(horizontal = 10.dp, vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = label,
style = MaterialTheme.typography.titleMedium,
color = colors.accent,
fontWeight = FontWeight.Bold
)
Text(
text = description,
style = MaterialTheme.typography.labelSmall,
color = colors.textMuted,
maxLines = 1
)
}
}
// ---- Кнопка лайка ----
@Composable
private fun LikeButton(isLiked: Boolean, likesCount: Int, onClick: () -> Unit) {
val colors = RadiolaTheme.colors
val heartColor by animateColorAsState(
targetValue = if (isLiked) colors.live else colors.textMuted,
animationSpec = tween(Motion.Fast),
label = "heartColor"
)
val interaction = remember { MutableInteractionSource() }
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(colors.surface2)
.pressScale(interactionSource = interaction)
.clickable(interactionSource = interaction, indication = null, onClick = onClick)
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Icon(
imageVector = if (isLiked) Lucide.Heart else Lucide.Heart,
contentDescription = if (isLiked) "Убрать лайк" else "Поставить лайк",
tint = heartColor,
modifier = Modifier.size(22.dp)
)
Text(
text = if (isLiked) "Нравится · ${formatPlays(likesCount)}" else "Нравится · ${formatPlays(likesCount)}",
style = MaterialTheme.typography.bodyMedium,
color = heartColor
)
}
}
// ---- Строка сервиса ----
@Composable
private fun ServiceRow(service: DeeplinkService, onClick: () -> Unit) {
val colors = RadiolaTheme.colors
val interaction = remember { MutableInteractionSource() }
Row(
modifier = Modifier
.fillMaxWidth()
.pressScale(interactionSource = interaction)
.clickable(interactionSource = interaction, indication = null, onClick = onClick)
.padding(horizontal = 20.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp)
) {
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(colors.surface2),
contentAlignment = Alignment.Center
) {
val logoRes = serviceLogoRes(service)
if (logoRes != null) {
Icon(
painter = androidx.compose.ui.res.painterResource(logoRes),
contentDescription = service.displayName,
tint = colors.textSecondary,
modifier = Modifier.size(20.dp)
)
} else {
Icon(
imageVector = Lucide.Music,
contentDescription = service.displayName,
tint = colors.textSecondary,
modifier = Modifier.size(18.dp)
)
}
}
Text(
text = service.displayName,
style = MaterialTheme.typography.bodyMedium,
color = colors.textPrimary
)
Spacer(Modifier.weight(1f))
Icon(
imageVector = Lucide.ChevronRight,
contentDescription = null,
tint = colors.textMuted,
modifier = Modifier.size(16.dp)
)
}
}
// ---- Утилиты ----
/** Форматирует число проигрываний: 1234 → «1.2k», 1_000_000 → «1.0M». */
private fun formatPlays(value: Int): String {
val df = DecimalFormat("#.#")
return when {
value >= 1_000_000 -> "${df.format(value / 1_000_000.0)}M"
value >= 1_000 -> "${df.format(value / 1_000.0)}k"
else -> value.toString()
}
}

View File

@@ -0,0 +1,108 @@
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
import com.radiola.domain.repository.ChartsRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class ChartsViewModel @Inject constructor(
private val chartsRepository: ChartsRepository
) : ViewModel() {
private val _period = MutableStateFlow(ChartPeriod.WEEK)
val period: StateFlow<ChartPeriod> = _period.asStateFlow()
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 _isLoadingCharts = MutableStateFlow(false)
val isLoadingCharts: StateFlow<Boolean> = _isLoadingCharts.asStateFlow()
private val _selectedTrackStats = MutableStateFlow<TrackStats?>(null)
val selectedTrackStats: StateFlow<TrackStats?> = _selectedTrackStats.asStateFlow()
private val _isLoadingStats = MutableStateFlow(false)
val isLoadingStats: StateFlow<Boolean> = _isLoadingStats.asStateFlow()
init {
loadCharts()
}
fun selectPeriod(newPeriod: ChartPeriod) {
if (_period.value == newPeriod) return
_period.value = newPeriod
loadCharts()
}
fun selectTrack(trackId: String) {
viewModelScope.launch {
_isLoadingStats.value = true
_selectedTrackStats.value = null
try {
val stats = chartsRepository.getTrackStats(trackId)
_selectedTrackStats.value = stats
} catch (e: Exception) {
Log.e("ChartsViewModel", "Ошибка загрузки статистики трека $trackId", e)
} finally {
_isLoadingStats.value = false
}
}
}
fun clearSelection() {
_selectedTrackStats.value = null
}
fun toggleLike(trackId: String) {
val stats = _selectedTrackStats.value ?: return
val newLiked = !stats.isLiked
// Оптимистично обновляем UI
_selectedTrackStats.value = stats.copy(
isLiked = newLiked,
totalLikes = if (newLiked) stats.totalLikes + 1 else stats.totalLikes - 1
)
viewModelScope.launch {
try {
chartsRepository.setLiked(trackId, newLiked)
} catch (e: Exception) {
Log.e("ChartsViewModel", "Ошибка переключения лайка трека $trackId", e)
// Откатываем при ошибке
_selectedTrackStats.value = stats
}
}
}
private fun loadCharts() {
viewModelScope.launch {
_isLoadingCharts.value = true
try {
val result = chartsRepository.getCharts(_period.value)
_charts.value = result
// Определяем, это превью или реальные данные.
// TODO: убрать проверку, когда бэкенд отдаёт charts.
// Если список совпадает с образцом — это превью.
_isPreview.value = result == ChartsRepositoryImpl.SAMPLE_CHARTS
} catch (e: Exception) {
Log.e("ChartsViewModel", "Ошибка загрузки чартов", e)
_isPreview.value = true
} finally {
_isLoadingCharts.value = false
}
}
}
}

View File

@@ -0,0 +1,133 @@
package com.radiola.ui.components
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.unit.dp
import com.radiola.domain.model.StatPoint
import com.radiola.ui.theme.RadiolaTheme
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Компонент-спарклайн: сглаженный линейный график с градиентной заливкой.
* Используется для отображения популярности трека (проигрывания / лайки).
* Не показывает оси — только форму данных.
*/
@Composable
fun PopularityChart(
points: List<StatPoint>,
modifier: Modifier = Modifier,
lineColor: Color = RadiolaTheme.colors.accent
) {
val colors = RadiolaTheme.colors
val dateFmt = remember { SimpleDateFormat("d MMM", Locale("ru")) }
Box(modifier = modifier) {
if (points.size >= 2) {
val minVal = points.minOf { it.value }.toFloat()
val maxVal = points.maxOf { it.value }.toFloat()
val range = (maxVal - minVal).coerceAtLeast(1f)
Canvas(modifier = Modifier.fillMaxSize()) {
val w = size.width
val h = size.height
val topPad = 4.dp.toPx()
val botPad = 4.dp.toPx()
val drawH = h - topPad - botPad
// Вычисляем координаты точек
fun xAt(i: Int) = i * w / (points.size - 1)
fun yAt(v: Float) = topPad + drawH * (1f - (v - minVal) / range)
// Сглаженный path через cubic bezier
val linePath = Path()
linePath.moveTo(xAt(0), yAt(points[0].value.toFloat()))
for (i in 1 until points.size) {
val x0 = xAt(i - 1)
val y0 = yAt(points[i - 1].value.toFloat())
val x1 = xAt(i)
val y1 = yAt(points[i].value.toFloat())
val cx = (x0 + x1) / 2f
linePath.cubicTo(cx, y0, cx, y1, x1, y1)
}
// Заливка под графиком
val fillPath = Path().apply {
addPath(linePath)
lineTo(xAt(points.size - 1), h)
lineTo(xAt(0), h)
close()
}
drawPath(
path = fillPath,
brush = Brush.verticalGradient(
colors = listOf(lineColor.copy(alpha = 0.28f), Color.Transparent),
startY = topPad,
endY = h
)
)
// Линия графика
drawPath(
path = linePath,
color = lineColor,
style = Stroke(
width = 2.dp.toPx(),
cap = StrokeCap.Round,
join = StrokeJoin.Round
)
)
// Точка первого и последнего значения
drawCircle(
color = lineColor,
radius = 3.dp.toPx(),
center = Offset(xAt(0), yAt(points.first().value.toFloat()))
)
drawCircle(
color = lineColor,
radius = 3.dp.toPx(),
center = Offset(xAt(points.size - 1), yAt(points.last().value.toFloat()))
)
}
// Подписи дат по краям
val firstDate = dateFmt.format(Date(points.first().date))
val lastDate = dateFmt.format(Date(points.last().date))
Text(
text = firstDate,
style = MaterialTheme.typography.labelSmall,
color = colors.textMuted,
modifier = Modifier
.align(Alignment.BottomStart)
.padding(start = 4.dp, bottom = 2.dp)
)
Text(
text = lastDate,
style = MaterialTheme.typography.labelSmall,
color = colors.textMuted,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = 4.dp, bottom = 2.dp)
)
}
// При 0 или 1 точке — ничего не рисуем (корректная пустая обработка)
}
}

View File

@@ -41,6 +41,7 @@ fun BottomNavBar(navController: NavController) {
// при холодном старте может содержать null (порядок инициализации Kotlin).
val items = listOf(
NavDestinations.Stations,
NavDestinations.Charts,
NavDestinations.Favorites,
NavDestinations.History,
NavDestinations.Recordings,

View File

@@ -7,6 +7,7 @@ import com.composables.icons.lucide.History
import com.composables.icons.lucide.Mic
import com.composables.icons.lucide.Radio
import com.composables.icons.lucide.Settings
import com.composables.icons.lucide.TrendingUp
sealed class NavDestinations(
val route: String,
@@ -15,6 +16,7 @@ sealed class NavDestinations(
val showInBottomBar: Boolean = true
) {
data object Stations : NavDestinations("stations", "Радио", Lucide.Radio)
data object Charts : NavDestinations("charts", "Чарты", Lucide.TrendingUp)
data object Favorites : NavDestinations("favorites", "Избранное", Lucide.Heart)
data object History : NavDestinations("history", "История", Lucide.History)
data object Recordings : NavDestinations("recordings", "Записи", Lucide.Mic)
@@ -22,6 +24,6 @@ sealed class NavDestinations(
data object Auth : NavDestinations("auth", "Вход", Lucide.Settings, showInBottomBar = false)
companion object {
val items = listOf(Stations, Favorites, History, Recordings, Settings)
val items = listOf(Stations, Charts, Favorites, History, Recordings, Settings)
}
}