feat(nav+fav): порядок меню, анимация иконки при выборе, индикатор на избранном

- Порядок нижнего меню: Радио · Избранное · История · Чарты · Запись · Настройки.
- Иконка вкладки при выборе делает упругий scale-«поп» (spring MediumBouncy) —
  в нижнем баре и боковом рейле.
- На экране «Избранное» играющая станция теперь подсвечивается так же, как на
  главной: вращающееся свечение под обложкой + индикатор-эквалайзер в углу
  (FavoritesViewModel отдаёт playingStationId/isPlaying из PlayerController,
  FavoritesScreen передаёт isCurrent/isPlaying в StationCard).
This commit is contained in:
nk
2026-06-07 17:06:28 +03:00
parent d63c1d4187
commit a5d9a06c3f
3 changed files with 34 additions and 4 deletions

View File

@@ -35,6 +35,8 @@ fun FavoritesScreen(
val favorites by viewModel.favorites.collectAsState() val favorites by viewModel.favorites.collectAsState()
val favoriteIds by viewModel.favoriteIds.collectAsState() val favoriteIds by viewModel.favoriteIds.collectAsState()
val nowPlaying by viewModel.nowPlaying.collectAsState() val nowPlaying by viewModel.nowPlaying.collectAsState()
val playingStationId by viewModel.playingStationId.collectAsState()
val isPlaying by viewModel.isPlaying.collectAsState()
val colors = RadiolaTheme.colors val colors = RadiolaTheme.colors
Column( Column(
@@ -95,6 +97,8 @@ fun FavoritesScreen(
onClick = { onStationClick(station) }, onClick = { onStationClick(station) },
onFavoriteClick = { viewModel.toggleFavorite(station) }, onFavoriteClick = { viewModel.toggleFavorite(station) },
nowTrack = nowPlaying[station.id], nowTrack = nowPlaying[station.id],
isCurrent = station.id == playingStationId,
isPlaying = isPlaying,
modifier = Modifier.animateItemPlacement() modifier = Modifier.animateItemPlacement()
) )
} }

View File

@@ -9,6 +9,7 @@ import com.radiola.domain.repository.NowPlayingRepository
import com.radiola.domain.usecase.ToggleFavoriteUseCase import com.radiola.domain.usecase.ToggleFavoriteUseCase
import com.radiola.domain.usecase.auth.PushFavoriteUseCase import com.radiola.domain.usecase.auth.PushFavoriteUseCase
import com.radiola.domain.usecase.auth.SyncFavoritesUseCase import com.radiola.domain.usecase.auth.SyncFavoritesUseCase
import com.radiola.service.PlayerController
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
@@ -24,9 +25,14 @@ class FavoritesViewModel @Inject constructor(
private val toggleFavoriteUseCase: ToggleFavoriteUseCase, private val toggleFavoriteUseCase: ToggleFavoriteUseCase,
private val pushFavoriteUseCase: PushFavoriteUseCase, private val pushFavoriteUseCase: PushFavoriteUseCase,
private val syncFavoritesUseCase: SyncFavoritesUseCase, private val syncFavoritesUseCase: SyncFavoritesUseCase,
private val nowPlayingRepository: NowPlayingRepository private val nowPlayingRepository: NowPlayingRepository,
private val playerController: PlayerController
) : ViewModel() { ) : ViewModel() {
// Активная (играющая) станция — для подсветки карточки, как на экране всех станций.
val playingStationId: StateFlow<Int?> = playerController.currentStationId
val isPlaying: StateFlow<Boolean> = playerController.isPlaying
val favorites: StateFlow<List<Station>> = favoritesRepository.getFavorites() val favorites: StateFlow<List<Station>> = favoritesRepository.getFavorites()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())

View File

@@ -2,6 +2,9 @@ package com.radiola.ui.navigation
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.expandHorizontally import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
@@ -28,11 +31,13 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
@@ -43,9 +48,9 @@ import com.radiola.ui.theme.RadiolaTheme
// при холодном старте может содержать null (порядок инициализации Kotlin). // при холодном старте может содержать null (порядок инициализации Kotlin).
private val navItems = listOf( private val navItems = listOf(
NavDestinations.Stations, NavDestinations.Stations,
NavDestinations.Charts,
NavDestinations.Favorites, NavDestinations.Favorites,
NavDestinations.History, NavDestinations.History,
NavDestinations.Charts,
NavDestinations.Recordings, NavDestinations.Recordings,
NavDestinations.Settings NavDestinations.Settings
) )
@@ -144,6 +149,13 @@ private fun VerticalPillTab(
animationSpec = tween(Motion.Medium), animationSpec = tween(Motion.Medium),
label = "railTabFg" label = "railTabFg"
) )
val pop = remember { Animatable(1f) }
LaunchedEffect(selected) {
if (selected) {
pop.snapTo(0.5f)
pop.animateTo(1f, spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = 620f))
}
}
Row( Row(
modifier = Modifier modifier = Modifier
.size(52.dp) .size(52.dp)
@@ -161,7 +173,7 @@ private fun VerticalPillTab(
imageVector = icon, imageVector = icon,
contentDescription = label, contentDescription = label,
tint = content, tint = content,
modifier = Modifier.size(22.dp) modifier = Modifier.size(22.dp).scale(pop.value)
) )
} }
} }
@@ -185,6 +197,14 @@ private fun PillTab(
animationSpec = tween(Motion.Medium), animationSpec = tween(Motion.Medium),
label = "tabFg" label = "tabFg"
) )
// Упругий «поп» иконки при выборе вкладки — маленькая приятная деталь.
val pop = remember { Animatable(1f) }
LaunchedEffect(selected) {
if (selected) {
pop.snapTo(0.5f)
pop.animateTo(1f, spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = 620f))
}
}
Row( Row(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
@@ -204,7 +224,7 @@ private fun PillTab(
imageVector = icon, imageVector = icon,
contentDescription = label, contentDescription = label,
tint = content, tint = content,
modifier = Modifier.height(22.dp).width(22.dp) modifier = Modifier.height(22.dp).width(22.dp).scale(pop.value)
) )
} }
} }