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

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