Files
radiola-android/app/src/main/java/com/radiola/ui/charts/ChartsScreen.kt
nk 251809df33 fix(filters): уменьшение чипа только у кнопки (offset=0 в покое = полный)
Баг: offset первого чипа в покое = 0 (считается от начала контента, не от вьюпорта),
а формула ждала offset≥235px → «Все» и соседние были невидимы/крошечные через пол-экрана.
Фикс: recedeFactor = info.offset / shrinkPx + 1 — полный размер в покое и правее (offset≥0),
уменьшение/затухание ТОЛЬКО когда чип уезжает влево под кнопку (offset<0, зона ~32dp).
Отступ-зазор уменьшен 96→60dp (Радио) / 100→64dp (Чарты) — «Все» вплотную к кнопке.
2026-06-07 17:55:11 +03:00

780 lines
29 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.horizontalScroll
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.rememberScrollState
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.lazy.rememberLazyListState
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.CategoryPicker
import com.radiola.ui.components.EmptyState
import com.radiola.ui.components.recede
import com.radiola.ui.components.recedeFactor
import com.radiola.ui.components.PopularityChart
import com.radiola.ui.components.crossfadeModel
import com.radiola.ui.components.serviceLogoRes
import com.radiola.ui.lyrics.LyricsSheet
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 genres by viewModel.genres.collectAsState()
val selectedGenre by viewModel.selectedGenre.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
)
// Фильтр по жанру (если бэкенд уже накопил жанры)
if (genres.isNotEmpty()) {
Spacer(Modifier.height(10.dp))
Box(modifier = Modifier.fillMaxWidth().height(44.dp)) {
GenreSelector(
genres = genres,
selected = selectedGenre,
onSelect = viewModel::selectGenre,
contentPadding = PaddingValues(start = 64.dp, end = 20.dp),
modifier = Modifier
.fillMaxWidth()
.align(Alignment.Center)
)
CategoryPicker(
title = "Стиль музыки",
items = genres,
selected = selectedGenre,
onSelect = viewModel::selectGenre,
modifier = Modifier.align(Alignment.CenterStart).padding(start = 20.dp)
)
}
}
Spacer(Modifier.height(12.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) }
)
}
}
// ---- Селектор периода ----
@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)
)
}
// ---- Селектор жанра ----
@Composable
private fun GenreSelector(
genres: List<String>,
selected: String?,
onSelect: (String?) -> Unit,
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(horizontal = 20.dp)
) {
val listState = rememberLazyListState()
val all = remember(genres) { listOf<String?>(null) + genres }
LazyRow(
modifier = modifier,
state = listState,
horizontalArrangement = Arrangement.spacedBy(9.dp),
contentPadding = contentPadding
) {
itemsIndexed(all, key = { _, g -> g ?: " all" }) { index, g ->
Box(modifier = Modifier.recede(recedeFactor(listState, index))) {
PeriodChip(
label = g ?: "Все",
selected = selected == g,
onClick = { onSelect(g) }
)
}
}
}
}
// ---- Строка чарта ----
@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
) {
val colors = RadiolaTheme.colors
val context = LocalContext.current
val haptic = LocalHapticFeedback.current
var showLyrics by remember { mutableStateOf(false) }
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)
)
}
// Лейбл · Год (либо дата релиза как запасной вариант)
val metaLine = buildList {
stats.label?.let { add(it) }
stats.year?.let { add(it.toString()) }
}.joinToString(" · ").ifEmpty {
stats.releaseDate?.let { "Вышел: $it" } ?: ""
}
if (metaLine.isNotEmpty()) {
Text(
text = metaLine,
style = MaterialTheme.typography.labelSmall,
color = colors.textMuted,
modifier = Modifier.padding(top = 2.dp)
)
}
// Жанр + стили
val genreTags = buildList {
stats.genre?.let { add(it) }
addAll(stats.styles)
}.distinct()
if (genreTags.isNotEmpty()) {
Spacer(Modifier.height(10.dp))
GenreTags(tags = genreTags)
}
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 = { showLyrics = true },
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, "Открыть в..."))
}
)
}
}
}
}
// Шторка текста песни поверх детальной карточки
if (showLyrics && stats != null) {
ModalBottomSheet(
onDismissRequest = { showLyrics = false },
containerColor = colors.bgBase,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
) {
LyricsSheet(
artist = stats.artist,
song = stats.song
)
}
}
}
// ---- Чипы жанра/стилей на детальной ----
@Composable
private fun GenreTags(tags: List<String>) {
val colors = RadiolaTheme.colors
Row(
modifier = Modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
tags.forEach { tag ->
Text(
text = tag,
style = MaterialTheme.typography.labelMedium,
color = colors.accent,
fontWeight = FontWeight.Medium,
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(colors.accent.copy(alpha = 0.12f))
.padding(horizontal = 10.dp, vertical = 5.dp)
)
}
}
}
// ---- Метрики трека ----
@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()
}
}