Баг: offset первого чипа в покое = 0 (считается от начала контента, не от вьюпорта), а формула ждала offset≥235px → «Все» и соседние были невидимы/крошечные через пол-экрана. Фикс: recedeFactor = info.offset / shrinkPx + 1 — полный размер в покое и правее (offset≥0), уменьшение/затухание ТОЛЬКО когда чип уезжает влево под кнопку (offset<0, зона ~32dp). Отступ-зазор уменьшен 96→60dp (Радио) / 100→64dp (Чарты) — «Все» вплотную к кнопке.
780 lines
29 KiB
Kotlin
780 lines
29 KiB
Kotlin
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()
|
||
}
|
||
}
|