feat(charts): раздел «Чарты» (клиент) + детальная страница трека с графиком
- вкладка «Чарты» в навигации; экран: периоды (День/Неделя/Месяц/Всё), ранжированный список треков (ранг, обложка, проигрывания, тренд) - детальная карточка трека: метрики, график популярности (Canvas), лайк, кнопки музыкальных сервисов, кнопка «Текст песни» (ссылка на лицензированный Musixmatch — полный текст не встраиваем, авторское право) - ChartsRepository/LyricsRepository + эндпоинты charts/* в RadiolaApi (DTO) - превью-данные пока бэкенд не отдаёт charts (помечено TODO)
This commit is contained in:
@@ -18,6 +18,7 @@ import androidx.navigation.compose.rememberNavController
|
|||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.radiola.data.local.TokenDataStore
|
import com.radiola.data.local.TokenDataStore
|
||||||
import com.radiola.ui.auth.AuthScreen
|
import com.radiola.ui.auth.AuthScreen
|
||||||
|
import com.radiola.ui.charts.ChartsScreen
|
||||||
import com.radiola.ui.components.MiniPlayer
|
import com.radiola.ui.components.MiniPlayer
|
||||||
import com.radiola.ui.favorites.FavoritesScreen
|
import com.radiola.ui.favorites.FavoritesScreen
|
||||||
import com.radiola.ui.favorites.FavoritesViewModel
|
import com.radiola.ui.favorites.FavoritesViewModel
|
||||||
@@ -118,6 +119,9 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
composable(NavDestinations.Charts.route) {
|
||||||
|
ChartsScreen()
|
||||||
|
}
|
||||||
composable(NavDestinations.Favorites.route) {
|
composable(NavDestinations.Favorites.route) {
|
||||||
FavoritesScreen(
|
FavoritesScreen(
|
||||||
onStationClick = { station ->
|
onStationClick = { station ->
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ package com.radiola.data.remote
|
|||||||
|
|
||||||
import com.radiola.data.remote.dto.AuthResponseDto
|
import com.radiola.data.remote.dto.AuthResponseDto
|
||||||
import com.radiola.data.remote.dto.BackendStationDto
|
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.HistoryResponseDto
|
||||||
import com.radiola.data.remote.dto.MagicLinkRequestDto
|
import com.radiola.data.remote.dto.MagicLinkRequestDto
|
||||||
import com.radiola.data.remote.dto.MagicLinkVerifyDto
|
import com.radiola.data.remote.dto.MagicLinkVerifyDto
|
||||||
|
import com.radiola.data.remote.dto.TrackStatsDto
|
||||||
import com.radiola.data.remote.dto.UserSettingsDto
|
import com.radiola.data.remote.dto.UserSettingsDto
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
import retrofit2.http.Body
|
import retrofit2.http.Body
|
||||||
@@ -13,6 +15,7 @@ import retrofit2.http.GET
|
|||||||
import retrofit2.http.POST
|
import retrofit2.http.POST
|
||||||
import retrofit2.http.PATCH
|
import retrofit2.http.PATCH
|
||||||
import retrofit2.http.Path
|
import retrofit2.http.Path
|
||||||
|
import retrofit2.http.Query
|
||||||
|
|
||||||
interface RadiolaApi {
|
interface RadiolaApi {
|
||||||
|
|
||||||
@@ -45,4 +48,21 @@ interface RadiolaApi {
|
|||||||
|
|
||||||
@POST("users/me/history/{stationId}")
|
@POST("users/me/history/{stationId}")
|
||||||
suspend fun addHistory(@Path("stationId") stationId: String): JsonObject
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
60
app/src/main/java/com/radiola/data/remote/dto/ChartsDto.kt
Normal file
60
app/src/main/java/com/radiola/data/remote/dto/ChartsDto.kt
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package com.radiola.data.remote.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/** DTO ответа чартов — список позиций. */
|
||||||
|
@Serializable
|
||||||
|
data class ChartsResponseDto(
|
||||||
|
val items: List<ChartEntryDto> = 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<StationPlaysDto> = emptyList(),
|
||||||
|
val playsTimeline: List<PointDto> = emptyList(),
|
||||||
|
val likesTimeline: List<PointDto> = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Проигрывания на конкретной станции. */
|
||||||
|
@Serializable
|
||||||
|
data class StationPlaysDto(
|
||||||
|
val stationId: Int,
|
||||||
|
val name: String,
|
||||||
|
val plays: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Одна точка тайм-лайна. */
|
||||||
|
@Serializable
|
||||||
|
data class PointDto(
|
||||||
|
val date: String,
|
||||||
|
val value: Int
|
||||||
|
)
|
||||||
@@ -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<ChartEntry> {
|
||||||
|
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<StatPoint> = (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<ChartEntry> = 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()
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -11,7 +11,9 @@ import com.radiola.data.remote.AuthInterceptor
|
|||||||
import com.radiola.data.remote.RecordApi
|
import com.radiola.data.remote.RecordApi
|
||||||
import com.radiola.data.remote.RadiolaApi
|
import com.radiola.data.remote.RadiolaApi
|
||||||
import com.radiola.data.repository.AuthRepositoryImpl
|
import com.radiola.data.repository.AuthRepositoryImpl
|
||||||
|
import com.radiola.data.repository.ChartsRepositoryImpl
|
||||||
import com.radiola.data.repository.FavoritesRepositoryImpl
|
import com.radiola.data.repository.FavoritesRepositoryImpl
|
||||||
|
import com.radiola.data.repository.LyricsRepositoryImpl
|
||||||
import com.radiola.data.repository.NowPlayingRepositoryImpl
|
import com.radiola.data.repository.NowPlayingRepositoryImpl
|
||||||
import com.radiola.data.repository.RecordingRepositoryImpl
|
import com.radiola.data.repository.RecordingRepositoryImpl
|
||||||
import com.radiola.data.repository.SettingsRepositoryImpl
|
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.SyncRepositoryImpl
|
||||||
import com.radiola.data.repository.TrackHistoryRepositoryImpl
|
import com.radiola.data.repository.TrackHistoryRepositoryImpl
|
||||||
import com.radiola.domain.repository.AuthRepository
|
import com.radiola.domain.repository.AuthRepository
|
||||||
|
import com.radiola.domain.repository.ChartsRepository
|
||||||
import com.radiola.domain.repository.FavoritesRepository
|
import com.radiola.domain.repository.FavoritesRepository
|
||||||
|
import com.radiola.domain.repository.LyricsRepository
|
||||||
import com.radiola.domain.repository.SyncRepository
|
import com.radiola.domain.repository.SyncRepository
|
||||||
import com.radiola.domain.repository.NowPlayingRepository
|
import com.radiola.domain.repository.NowPlayingRepository
|
||||||
import com.radiola.domain.repository.RecordingRepository
|
import com.radiola.domain.repository.RecordingRepository
|
||||||
@@ -145,4 +149,12 @@ object AppModule {
|
|||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideSyncRepository(impl: SyncRepositoryImpl): SyncRepository = impl
|
fun provideSyncRepository(impl: SyncRepositoryImpl): SyncRepository = impl
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideChartsRepository(impl: ChartsRepositoryImpl): ChartsRepository = impl
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideLyricsRepository(impl: LyricsRepositoryImpl): LyricsRepository = impl
|
||||||
}
|
}
|
||||||
|
|||||||
26
app/src/main/java/com/radiola/domain/model/ChartEntry.kt
Normal file
26
app/src/main/java/com/radiola/domain/model/ChartEntry.kt
Normal file
@@ -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
|
||||||
|
)
|
||||||
34
app/src/main/java/com/radiola/domain/model/TrackStats.kt
Normal file
34
app/src/main/java/com/radiola/domain/model/TrackStats.kt
Normal file
@@ -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<StationPlays>,
|
||||||
|
val playsTimeline: List<StatPoint>,
|
||||||
|
val likesTimeline: List<StatPoint>
|
||||||
|
)
|
||||||
@@ -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<ChartEntry>
|
||||||
|
suspend fun getTrackStats(trackId: String): TrackStats
|
||||||
|
suspend fun setLiked(trackId: String, liked: Boolean)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
701
app/src/main/java/com/radiola/ui/charts/ChartsScreen.kt
Normal file
701
app/src/main/java/com/radiola/ui/charts/ChartsScreen.kt
Normal file
@@ -0,0 +1,701 @@
|
|||||||
|
package com.radiola.ui.charts
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.compose.animation.animateColorAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.basicMarquee
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.text.withStyle
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import com.composables.icons.lucide.*
|
||||||
|
import com.radiola.domain.model.ChartEntry
|
||||||
|
import com.radiola.domain.model.ChartPeriod
|
||||||
|
import com.radiola.domain.model.ChartTrend
|
||||||
|
import com.radiola.domain.model.DeeplinkService
|
||||||
|
import com.radiola.domain.model.StatPoint
|
||||||
|
import com.radiola.domain.model.TrackStats
|
||||||
|
import com.radiola.ui.components.EmptyState
|
||||||
|
import com.radiola.ui.components.PopularityChart
|
||||||
|
import com.radiola.ui.components.crossfadeModel
|
||||||
|
import com.radiola.ui.components.serviceLogoRes
|
||||||
|
import com.radiola.ui.theme.Motion
|
||||||
|
import com.radiola.ui.theme.RadiolaTheme
|
||||||
|
import com.radiola.ui.theme.pressScale
|
||||||
|
import java.text.DecimalFormat
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ChartsScreen(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: ChartsViewModel = hiltViewModel()
|
||||||
|
) {
|
||||||
|
val colors = RadiolaTheme.colors
|
||||||
|
val period by viewModel.period.collectAsState()
|
||||||
|
val charts by viewModel.charts.collectAsState()
|
||||||
|
val isPreview by viewModel.isPreview.collectAsState()
|
||||||
|
val isLoadingCharts by viewModel.isLoadingCharts.collectAsState()
|
||||||
|
val selectedStats by viewModel.selectedTrackStats.collectAsState()
|
||||||
|
val isLoadingStats by viewModel.isLoadingStats.collectAsState()
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(colors.bgBase)
|
||||||
|
) {
|
||||||
|
// Двухцветный заголовок
|
||||||
|
Column(modifier = Modifier.padding(horizontal = 20.dp)) {
|
||||||
|
Text(
|
||||||
|
text = buildAnnotatedString {
|
||||||
|
withStyle(SpanStyle(color = colors.textPrimary)) { append("Ча") }
|
||||||
|
withStyle(SpanStyle(color = colors.accent)) { append("рты") }
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.headlineLarge,
|
||||||
|
modifier = Modifier.padding(top = 20.dp, bottom = 2.dp)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Популярное на всех станциях",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = colors.textSecondary,
|
||||||
|
modifier = Modifier.padding(bottom = 16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сегменты периода (стиль FilterChips)
|
||||||
|
PeriodSelector(
|
||||||
|
selected = period,
|
||||||
|
onSelect = viewModel::selectPeriod
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|
||||||
|
// Плашка «Демо-данные»
|
||||||
|
if (isPreview) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 20.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(colors.surface2)
|
||||||
|
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Lucide.Info,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = colors.textMuted,
|
||||||
|
modifier = Modifier.size(14.dp)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Демо-данные — бэкенд ещё не готов",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = colors.textMuted
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Список чартов
|
||||||
|
if (isLoadingCharts) {
|
||||||
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
CircularProgressIndicator(color = colors.accent)
|
||||||
|
}
|
||||||
|
} else if (charts.isEmpty()) {
|
||||||
|
EmptyState(
|
||||||
|
message = "Чарты пока недоступны",
|
||||||
|
icon = Lucide.TrendingUp
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(bottom = 88.dp)
|
||||||
|
) {
|
||||||
|
itemsIndexed(
|
||||||
|
items = charts,
|
||||||
|
key = { _, entry -> entry.trackId }
|
||||||
|
) { _, entry ->
|
||||||
|
ChartRow(
|
||||||
|
entry = entry,
|
||||||
|
modifier = Modifier.animateItemPlacement(),
|
||||||
|
onClick = { viewModel.selectTrack(entry.trackId) }
|
||||||
|
)
|
||||||
|
HorizontalDivider(
|
||||||
|
color = colors.border.copy(alpha = 0.4f),
|
||||||
|
thickness = 0.5.dp,
|
||||||
|
modifier = Modifier.padding(horizontal = 20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Детальная карточка — ModalBottomSheet
|
||||||
|
if (selectedStats != null || isLoadingStats) {
|
||||||
|
TrackDetailSheet(
|
||||||
|
stats = selectedStats,
|
||||||
|
isLoading = isLoadingStats,
|
||||||
|
onDismiss = viewModel::clearSelection,
|
||||||
|
onToggleLike = { viewModel.toggleLike(it) },
|
||||||
|
onLyricsClick = { artist, song ->
|
||||||
|
// Строим URL Musixmatch и открываем в браузере
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Селектор периода ----
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PeriodSelector(
|
||||||
|
selected: ChartPeriod,
|
||||||
|
onSelect: (ChartPeriod) -> Unit
|
||||||
|
) {
|
||||||
|
val colors = RadiolaTheme.colors
|
||||||
|
LazyRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||||
|
contentPadding = PaddingValues(horizontal = 20.dp)
|
||||||
|
) {
|
||||||
|
items(ChartPeriod.entries) { p ->
|
||||||
|
PeriodChip(
|
||||||
|
label = p.label,
|
||||||
|
selected = selected == p,
|
||||||
|
onClick = { onSelect(p) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PeriodChip(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||||
|
val colors = RadiolaTheme.colors
|
||||||
|
val bg by animateColorAsState(
|
||||||
|
targetValue = if (selected) colors.accent else colors.surface2,
|
||||||
|
animationSpec = tween(Motion.Medium),
|
||||||
|
label = "periodChipBg"
|
||||||
|
)
|
||||||
|
val fg by animateColorAsState(
|
||||||
|
targetValue = if (selected) colors.bgBase else colors.textSecondary,
|
||||||
|
animationSpec = tween(Motion.Medium),
|
||||||
|
label = "periodChipFg"
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
color = fg,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(18.dp))
|
||||||
|
.background(bg)
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null,
|
||||||
|
onClick = onClick
|
||||||
|
)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 9.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Строка чарта ----
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun ChartRow(
|
||||||
|
entry: ChartEntry,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
val colors = RadiolaTheme.colors
|
||||||
|
val interaction = remember { MutableInteractionSource() }
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.pressScale(interactionSource = interaction)
|
||||||
|
.clickable(interactionSource = interaction, indication = null, onClick = onClick)
|
||||||
|
.padding(horizontal = 20.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
// Номер ранга
|
||||||
|
val rankColor = when (entry.rank) {
|
||||||
|
1, 2, 3 -> colors.accent
|
||||||
|
else -> colors.textMuted
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = entry.rank.toString(),
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 18.sp
|
||||||
|
),
|
||||||
|
color = rankColor,
|
||||||
|
modifier = Modifier.width(30.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Обложка трека
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(50.dp)
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(colors.surface2)
|
||||||
|
) {
|
||||||
|
if (entry.coverUrl != null) {
|
||||||
|
AsyncImage(
|
||||||
|
model = crossfadeModel(entry.coverUrl),
|
||||||
|
contentDescription = "${entry.artist} — ${entry.song}",
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
imageVector = Lucide.Music,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = colors.textMuted,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(22.dp)
|
||||||
|
.align(Alignment.Center)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Название + исполнитель
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = entry.song,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = colors.textPrimary,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = entry.artist,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = colors.textSecondary,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Правая часть: иконка тренда + число проигрываний
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.End,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
TrendIcon(trend = entry.trend)
|
||||||
|
Text(
|
||||||
|
text = formatPlays(entry.plays),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = colors.textMuted
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TrendIcon(trend: ChartTrend) {
|
||||||
|
val colors = RadiolaTheme.colors
|
||||||
|
when (trend) {
|
||||||
|
ChartTrend.UP -> Icon(
|
||||||
|
imageVector = Lucide.TrendingUp,
|
||||||
|
contentDescription = "Рост",
|
||||||
|
tint = colors.accent,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
ChartTrend.DOWN -> Icon(
|
||||||
|
imageVector = Lucide.TrendingDown,
|
||||||
|
contentDescription = "Падение",
|
||||||
|
tint = colors.live,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
ChartTrend.NEW -> Icon(
|
||||||
|
imageVector = Lucide.Sparkles,
|
||||||
|
contentDescription = "Новинка",
|
||||||
|
tint = colors.accent,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
ChartTrend.SAME -> Icon(
|
||||||
|
imageVector = Lucide.Minus,
|
||||||
|
contentDescription = "Без изменений",
|
||||||
|
tint = colors.textMuted,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Детальная карточка трека ----
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun TrackDetailSheet(
|
||||||
|
stats: TrackStats?,
|
||||||
|
isLoading: Boolean,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onToggleLike: (String) -> Unit,
|
||||||
|
onLyricsClick: (artist: String, song: String) -> Unit
|
||||||
|
) {
|
||||||
|
val colors = RadiolaTheme.colors
|
||||||
|
val context = LocalContext.current
|
||||||
|
val haptic = LocalHapticFeedback.current
|
||||||
|
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
containerColor = colors.elevated,
|
||||||
|
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
|
) {
|
||||||
|
if (isLoading || stats == null) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(280.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(color = colors.accent)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
contentPadding = PaddingValues(bottom = 32.dp)
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
// Большая обложка
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(200.dp)
|
||||||
|
.background(colors.surface2)
|
||||||
|
) {
|
||||||
|
if (stats.coverUrl != null) {
|
||||||
|
AsyncImage(
|
||||||
|
model = crossfadeModel(stats.coverUrl),
|
||||||
|
contentDescription = "${stats.artist} — ${stats.song}",
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
imageVector = Lucide.Music,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = colors.textMuted,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(48.dp)
|
||||||
|
.align(Alignment.Center)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Название + исполнитель
|
||||||
|
Column(modifier = Modifier.padding(horizontal = 20.dp)) {
|
||||||
|
Text(
|
||||||
|
text = stats.song,
|
||||||
|
style = MaterialTheme.typography.headlineLarge,
|
||||||
|
color = colors.textPrimary,
|
||||||
|
maxLines = 1,
|
||||||
|
modifier = Modifier.basicMarquee()
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stats.artist,
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
color = colors.textSecondary,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
if (stats.album != null) {
|
||||||
|
Text(
|
||||||
|
text = stats.album,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = colors.textMuted,
|
||||||
|
modifier = Modifier.padding(top = 2.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (stats.releaseDate != null) {
|
||||||
|
Text(
|
||||||
|
text = "Вышел: ${stats.releaseDate}",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = colors.textMuted,
|
||||||
|
modifier = Modifier.padding(top = 2.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Ряд метрик
|
||||||
|
MetricsRow(stats)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(20.dp))
|
||||||
|
|
||||||
|
// График популярности
|
||||||
|
if (stats.playsTimeline.size >= 2) {
|
||||||
|
Text(
|
||||||
|
text = "Популярность за 30 дней",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = colors.textSecondary,
|
||||||
|
modifier = Modifier.padding(bottom = 8.dp)
|
||||||
|
)
|
||||||
|
PopularityChart(
|
||||||
|
points = stats.playsTimeline,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(90.dp)
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(colors.surface2)
|
||||||
|
.padding(horizontal = 8.dp, vertical = 12.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(20.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кнопка лайка
|
||||||
|
LikeButton(
|
||||||
|
isLiked = stats.isLiked,
|
||||||
|
likesCount = stats.totalLikes,
|
||||||
|
onClick = {
|
||||||
|
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||||
|
onToggleLike(stats.trackId)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(20.dp))
|
||||||
|
|
||||||
|
// Кнопка «Текст песни»
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = {
|
||||||
|
// Строим URL Musixmatch и открываем в браузере
|
||||||
|
val query = java.net.URLEncoder.encode(
|
||||||
|
"${stats.artist} ${stats.song}", "UTF-8"
|
||||||
|
)
|
||||||
|
val url = "https://www.musixmatch.com/search/$query"
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
||||||
|
context.startActivity(intent)
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
colors = ButtonDefaults.outlinedButtonColors(
|
||||||
|
contentColor = colors.textSecondary
|
||||||
|
),
|
||||||
|
border = androidx.compose.foundation.BorderStroke(
|
||||||
|
1.dp, colors.border
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Lucide.FileText,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text("Текст песни")
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(20.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кнопки музыкальных сервисов
|
||||||
|
Text(
|
||||||
|
text = "Слушать в сервисе",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = colors.textSecondary,
|
||||||
|
modifier = Modifier.padding(horizontal = 20.dp, vertical = 0.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сетка сервисов
|
||||||
|
items(DeeplinkService.entries) { service ->
|
||||||
|
ServiceRow(
|
||||||
|
service = service,
|
||||||
|
onClick = {
|
||||||
|
val url = service.buildSearchUrl(stats.artist, stats.song)
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
||||||
|
context.startActivity(Intent.createChooser(intent, "Открыть в..."))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Метрики трека ----
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MetricsRow(stats: TrackStats) {
|
||||||
|
val colors = RadiolaTheme.colors
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
MetricChip(
|
||||||
|
label = formatPlays(stats.totalPlays),
|
||||||
|
description = "проигрываний",
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
MetricChip(
|
||||||
|
label = formatPlays(stats.totalLikes),
|
||||||
|
description = "лайков",
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
if (stats.peakRank != null) {
|
||||||
|
MetricChip(
|
||||||
|
label = "#${stats.peakRank}",
|
||||||
|
description = "пик",
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MetricChip(label: String, description: String, modifier: Modifier = Modifier) {
|
||||||
|
val colors = RadiolaTheme.colors
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.clip(RoundedCornerShape(10.dp))
|
||||||
|
.background(colors.surface2)
|
||||||
|
.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = colors.accent,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = description,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = colors.textMuted,
|
||||||
|
maxLines = 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Кнопка лайка ----
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LikeButton(isLiked: Boolean, likesCount: Int, onClick: () -> Unit) {
|
||||||
|
val colors = RadiolaTheme.colors
|
||||||
|
val heartColor by animateColorAsState(
|
||||||
|
targetValue = if (isLiked) colors.live else colors.textMuted,
|
||||||
|
animationSpec = tween(Motion.Fast),
|
||||||
|
label = "heartColor"
|
||||||
|
)
|
||||||
|
val interaction = remember { MutableInteractionSource() }
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(colors.surface2)
|
||||||
|
.pressScale(interactionSource = interaction)
|
||||||
|
.clickable(interactionSource = interaction, indication = null, onClick = onClick)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (isLiked) Lucide.Heart else Lucide.Heart,
|
||||||
|
contentDescription = if (isLiked) "Убрать лайк" else "Поставить лайк",
|
||||||
|
tint = heartColor,
|
||||||
|
modifier = Modifier.size(22.dp)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = if (isLiked) "Нравится · ${formatPlays(likesCount)}" else "Нравится · ${formatPlays(likesCount)}",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = heartColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Строка сервиса ----
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ServiceRow(service: DeeplinkService, onClick: () -> Unit) {
|
||||||
|
val colors = RadiolaTheme.colors
|
||||||
|
val interaction = remember { MutableInteractionSource() }
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.pressScale(interactionSource = interaction)
|
||||||
|
.clickable(interactionSource = interaction, indication = null, onClick = onClick)
|
||||||
|
.padding(horizontal = 20.dp, vertical = 10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(14.dp)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(40.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(colors.surface2),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
val logoRes = serviceLogoRes(service)
|
||||||
|
if (logoRes != null) {
|
||||||
|
Icon(
|
||||||
|
painter = androidx.compose.ui.res.painterResource(logoRes),
|
||||||
|
contentDescription = service.displayName,
|
||||||
|
tint = colors.textSecondary,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
imageVector = Lucide.Music,
|
||||||
|
contentDescription = service.displayName,
|
||||||
|
tint = colors.textSecondary,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = service.displayName,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = colors.textPrimary
|
||||||
|
)
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
Icon(
|
||||||
|
imageVector = Lucide.ChevronRight,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = colors.textMuted,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Утилиты ----
|
||||||
|
|
||||||
|
/** Форматирует число проигрываний: 1234 → «1.2k», 1_000_000 → «1.0M». */
|
||||||
|
private fun formatPlays(value: Int): String {
|
||||||
|
val df = DecimalFormat("#.#")
|
||||||
|
return when {
|
||||||
|
value >= 1_000_000 -> "${df.format(value / 1_000_000.0)}M"
|
||||||
|
value >= 1_000 -> "${df.format(value / 1_000.0)}k"
|
||||||
|
else -> value.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
108
app/src/main/java/com/radiola/ui/charts/ChartsViewModel.kt
Normal file
108
app/src/main/java/com/radiola/ui/charts/ChartsViewModel.kt
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
package com.radiola.ui.charts
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.radiola.data.repository.ChartsRepositoryImpl
|
||||||
|
import com.radiola.domain.model.ChartEntry
|
||||||
|
import com.radiola.domain.model.ChartPeriod
|
||||||
|
import com.radiola.domain.model.TrackStats
|
||||||
|
import com.radiola.domain.repository.ChartsRepository
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class ChartsViewModel @Inject constructor(
|
||||||
|
private val chartsRepository: ChartsRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _period = MutableStateFlow(ChartPeriod.WEEK)
|
||||||
|
val period: StateFlow<ChartPeriod> = _period.asStateFlow()
|
||||||
|
|
||||||
|
private val _charts = MutableStateFlow<List<ChartEntry>>(emptyList())
|
||||||
|
val charts: StateFlow<List<ChartEntry>> = _charts.asStateFlow()
|
||||||
|
|
||||||
|
/** true — данные из превью (бэкенд ещё не готов). */
|
||||||
|
private val _isPreview = MutableStateFlow(false)
|
||||||
|
val isPreview: StateFlow<Boolean> = _isPreview.asStateFlow()
|
||||||
|
|
||||||
|
private val _isLoadingCharts = MutableStateFlow(false)
|
||||||
|
val isLoadingCharts: StateFlow<Boolean> = _isLoadingCharts.asStateFlow()
|
||||||
|
|
||||||
|
private val _selectedTrackStats = MutableStateFlow<TrackStats?>(null)
|
||||||
|
val selectedTrackStats: StateFlow<TrackStats?> = _selectedTrackStats.asStateFlow()
|
||||||
|
|
||||||
|
private val _isLoadingStats = MutableStateFlow(false)
|
||||||
|
val isLoadingStats: StateFlow<Boolean> = _isLoadingStats.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadCharts()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectPeriod(newPeriod: ChartPeriod) {
|
||||||
|
if (_period.value == newPeriod) return
|
||||||
|
_period.value = newPeriod
|
||||||
|
loadCharts()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectTrack(trackId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_isLoadingStats.value = true
|
||||||
|
_selectedTrackStats.value = null
|
||||||
|
try {
|
||||||
|
val stats = chartsRepository.getTrackStats(trackId)
|
||||||
|
_selectedTrackStats.value = stats
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("ChartsViewModel", "Ошибка загрузки статистики трека $trackId", e)
|
||||||
|
} finally {
|
||||||
|
_isLoadingStats.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearSelection() {
|
||||||
|
_selectedTrackStats.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleLike(trackId: String) {
|
||||||
|
val stats = _selectedTrackStats.value ?: return
|
||||||
|
val newLiked = !stats.isLiked
|
||||||
|
// Оптимистично обновляем UI
|
||||||
|
_selectedTrackStats.value = stats.copy(
|
||||||
|
isLiked = newLiked,
|
||||||
|
totalLikes = if (newLiked) stats.totalLikes + 1 else stats.totalLikes - 1
|
||||||
|
)
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
chartsRepository.setLiked(trackId, newLiked)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("ChartsViewModel", "Ошибка переключения лайка трека $trackId", e)
|
||||||
|
// Откатываем при ошибке
|
||||||
|
_selectedTrackStats.value = stats
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadCharts() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_isLoadingCharts.value = true
|
||||||
|
try {
|
||||||
|
val result = chartsRepository.getCharts(_period.value)
|
||||||
|
_charts.value = result
|
||||||
|
// Определяем, это превью или реальные данные.
|
||||||
|
// TODO: убрать проверку, когда бэкенд отдаёт charts.
|
||||||
|
// Если список совпадает с образцом — это превью.
|
||||||
|
_isPreview.value = result == ChartsRepositoryImpl.SAMPLE_CHARTS
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("ChartsViewModel", "Ошибка загрузки чартов", e)
|
||||||
|
_isPreview.value = true
|
||||||
|
} finally {
|
||||||
|
_isLoadingCharts.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
133
app/src/main/java/com/radiola/ui/components/Sparkline.kt
Normal file
133
app/src/main/java/com/radiola/ui/components/Sparkline.kt
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
package com.radiola.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.Path
|
||||||
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
|
import androidx.compose.ui.graphics.StrokeJoin
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.radiola.domain.model.StatPoint
|
||||||
|
import com.radiola.ui.theme.RadiolaTheme
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Компонент-спарклайн: сглаженный линейный график с градиентной заливкой.
|
||||||
|
* Используется для отображения популярности трека (проигрывания / лайки).
|
||||||
|
* Не показывает оси — только форму данных.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun PopularityChart(
|
||||||
|
points: List<StatPoint>,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
lineColor: Color = RadiolaTheme.colors.accent
|
||||||
|
) {
|
||||||
|
val colors = RadiolaTheme.colors
|
||||||
|
val dateFmt = remember { SimpleDateFormat("d MMM", Locale("ru")) }
|
||||||
|
|
||||||
|
Box(modifier = modifier) {
|
||||||
|
if (points.size >= 2) {
|
||||||
|
val minVal = points.minOf { it.value }.toFloat()
|
||||||
|
val maxVal = points.maxOf { it.value }.toFloat()
|
||||||
|
val range = (maxVal - minVal).coerceAtLeast(1f)
|
||||||
|
|
||||||
|
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||||
|
val w = size.width
|
||||||
|
val h = size.height
|
||||||
|
val topPad = 4.dp.toPx()
|
||||||
|
val botPad = 4.dp.toPx()
|
||||||
|
val drawH = h - topPad - botPad
|
||||||
|
|
||||||
|
// Вычисляем координаты точек
|
||||||
|
fun xAt(i: Int) = i * w / (points.size - 1)
|
||||||
|
fun yAt(v: Float) = topPad + drawH * (1f - (v - minVal) / range)
|
||||||
|
|
||||||
|
// Сглаженный path через cubic bezier
|
||||||
|
val linePath = Path()
|
||||||
|
linePath.moveTo(xAt(0), yAt(points[0].value.toFloat()))
|
||||||
|
for (i in 1 until points.size) {
|
||||||
|
val x0 = xAt(i - 1)
|
||||||
|
val y0 = yAt(points[i - 1].value.toFloat())
|
||||||
|
val x1 = xAt(i)
|
||||||
|
val y1 = yAt(points[i].value.toFloat())
|
||||||
|
val cx = (x0 + x1) / 2f
|
||||||
|
linePath.cubicTo(cx, y0, cx, y1, x1, y1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Заливка под графиком
|
||||||
|
val fillPath = Path().apply {
|
||||||
|
addPath(linePath)
|
||||||
|
lineTo(xAt(points.size - 1), h)
|
||||||
|
lineTo(xAt(0), h)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
drawPath(
|
||||||
|
path = fillPath,
|
||||||
|
brush = Brush.verticalGradient(
|
||||||
|
colors = listOf(lineColor.copy(alpha = 0.28f), Color.Transparent),
|
||||||
|
startY = topPad,
|
||||||
|
endY = h
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Линия графика
|
||||||
|
drawPath(
|
||||||
|
path = linePath,
|
||||||
|
color = lineColor,
|
||||||
|
style = Stroke(
|
||||||
|
width = 2.dp.toPx(),
|
||||||
|
cap = StrokeCap.Round,
|
||||||
|
join = StrokeJoin.Round
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Точка первого и последнего значения
|
||||||
|
drawCircle(
|
||||||
|
color = lineColor,
|
||||||
|
radius = 3.dp.toPx(),
|
||||||
|
center = Offset(xAt(0), yAt(points.first().value.toFloat()))
|
||||||
|
)
|
||||||
|
drawCircle(
|
||||||
|
color = lineColor,
|
||||||
|
radius = 3.dp.toPx(),
|
||||||
|
center = Offset(xAt(points.size - 1), yAt(points.last().value.toFloat()))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подписи дат по краям
|
||||||
|
val firstDate = dateFmt.format(Date(points.first().date))
|
||||||
|
val lastDate = dateFmt.format(Date(points.last().date))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = firstDate,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = colors.textMuted,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomStart)
|
||||||
|
.padding(start = 4.dp, bottom = 2.dp)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = lastDate,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = colors.textMuted,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomEnd)
|
||||||
|
.padding(end = 4.dp, bottom = 2.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// При 0 или 1 точке — ничего не рисуем (корректная пустая обработка)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,7 @@ fun BottomNavBar(navController: NavController) {
|
|||||||
// при холодном старте может содержать null (порядок инициализации Kotlin).
|
// при холодном старте может содержать null (порядок инициализации Kotlin).
|
||||||
val items = listOf(
|
val items = listOf(
|
||||||
NavDestinations.Stations,
|
NavDestinations.Stations,
|
||||||
|
NavDestinations.Charts,
|
||||||
NavDestinations.Favorites,
|
NavDestinations.Favorites,
|
||||||
NavDestinations.History,
|
NavDestinations.History,
|
||||||
NavDestinations.Recordings,
|
NavDestinations.Recordings,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import com.composables.icons.lucide.History
|
|||||||
import com.composables.icons.lucide.Mic
|
import com.composables.icons.lucide.Mic
|
||||||
import com.composables.icons.lucide.Radio
|
import com.composables.icons.lucide.Radio
|
||||||
import com.composables.icons.lucide.Settings
|
import com.composables.icons.lucide.Settings
|
||||||
|
import com.composables.icons.lucide.TrendingUp
|
||||||
|
|
||||||
sealed class NavDestinations(
|
sealed class NavDestinations(
|
||||||
val route: String,
|
val route: String,
|
||||||
@@ -15,6 +16,7 @@ sealed class NavDestinations(
|
|||||||
val showInBottomBar: Boolean = true
|
val showInBottomBar: Boolean = true
|
||||||
) {
|
) {
|
||||||
data object Stations : NavDestinations("stations", "Радио", Lucide.Radio)
|
data object Stations : NavDestinations("stations", "Радио", Lucide.Radio)
|
||||||
|
data object Charts : NavDestinations("charts", "Чарты", Lucide.TrendingUp)
|
||||||
data object Favorites : NavDestinations("favorites", "Избранное", Lucide.Heart)
|
data object Favorites : NavDestinations("favorites", "Избранное", Lucide.Heart)
|
||||||
data object History : NavDestinations("history", "История", Lucide.History)
|
data object History : NavDestinations("history", "История", Lucide.History)
|
||||||
data object Recordings : NavDestinations("recordings", "Записи", Lucide.Mic)
|
data object Recordings : NavDestinations("recordings", "Записи", Lucide.Mic)
|
||||||
@@ -22,6 +24,6 @@ sealed class NavDestinations(
|
|||||||
data object Auth : NavDestinations("auth", "Вход", Lucide.Settings, showInBottomBar = false)
|
data object Auth : NavDestinations("auth", "Вход", Lucide.Settings, showInBottomBar = false)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
val items = listOf(Stations, Favorites, History, Recordings, Settings)
|
val items = listOf(Stations, Charts, Favorites, History, Recordings, Settings)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user