From d612ba6f49864a2569957293f8c9d6e89039fb4d Mon Sep 17 00:00:00 2001 From: avi Date: Tue, 8 Sep 2026 09:59:21 -0500 Subject: [PATCH] feat(nav): swipeable pager for the 4 tabs (Spotify-style) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../java/com/rada/ui/navigation/NavGraph.kt | 27 +++- .../java/com/rada/ui/navigation/Screen.kt | 4 + .../java/com/rada/ui/navigation/TabsScreen.kt | 136 ++++++++++++++++++ app/src/main/java/com/rada/ui/theme/Motion.kt | 16 +-- 4 files changed, 171 insertions(+), 12 deletions(-) create mode 100644 app/src/main/java/com/rada/ui/navigation/TabsScreen.kt diff --git a/app/src/main/java/com/rada/ui/navigation/NavGraph.kt b/app/src/main/java/com/rada/ui/navigation/NavGraph.kt index aa0f547..54976a9 100644 --- a/app/src/main/java/com/rada/ui/navigation/NavGraph.kt +++ b/app/src/main/java/com/rada/ui/navigation/NavGraph.kt @@ -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( diff --git a/app/src/main/java/com/rada/ui/navigation/Screen.kt b/app/src/main/java/com/rada/ui/navigation/Screen.kt index 8a1d877..43f3001 100644 --- a/app/src/main/java/com/rada/ui/navigation/Screen.kt +++ b/app/src/main/java/com/rada/ui/navigation/Screen.kt @@ -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" } ?: "") + } } diff --git a/app/src/main/java/com/rada/ui/navigation/TabsScreen.kt b/app/src/main/java/com/rada/ui/navigation/TabsScreen.kt new file mode 100644 index 0000000..cf39fdb --- /dev/null +++ b/app/src/main/java/com/rada/ui/navigation/TabsScreen.kt @@ -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) } + }, + ) + } + } + } +} diff --git a/app/src/main/java/com/rada/ui/theme/Motion.kt b/app/src/main/java/com/rada/ui/theme/Motion.kt index 27e2fe3..d114d7e 100644 --- a/app/src/main/java/com/rada/ui/theme/Motion.kt +++ b/app/src/main/java/com/rada/ui/theme/Motion.kt @@ -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 ───────────────────────────────────────────────────────