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

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