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, selected: String?, onSelect: (String?) -> Unit, modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(horizontal = 20.dp) ) { val listState = rememberLazyListState() val all = remember(genres) { listOf(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) { 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() } }