- вкладка «Чарты» в навигации; экран: периоды (День/Неделя/Месяц/Всё), ранжированный список треков (ранг, обложка, проигрывания, тренд) - детальная карточка трека: метрики, график популярности (Canvas), лайк, кнопки музыкальных сервисов, кнопка «Текст песни» (ссылка на лицензированный Musixmatch — полный текст не встраиваем, авторское право) - ChartsRepository/LyricsRepository + эндпоинты charts/* в RadiolaApi (DTO) - превью-данные пока бэкенд не отдаёт charts (помечено TODO)
134 lines
5.2 KiB
Kotlin
134 lines
5.2 KiB
Kotlin
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 точке — ничего не рисуем (корректная пустая обработка)
|
||
}
|
||
}
|