feat(nav): swipeable pager for the 4 tabs (Spotify-style)

- New TabsScreen: HorizontalPager (Stations · Library · Streams ·
  Playlists) synced with a shared bottom NavigationBar; swipes follow
  the finger, tab taps animate to page, neighbors pre-composed so
  pages and their ViewModels survive page changes
- Light haptic tick when a new page settles (skipped on first frame)
- Mini-player sits above the nav bar inside the pager surface:
  playback controls reachable from every tab
- Hub tab buttons navigate to tabs?index=N; playlist selection from
  the Playlists page animates to Library and deep-links the playlist
- All previous routes preserved (stations/library/streams/playlists
  still resolve); detail transitions now direction-aware via
  Motion.kt forward/backward params
This commit is contained in:
avi 2026-09-08 09:59:21 -05:00
commit d612ba6f49
4 changed files with 171 additions and 12 deletions

View file

@ -38,6 +38,25 @@ fun NavGraph(navController: NavHostController) {
startDestination = Screen.Player.route,
modifier = Modifier.weight(1f),
) {
composable(
route = Screen.Tabs.route,
arguments = listOf(
navArgument("index") { type = NavType.IntType; defaultValue = 0 },
navArgument("playlistId") { type = NavType.LongType; defaultValue = -1L },
),
enterTransition = { detailEnter(reduce) },
exitTransition = { detailExit(reduce) },
popEnterTransition = { detailPopEnter(reduce) },
popExitTransition = { detailPopExit(reduce) },
) { backStackEntry ->
val args = backStackEntry.arguments
TabsScreen(
initialIndex = args?.getInt("index") ?: 0,
initialPlaylistId = args?.getLong("playlistId")?.takeIf { it > 0 },
onBack = { navController.popBackStack() },
onHome = { navController.navigate(Screen.Player.route) { popUpTo(Screen.Player.route) { inclusive = true } } },
)
}
composable(
Screen.Player.route,
enterTransition = { hubEnter(reduce) },
@ -46,10 +65,10 @@ fun NavGraph(navController: NavHostController) {
popExitTransition = { hubExit(reduce) },
) {
PlayerScreen(
onNavigateToStations = { navController.navigate(Screen.Stations.route) },
onNavigateToLibrary = { navController.navigate(Screen.Library.createRoute()) },
onNavigateToStreams = { navController.navigate(Screen.Streams.route) },
onNavigateToPlaylists = { navController.navigate(Screen.Playlists.route) },
onNavigateToStations = { navController.navigate(Screen.Tabs.createRoute(0)) },
onNavigateToLibrary = { navController.navigate(Screen.Tabs.createRoute(1)) },
onNavigateToStreams = { navController.navigate(Screen.Tabs.createRoute(2)) },
onNavigateToPlaylists = { navController.navigate(Screen.Tabs.createRoute(3)) },
)
}
composable(

View file

@ -10,4 +10,8 @@ sealed class Screen(val route: String) {
data object Stations : Screen("stations")
data object Streams : Screen("streams")
data object Playlists : Screen("playlists")
data object Tabs : Screen("tabs?index={index}&playlistId={playlistId}") {
fun createRoute(index: Int = 0, playlistId: Long? = null): String =
"tabs?index=$index" + (playlistId?.let { "&playlistId=$it" } ?: "")
}
}

View file

@ -0,0 +1,136 @@
package com.rada.ui.navigation
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.PlaylistAdd
import androidx.compose.material.icons.filled.LibraryMusic
import androidx.compose.material.icons.filled.Podcasts
import androidx.compose.material.icons.filled.Radio
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import com.rada.ui.library.LibraryScreen
import com.rada.ui.player.NowPlayingBar
import com.rada.ui.playlists.PlaylistManagerScreen
import com.rada.ui.stations.StationBrowserScreen
import com.rada.ui.streams.StreamsScreen
import com.rada.ui.theme.LocalTokens
import kotlinx.coroutines.launch
/**
* The four peer tabs as a swipeable pager (Spotify's horizontal structure):
* Stations · Library · Streams · Playlists. Synced with the bottom bar;
* swiping follows the finger, tab taps animate. The mini-player sits above
* the nav bar so playback controls are always reachable.
*/
@Composable
fun TabsScreen(
initialIndex: Int,
initialPlaylistId: Long? = null,
onBack: () -> Unit,
onHome: () -> Unit,
) {
val pagerState = rememberPagerState(initialPage = initialIndex.coerceIn(0, 3)) { 4 }
val scope = rememberCoroutineScope()
val haptics = LocalHapticFeedback.current
// Deep-link target for Library (playlist picker → open that playlist).
var libraryPlaylistId by remember { mutableStateOf(initialPlaylistId) }
// Light tick as a new page settles — tab changes are tactile, not silent.
// Skipped on first composition (nothing actually changed).
var firstSettle by remember { mutableStateOf(true) }
LaunchedEffect(pagerState.settledPage) {
if (firstSettle) firstSettle = false
else haptics.performHapticFeedback(HapticFeedbackType.LongPress)
}
Scaffold(
containerColor = MaterialTheme.colorScheme.background,
bottomBar = {
Column {
NowPlayingBar(
onNavigateToPlayer = onHome,
)
NavigationBar(
containerColor = LocalTokens.current.panel.copy(alpha = 0.94f),
) {
val items = listOf(
Triple("Stations", Icons.Default.Radio, 0),
Triple("Library", Icons.Default.LibraryMusic, 1),
Triple("Streams", Icons.Default.Podcasts, 2),
Triple("Playlists", Icons.AutoMirrored.Filled.PlaylistAdd, 3),
)
items.forEach { (label, icon, page) ->
NavigationBarItem(
selected = pagerState.settledPage == page,
onClick = {
scope.launch { pagerState.animateScrollToPage(page) }
},
icon = { Icon(icon, contentDescription = label) },
label = { Text(label) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = MaterialTheme.colorScheme.primary,
selectedTextColor = MaterialTheme.colorScheme.primary,
indicatorColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f),
),
)
}
}
}
},
) { padding ->
HorizontalPager(
state = pagerState,
modifier = Modifier
.fillMaxSize()
.padding(bottom = padding.calculateBottomPadding()),
// Keep neighbors composed so swiping shows real content instantly
// and their ViewModels survive page changes.
beyondViewportPageCount = 1,
) { page ->
when (page) {
0 -> StationBrowserScreen(
onBack = onBack,
onHome = onHome,
onStationSelected = { /* stay on tab; bar shows the station */ },
)
1 -> LibraryScreen(
onBack = onBack,
onHome = onHome,
initialPlaylistId = libraryPlaylistId,
)
2 -> StreamsScreen(
onBack = onBack,
onHome = onHome,
onStreamSelected = { },
)
3 -> PlaylistManagerScreen(
onBack = onBack,
onHome = onHome,
onPlaylistSelected = { playlistId ->
libraryPlaylistId = playlistId
scope.launch { pagerState.animateScrollToPage(1) }
},
)
}
}
}
}

View file

@ -61,24 +61,24 @@ fun hubExit(reduce: Boolean): ExitTransition =
// and pop back to the left, so direction teaches the hierarchy. Slide is a
// quarter-travel, not a full page wipe — fast screens stay legible mid-motion.
fun detailEnter(reduce: Boolean): EnterTransition =
fun detailEnter(reduce: Boolean, forward: Boolean = true): EnterTransition =
if (reduce) fadeIn(tween(ExitFastMs))
else slideInHorizontally(tween(ViewMs, easing = MotionArrival)) { it / 4 } +
else slideInHorizontally(tween(ViewMs, easing = MotionArrival)) { if (forward) it / 4 else -it / 4 } +
fadeIn(tween(StateMs, easing = MotionArrival))
fun detailExit(reduce: Boolean): ExitTransition =
fun detailExit(reduce: Boolean, forward: Boolean = true): ExitTransition =
if (reduce) fadeOut(tween(ExitFastMs))
else slideOutHorizontally(tween(StateMs, easing = MotionExit)) { -it / 8 } +
else slideOutHorizontally(tween(StateMs, easing = MotionExit)) { if (forward) -it / 8 else it / 8 } +
fadeOut(tween(ExitFastMs, easing = MotionExit))
fun detailPopEnter(reduce: Boolean): EnterTransition =
fun detailPopEnter(reduce: Boolean, forward: Boolean = true): EnterTransition =
if (reduce) fadeIn(tween(ExitFastMs))
else slideInHorizontally(tween(ViewMs, easing = MotionArrival)) { -it / 4 } +
else slideInHorizontally(tween(ViewMs, easing = MotionArrival)) { if (forward) -it / 8 else it / 8 } +
fadeIn(tween(StateMs, easing = MotionArrival))
fun detailPopExit(reduce: Boolean): ExitTransition =
fun detailPopExit(reduce: Boolean, forward: Boolean = true): ExitTransition =
if (reduce) fadeOut(tween(ExitFastMs))
else slideOutHorizontally(tween(StateMs, easing = MotionExit)) { it / 8 } +
else slideOutHorizontally(tween(StateMs, easing = MotionExit)) { if (forward) it / 4 else -it / 4 } +
fadeOut(tween(ExitFastMs, easing = MotionExit))
// ── Now-playing bar ───────────────────────────────────────────────────────