diff --git a/app/src/main/java/com/radiola/MainActivity.kt b/app/src/main/java/com/radiola/MainActivity.kt index cec4f61..e04e3e8 100644 --- a/app/src/main/java/com/radiola/MainActivity.kt +++ b/app/src/main/java/com/radiola/MainActivity.kt @@ -18,6 +18,7 @@ import androidx.navigation.compose.rememberNavController import androidx.lifecycle.lifecycleScope import com.radiola.data.local.TokenDataStore import com.radiola.ui.auth.AuthScreen +import com.radiola.ui.charts.ChartsScreen import com.radiola.ui.components.MiniPlayer import com.radiola.ui.favorites.FavoritesScreen import com.radiola.ui.favorites.FavoritesViewModel @@ -118,6 +119,9 @@ class MainActivity : ComponentActivity() { } ) } + composable(NavDestinations.Charts.route) { + ChartsScreen() + } composable(NavDestinations.Favorites.route) { FavoritesScreen( onStationClick = { station -> diff --git a/app/src/main/java/com/radiola/data/remote/RadiolaApi.kt b/app/src/main/java/com/radiola/data/remote/RadiolaApi.kt index d362b39..51fd50f 100644 --- a/app/src/main/java/com/radiola/data/remote/RadiolaApi.kt +++ b/app/src/main/java/com/radiola/data/remote/RadiolaApi.kt @@ -2,9 +2,11 @@ package com.radiola.data.remote import com.radiola.data.remote.dto.AuthResponseDto import com.radiola.data.remote.dto.BackendStationDto +import com.radiola.data.remote.dto.ChartsResponseDto import com.radiola.data.remote.dto.HistoryResponseDto import com.radiola.data.remote.dto.MagicLinkRequestDto import com.radiola.data.remote.dto.MagicLinkVerifyDto +import com.radiola.data.remote.dto.TrackStatsDto import com.radiola.data.remote.dto.UserSettingsDto import kotlinx.serialization.json.JsonObject import retrofit2.http.Body @@ -13,6 +15,7 @@ import retrofit2.http.GET import retrofit2.http.POST import retrofit2.http.PATCH import retrofit2.http.Path +import retrofit2.http.Query interface RadiolaApi { @@ -45,4 +48,21 @@ interface RadiolaApi { @POST("users/me/history/{stationId}") suspend fun addHistory(@Path("stationId") stationId: String): JsonObject + + // --- Чарты --- + + @GET("charts/tracks") + suspend fun getCharts( + @Query("period") period: String, + @Query("limit") limit: Int = 100 + ): ChartsResponseDto + + @GET("charts/tracks/{trackId}") + suspend fun getTrackStats(@Path("trackId") trackId: String): TrackStatsDto + + @POST("charts/tracks/{trackId}/like") + suspend fun likeTrack(@Path("trackId") trackId: String): JsonObject + + @DELETE("charts/tracks/{trackId}/like") + suspend fun unlikeTrack(@Path("trackId") trackId: String): JsonObject } diff --git a/app/src/main/java/com/radiola/data/remote/dto/ChartsDto.kt b/app/src/main/java/com/radiola/data/remote/dto/ChartsDto.kt new file mode 100644 index 0000000..3ea8ce3 --- /dev/null +++ b/app/src/main/java/com/radiola/data/remote/dto/ChartsDto.kt @@ -0,0 +1,60 @@ +package com.radiola.data.remote.dto + +import kotlinx.serialization.Serializable + +/** DTO ответа чартов — список позиций. */ +@Serializable +data class ChartsResponseDto( + val items: List = emptyList() +) + +/** Одна позиция в чарте. */ +@Serializable +data class ChartEntryDto( + val rank: Int, + val trackId: String, + val artist: String, + val song: String, + val coverUrl: String? = null, + val plays: Int = 0, + val stationsCount: Int = 0, + val likes: Int = 0, + val prevRank: Int? = null, + /** Направление: up | down | new | same */ + val trend: String = "same" +) + +/** Подробная статистика трека. */ +@Serializable +data class TrackStatsDto( + val trackId: String, + val artist: String, + val song: String, + val album: String? = null, + val coverUrl: String? = null, + val releaseDate: String? = null, + val firstSeen: String? = null, + val totalPlays: Int = 0, + val totalLikes: Int = 0, + val isLiked: Boolean = false, + val currentRank: Int? = null, + val peakRank: Int? = null, + val stations: List = emptyList(), + val playsTimeline: List = emptyList(), + val likesTimeline: List = emptyList() +) + +/** Проигрывания на конкретной станции. */ +@Serializable +data class StationPlaysDto( + val stationId: Int, + val name: String, + val plays: Int +) + +/** Одна точка тайм-лайна. */ +@Serializable +data class PointDto( + val date: String, + val value: Int +) diff --git a/app/src/main/java/com/radiola/data/repository/ChartsRepositoryImpl.kt b/app/src/main/java/com/radiola/data/repository/ChartsRepositoryImpl.kt new file mode 100644 index 0000000..fd67859 --- /dev/null +++ b/app/src/main/java/com/radiola/data/repository/ChartsRepositoryImpl.kt @@ -0,0 +1,197 @@ +package com.radiola.data.repository + +import android.util.Log +import com.radiola.data.remote.RadiolaApi +import com.radiola.data.remote.dto.ChartEntryDto +import com.radiola.data.remote.dto.PointDto +import com.radiola.data.remote.dto.StationPlaysDto +import com.radiola.data.remote.dto.TrackStatsDto +import com.radiola.domain.model.ChartEntry +import com.radiola.domain.model.ChartPeriod +import com.radiola.domain.model.ChartTrend +import com.radiola.domain.model.StatPoint +import com.radiola.domain.model.StationPlays +import com.radiola.domain.model.TrackStats +import com.radiola.domain.repository.ChartsRepository +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ChartsRepositoryImpl @Inject constructor( + private val api: RadiolaApi +) : ChartsRepository { + + // TODO: убрать превью, когда бэкенд отдаёт charts + override suspend fun getCharts(period: ChartPeriod): List { + return try { + val response = api.getCharts(period.apiValue) + response.items.map { it.toDomain() } + } catch (e: Exception) { + Log.w("ChartsRepository", "Эндпоинт charts недоступен, возвращаем превью-данные: ${e.message}") + SAMPLE_CHARTS + } + } + + // TODO: убрать превью, когда бэкенд отдаёт charts + override suspend fun getTrackStats(trackId: String): TrackStats { + return try { + val dto = api.getTrackStats(trackId) + dto.toDomain() + } catch (e: Exception) { + Log.w("ChartsRepository", "Эндпоинт getTrackStats недоступен, возвращаем превью-данные: ${e.message}") + sampleStats(trackId) + } + } + + override suspend fun setLiked(trackId: String, liked: Boolean) { + try { + if (liked) api.likeTrack(trackId) else api.unlikeTrack(trackId) + } catch (e: Exception) { + Log.w("ChartsRepository", "Ошибка лайка трека $trackId: ${e.message}") + } + } + + // ---- Маппинг DTO → Domain ---- + + private fun ChartEntryDto.toDomain() = ChartEntry( + rank = rank, + trackId = trackId, + artist = artist, + song = song, + coverUrl = coverUrl, + plays = plays, + stationsCount = stationsCount, + likes = likes, + prevRank = prevRank, + trend = when (trend) { + "up" -> ChartTrend.UP + "down" -> ChartTrend.DOWN + "new" -> ChartTrend.NEW + else -> ChartTrend.SAME + } + ) + + private fun TrackStatsDto.toDomain() = TrackStats( + trackId = trackId, + artist = artist, + song = song, + album = album, + coverUrl = coverUrl, + releaseDate = releaseDate, + firstSeen = firstSeen, + totalPlays = totalPlays, + totalLikes = totalLikes, + isLiked = isLiked, + currentRank = currentRank, + peakRank = peakRank, + stations = stations.map { it.toDomain() }, + playsTimeline = playsTimeline.map { it.toDomain() }, + likesTimeline = likesTimeline.map { it.toDomain() } + ) + + private fun StationPlaysDto.toDomain() = StationPlays(stationId, name, plays) + + private fun PointDto.toDomain(): StatPoint { + val epochMs = try { + Instant.parse(date).toEpochMilli() + } catch (e: Exception) { + // Попробуем как yyyy-MM-dd + try { + LocalDate.parse(date, DateTimeFormatter.ISO_LOCAL_DATE) + .atStartOfDay(ZoneOffset.UTC) + .toInstant() + .toEpochMilli() + } catch (e2: Exception) { + System.currentTimeMillis() + } + } + return StatPoint(epochMs, value) + } + + // ---- Превью-данные (используются пока бэкенд не готов) ---- + // TODO: убрать превью, когда бэкенд отдаёт charts + + private fun sampleStats(trackId: String): TrackStats { + val entry = SAMPLE_CHARTS.firstOrNull { it.trackId == trackId } + val now = System.currentTimeMillis() + val dayMs = 86_400_000L + + // 30 точек с плавным ростом + шум + fun timeline(base: Int): List = (0 until 30).map { i -> + val noise = (-base / 20..base / 20).random() + StatPoint( + date = now - (29 - i) * dayMs, + value = (base * (0.4 + i * 0.02) + noise).toInt().coerceAtLeast(0) + ) + } + + return TrackStats( + trackId = trackId, + artist = entry?.artist ?: "Неизвестный исполнитель", + song = entry?.song ?: "Неизвестный трек", + album = sampleAlbum(trackId), + coverUrl = entry?.coverUrl, + releaseDate = sampleReleaseDate(trackId), + firstSeen = "2024-01-15", + totalPlays = entry?.plays ?: 12_000, + totalLikes = entry?.likes ?: 430, + isLiked = false, + currentRank = entry?.rank, + peakRank = (entry?.rank ?: 5).coerceAtMost(3), + stations = listOf( + StationPlays(1, "Европа Плюс", 3_200), + StationPlays(2, "Авторадио", 2_800), + StationPlays(3, "Радио Energy", 1_900) + ), + playsTimeline = timeline(entry?.plays ?: 12_000), + likesTimeline = timeline(entry?.likes ?: 430) + ) + } + + private fun sampleAlbum(trackId: String): String? = when (trackId) { + "track_01" -> "Чёрный альбом" + "track_02" -> "Танцы на стёклах" + "track_03" -> "Dangerous" + "track_04" -> "Воздух" + "track_05" -> "÷ (Divide)" + else -> null + } + + private fun sampleReleaseDate(trackId: String): String? = when (trackId) { + "track_01" -> "2023-09-22" + "track_02" -> "2022-06-15" + "track_03" -> "1991-11-26" + "track_04" -> "2023-03-10" + "track_05" -> "2017-03-03" + else -> "2024-01-01" + } + + companion object { + // TODO: убрать превью, когда бэкенд отдаёт charts + val SAMPLE_CHARTS: List = listOf( + ChartEntry(1, "track_01", "Монеточка", "Каждый раз", null, 48_200, 12, 1_840, 2, ChartTrend.UP), + ChartEntry(2, "track_02", "Земфира", "Хочешь?", null, 43_100, 15, 2_100, 1, ChartTrend.DOWN), + ChartEntry(3, "track_03", "Michael Jackson", "Billie Jean", null, 41_500, 18, 3_200, null, ChartTrend.NEW), + ChartEntry(4, "track_04", "Баста", "Моя игра", null, 38_900, 11, 1_420, 5, ChartTrend.UP), + ChartEntry(5, "track_05", "Ed Sheeran", "Shape of You", null, 36_400, 20, 2_870, 4, ChartTrend.DOWN), + ChartEntry(6, "track_06", "Ленинград", "Экспонат", null, 34_700, 9, 980, 6, ChartTrend.SAME), + ChartEntry(7, "track_07", "Imagine Dragons", "Believer", null, 33_100, 16, 1_650, 8, ChartTrend.UP), + ChartEntry(8, "track_08", "Нервы", "Друг", null, 31_500, 7, 760, 7, ChartTrend.DOWN), + ChartEntry(9, "track_09", "The Weeknd", "Blinding Lights", null, 29_800, 22, 2_440, null, ChartTrend.NEW), + ChartEntry(10, "track_10", "Сплин", "Романс", null, 27_300, 10, 690, 10, ChartTrend.SAME), + ChartEntry(11, "track_11", "Dua Lipa", "Levitating", null, 25_600, 14, 1_110, 12, ChartTrend.UP), + ChartEntry(12, "track_12", "Кино", "Группа крови", null, 23_900, 8, 870, 11, ChartTrend.DOWN), + ChartEntry(13, "track_13", "Bruno Mars", "Uptown Funk", null, 22_100, 19, 1_320, null, ChartTrend.NEW), + ChartEntry(14, "track_14", "Мумий Тролль", "Невеста", null, 20_400, 6, 510, 14, ChartTrend.SAME), + ChartEntry(15, "track_15", "Coldplay", "Yellow", null, 18_700, 13, 780, 15, ChartTrend.SAME) + ) + } +} + +/** Расширение для случайного числа в диапазоне (используется в preview). */ +private fun IntRange.random(): Int = + if (first >= last) first else first + (Math.random() * (last - first + 1)).toInt() diff --git a/app/src/main/java/com/radiola/data/repository/LyricsRepositoryImpl.kt b/app/src/main/java/com/radiola/data/repository/LyricsRepositoryImpl.kt new file mode 100644 index 0000000..37de334 --- /dev/null +++ b/app/src/main/java/com/radiola/data/repository/LyricsRepositoryImpl.kt @@ -0,0 +1,21 @@ +package com.radiola.data.repository + +import com.radiola.domain.repository.LyricsRepository +import java.net.URLEncoder +import javax.inject.Inject +import javax.inject.Singleton + +// Тексты песен — авторское право. Показываем ссылку на лицензированный сервис, +// полный текст не храним/не встраиваем. +// Для сниппета подключить официальный Musixmatch API (с атрибуцией). +@Singleton +class LyricsRepositoryImpl @Inject constructor() : LyricsRepository { + + override fun providerUrl(artist: String, song: String): String { + val query = URLEncoder.encode("$artist $song", "UTF-8") + return "https://www.musixmatch.com/search/$query" + } + + // TODO: подключить официальный Musixmatch API (с атрибуцией) и вернуть реальный сниппет. + override suspend fun snippet(artist: String, song: String): String? = null +} diff --git a/app/src/main/java/com/radiola/di/AppModule.kt b/app/src/main/java/com/radiola/di/AppModule.kt index e804ddb..7c12443 100644 --- a/app/src/main/java/com/radiola/di/AppModule.kt +++ b/app/src/main/java/com/radiola/di/AppModule.kt @@ -11,7 +11,9 @@ import com.radiola.data.remote.AuthInterceptor import com.radiola.data.remote.RecordApi import com.radiola.data.remote.RadiolaApi import com.radiola.data.repository.AuthRepositoryImpl +import com.radiola.data.repository.ChartsRepositoryImpl import com.radiola.data.repository.FavoritesRepositoryImpl +import com.radiola.data.repository.LyricsRepositoryImpl import com.radiola.data.repository.NowPlayingRepositoryImpl import com.radiola.data.repository.RecordingRepositoryImpl import com.radiola.data.repository.SettingsRepositoryImpl @@ -19,7 +21,9 @@ import com.radiola.data.repository.StationRepositoryImpl import com.radiola.data.repository.SyncRepositoryImpl import com.radiola.data.repository.TrackHistoryRepositoryImpl import com.radiola.domain.repository.AuthRepository +import com.radiola.domain.repository.ChartsRepository import com.radiola.domain.repository.FavoritesRepository +import com.radiola.domain.repository.LyricsRepository import com.radiola.domain.repository.SyncRepository import com.radiola.domain.repository.NowPlayingRepository import com.radiola.domain.repository.RecordingRepository @@ -145,4 +149,12 @@ object AppModule { @Provides @Singleton fun provideSyncRepository(impl: SyncRepositoryImpl): SyncRepository = impl + + @Provides + @Singleton + fun provideChartsRepository(impl: ChartsRepositoryImpl): ChartsRepository = impl + + @Provides + @Singleton + fun provideLyricsRepository(impl: LyricsRepositoryImpl): LyricsRepository = impl } diff --git a/app/src/main/java/com/radiola/domain/model/ChartEntry.kt b/app/src/main/java/com/radiola/domain/model/ChartEntry.kt new file mode 100644 index 0000000..b9420fd --- /dev/null +++ b/app/src/main/java/com/radiola/domain/model/ChartEntry.kt @@ -0,0 +1,26 @@ +package com.radiola.domain.model + +/** Период чарта, выбираемый пользователем. */ +enum class ChartPeriod(val apiValue: String, val label: String) { + DAY("day", "День"), + WEEK("week", "Неделя"), + MONTH("month", "Месяц"), + ALL("all", "Всё время") +} + +/** Направление движения позиции в чарте. */ +enum class ChartTrend { UP, DOWN, NEW, SAME } + +/** Одна позиция в чарте. */ +data class ChartEntry( + val rank: Int, + val trackId: String, + val artist: String, + val song: String, + val coverUrl: String?, + val plays: Int, + val stationsCount: Int, + val likes: Int, + val prevRank: Int?, + val trend: ChartTrend +) diff --git a/app/src/main/java/com/radiola/domain/model/TrackStats.kt b/app/src/main/java/com/radiola/domain/model/TrackStats.kt new file mode 100644 index 0000000..dce94bd --- /dev/null +++ b/app/src/main/java/com/radiola/domain/model/TrackStats.kt @@ -0,0 +1,34 @@ +package com.radiola.domain.model + +/** Одна точка на графике популярности. */ +data class StatPoint( + /** Метка времени в epoch-миллисекундах. */ + val date: Long, + val value: Int +) + +/** Проигрывания на конкретной станции. */ +data class StationPlays( + val stationId: Int, + val name: String, + val plays: Int +) + +/** Полная статистика трека (детальная карточка). */ +data class TrackStats( + val trackId: String, + val artist: String, + val song: String, + val album: String?, + val coverUrl: String?, + val releaseDate: String?, + val firstSeen: String?, + val totalPlays: Int, + val totalLikes: Int, + val isLiked: Boolean, + val currentRank: Int?, + val peakRank: Int?, + val stations: List, + val playsTimeline: List, + val likesTimeline: List +) diff --git a/app/src/main/java/com/radiola/domain/repository/ChartsRepository.kt b/app/src/main/java/com/radiola/domain/repository/ChartsRepository.kt new file mode 100644 index 0000000..794c972 --- /dev/null +++ b/app/src/main/java/com/radiola/domain/repository/ChartsRepository.kt @@ -0,0 +1,11 @@ +package com.radiola.domain.repository + +import com.radiola.domain.model.ChartEntry +import com.radiola.domain.model.ChartPeriod +import com.radiola.domain.model.TrackStats + +interface ChartsRepository { + suspend fun getCharts(period: ChartPeriod): List + suspend fun getTrackStats(trackId: String): TrackStats + suspend fun setLiked(trackId: String, liked: Boolean) +} diff --git a/app/src/main/java/com/radiola/domain/repository/LyricsRepository.kt b/app/src/main/java/com/radiola/domain/repository/LyricsRepository.kt new file mode 100644 index 0000000..a3674bd --- /dev/null +++ b/app/src/main/java/com/radiola/domain/repository/LyricsRepository.kt @@ -0,0 +1,15 @@ +package com.radiola.domain.repository + +// Тексты песен — авторское право. Показываем ссылку на лицензированный сервис, +// полный текст не храним/не встраиваем. +// Для сниппета подключить официальный Musixmatch API (с атрибуцией). +interface LyricsRepository { + /** URL поиска на лицензированном сервисе Musixmatch. */ + fun providerUrl(artist: String, song: String): String + + /** + * Лицензированный сниппет текста. + * TODO: подключить официальный Musixmatch API (с атрибуцией) и вернуть реальный сниппет. + */ + suspend fun snippet(artist: String, song: String): String? = null +} diff --git a/app/src/main/java/com/radiola/ui/charts/ChartsScreen.kt b/app/src/main/java/com/radiola/ui/charts/ChartsScreen.kt new file mode 100644 index 0000000..e33ccf2 --- /dev/null +++ b/app/src/main/java/com/radiola/ui/charts/ChartsScreen.kt @@ -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() + } +} diff --git a/app/src/main/java/com/radiola/ui/charts/ChartsViewModel.kt b/app/src/main/java/com/radiola/ui/charts/ChartsViewModel.kt new file mode 100644 index 0000000..1dd49fd --- /dev/null +++ b/app/src/main/java/com/radiola/ui/charts/ChartsViewModel.kt @@ -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 = _period.asStateFlow() + + private val _charts = MutableStateFlow>(emptyList()) + val charts: StateFlow> = _charts.asStateFlow() + + /** true — данные из превью (бэкенд ещё не готов). */ + private val _isPreview = MutableStateFlow(false) + val isPreview: StateFlow = _isPreview.asStateFlow() + + private val _isLoadingCharts = MutableStateFlow(false) + val isLoadingCharts: StateFlow = _isLoadingCharts.asStateFlow() + + private val _selectedTrackStats = MutableStateFlow(null) + val selectedTrackStats: StateFlow = _selectedTrackStats.asStateFlow() + + private val _isLoadingStats = MutableStateFlow(false) + val isLoadingStats: StateFlow = _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 + } + } + } +} diff --git a/app/src/main/java/com/radiola/ui/components/Sparkline.kt b/app/src/main/java/com/radiola/ui/components/Sparkline.kt new file mode 100644 index 0000000..08d84ac --- /dev/null +++ b/app/src/main/java/com/radiola/ui/components/Sparkline.kt @@ -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, + 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 точке — ничего не рисуем (корректная пустая обработка) + } +} diff --git a/app/src/main/java/com/radiola/ui/navigation/BottomNavBar.kt b/app/src/main/java/com/radiola/ui/navigation/BottomNavBar.kt index 22ccb90..42dcfd2 100644 --- a/app/src/main/java/com/radiola/ui/navigation/BottomNavBar.kt +++ b/app/src/main/java/com/radiola/ui/navigation/BottomNavBar.kt @@ -41,6 +41,7 @@ fun BottomNavBar(navController: NavController) { // при холодном старте может содержать null (порядок инициализации Kotlin). val items = listOf( NavDestinations.Stations, + NavDestinations.Charts, NavDestinations.Favorites, NavDestinations.History, NavDestinations.Recordings, diff --git a/app/src/main/java/com/radiola/ui/navigation/NavDestinations.kt b/app/src/main/java/com/radiola/ui/navigation/NavDestinations.kt index 50d20ea..03e7820 100644 --- a/app/src/main/java/com/radiola/ui/navigation/NavDestinations.kt +++ b/app/src/main/java/com/radiola/ui/navigation/NavDestinations.kt @@ -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) } }