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,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 точке — ничего не рисуем (корректная пустая обработка)
}
}