feat(charts): раздел «Чарты» (клиент) + детальная страница трека с графиком
- вкладка «Чарты» в навигации; экран: периоды (День/Неделя/Месяц/Всё), ранжированный список треков (ранг, обложка, проигрывания, тренд) - детальная карточка трека: метрики, график популярности (Canvas), лайк, кнопки музыкальных сервисов, кнопка «Текст песни» (ссылка на лицензированный Musixmatch — полный текст не встраиваем, авторское право) - ChartsRepository/LyricsRepository + эндпоинты charts/* в RadiolaApi (DTO) - превью-данные пока бэкенд не отдаёт charts (помечено TODO)
This commit is contained in:
701
app/src/main/java/com/radiola/ui/charts/ChartsScreen.kt
Normal file
701
app/src/main/java/com/radiola/ui/charts/ChartsScreen.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
108
app/src/main/java/com/radiola/ui/charts/ChartsViewModel.kt
Normal file
108
app/src/main/java/com/radiola/ui/charts/ChartsViewModel.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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 точке — ничего не рисуем (корректная пустая обработка)
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,7 @@ fun BottomNavBar(navController: NavController) {
|
||||
// при холодном старте может содержать null (порядок инициализации Kotlin).
|
||||
val items = listOf(
|
||||
NavDestinations.Stations,
|
||||
NavDestinations.Charts,
|
||||
NavDestinations.Favorites,
|
||||
NavDestinations.History,
|
||||
NavDestinations.Recordings,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user