feat(charts): раздел «Чарты» (клиент) + детальная страница трека с графиком
- вкладка «Чарты» в навигации; экран: периоды (День/Неделя/Месяц/Всё), ранжированный список треков (ранг, обложка, проигрывания, тренд) - детальная карточка трека: метрики, график популярности (Canvas), лайк, кнопки музыкальных сервисов, кнопка «Текст песни» (ссылка на лицензированный Musixmatch — полный текст не встраиваем, авторское право) - ChartsRepository/LyricsRepository + эндпоинты charts/* в RadiolaApi (DTO) - превью-данные пока бэкенд не отдаёт charts (помечено TODO)
This commit is contained in:
133
app/src/main/java/com/radiola/ui/components/Sparkline.kt
Normal file
133
app/src/main/java/com/radiola/ui/components/Sparkline.kt
Normal 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 точке — ничего не рисуем (корректная пустая обработка)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user