From afdd9b860f6a47b89c50545854c6d51d95ac1b04 Mon Sep 17 00:00:00 2001 From: Barnabas Balogh Date: Sun, 26 Apr 2026 18:08:40 +0200 Subject: [PATCH] feat: integrate dynamic library navigation in TV drawer Refactors the TV navigation drawer to use specific library routes instead of a static libraries overview. Added a `NavDisplay` with a backstack to manage navigation between the Home screen and individual Library screens directly from the drawer. - Replace `TvDrawerDestination` enum with `Route` in the navigation drawer - Dynamically populate drawer items based on available libraries - Add `NavDisplay` to `TvAppScreen` for nested TV navigation - Connect `TvLibraryScreen` to `AppViewModel` for media selection handling - Remove back button from `TvLibraryScreen` in favor of drawer navigation - Update `TvNavigationDrawerTest` to reflect routing changes --- .../home/components/TvNavigationDrawerTest.kt | 17 ++- .../purefin/navigation/TvRouteEntryBuilder.kt | 8 +- .../hu/bbara/purefin/ui/screen/TvAppScreen.kt | 118 +++++++++++------- .../ui/screen/home/components/TvHomeModels.kt | 11 +- .../home/components/TvNavigationDrawer.kt | 13 +- .../ui/screen/library/TvLibraryScreen.kt | 55 +++----- 6 files changed, 120 insertions(+), 102 deletions(-) diff --git a/app-tv/src/androidTest/java/hu/bbara/purefin/ui/screen/home/components/TvNavigationDrawerTest.kt b/app-tv/src/androidTest/java/hu/bbara/purefin/ui/screen/home/components/TvNavigationDrawerTest.kt index c08266fa..daa23409 100644 --- a/app-tv/src/androidTest/java/hu/bbara/purefin/ui/screen/home/components/TvNavigationDrawerTest.kt +++ b/app-tv/src/androidTest/java/hu/bbara/purefin/ui/screen/home/components/TvNavigationDrawerTest.kt @@ -24,9 +24,12 @@ import androidx.compose.ui.test.performKeyInput import androidx.compose.ui.test.performSemanticsAction import androidx.compose.ui.test.pressKey import androidx.compose.ui.unit.dp +import hu.bbara.purefin.navigation.LibraryDto +import hu.bbara.purefin.navigation.Route import hu.bbara.purefin.ui.theme.AppTheme import org.junit.Rule import org.junit.Test +import java.util.UUID @OptIn(ExperimentalTestApi::class) class TvNavigationDrawerTest { @@ -56,20 +59,26 @@ class TvNavigationDrawerTest { @Test fun tvNavigationDrawer_updatesSelectedDestination() { - var selectedDestination by mutableStateOf(TvDrawerDestination.HOME) + val libraryRoute = Route.LibraryRoute( + library = LibraryDto( + id = UUID.fromString("22222222-2222-2222-2222-222222222222"), + name = "Movies" + ) + ) + var selectedDestination by mutableStateOf(Route.Home) composeRule.setContent { AppTheme { TvNavigationDrawer( destinations = listOf( TvDrawerDestinationItem( - destination = TvDrawerDestination.HOME, + destination = Route.Home, label = "Home", icon = Icons.Outlined.Home ), TvDrawerDestinationItem( - destination = TvDrawerDestination.LIBRARIES, - label = "Libraries", + destination = libraryRoute, + label = "Movies", icon = Icons.Outlined.Collections ) ), diff --git a/app-tv/src/main/java/hu/bbara/purefin/navigation/TvRouteEntryBuilder.kt b/app-tv/src/main/java/hu/bbara/purefin/navigation/TvRouteEntryBuilder.kt index 96a7e058..4c306b71 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/navigation/TvRouteEntryBuilder.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/navigation/TvRouteEntryBuilder.kt @@ -1,6 +1,8 @@ package hu.bbara.purefin.navigation +import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation3.runtime.EntryProviderScope +import hu.bbara.purefin.feature.browse.home.AppViewModel import hu.bbara.purefin.ui.screen.episode.TvEpisodeScreen import hu.bbara.purefin.ui.screen.movie.TvMovieScreen import hu.bbara.purefin.ui.screen.series.TvSeriesScreen @@ -51,6 +53,10 @@ fun EntryProviderScope.tvPlayerSection() { fun EntryProviderScope.tvLibrarySection() { entry { route -> - TvLibraryScreen(library = route.library) + val viewModel: AppViewModel = hiltViewModel() + TvLibraryScreen( + library = route.library, + onMediaSelected = viewModel::onMediaSelected + ) } } diff --git a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/TvAppScreen.kt b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/TvAppScreen.kt index 6bff0e88..d3e27b45 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/TvAppScreen.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/TvAppScreen.kt @@ -2,7 +2,6 @@ package hu.bbara.purefin.ui.screen import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Collections import androidx.compose.material.icons.outlined.Home import androidx.compose.material.icons.outlined.Movie import androidx.compose.material.icons.outlined.Tv @@ -10,18 +9,24 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.LifecycleResumeEffect -import hu.bbara.purefin.model.LibraryKind +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.navigation3.ui.NavDisplay import hu.bbara.purefin.feature.browse.home.AppViewModel +import hu.bbara.purefin.model.LibraryKind +import hu.bbara.purefin.navigation.LibraryDto +import hu.bbara.purefin.navigation.LocalNavigationManager +import hu.bbara.purefin.navigation.Route import hu.bbara.purefin.ui.screen.home.TvHomeScreen -import hu.bbara.purefin.ui.screen.home.components.TvDrawerDestination import hu.bbara.purefin.ui.screen.home.components.TvDrawerDestinationItem import hu.bbara.purefin.ui.screen.home.components.TvNavigationDrawer -import hu.bbara.purefin.ui.screen.libraries.TvLibrariesOverviewScreen +import hu.bbara.purefin.ui.screen.library.TvLibraryScreen @Composable fun TvAppScreen( @@ -32,28 +37,32 @@ fun TvAppScreen( val continueWatching by viewModel.continueWatching.collectAsState() val nextUp by viewModel.nextUp.collectAsState() val latestLibraryContent by viewModel.latestLibraryContent.collectAsState() + val navigationManager = LocalNavigationManager.current - var selectedDestination by rememberSaveable { androidx.compose.runtime.mutableStateOf(TvDrawerDestination.HOME) } + @Suppress("UNCHECKED_CAST") + val backStack = rememberNavBackStack(Route.Home) as NavBackStack + val selectedDestination = backStack.lastOrNull() ?: Route.Home - val destinations = remember(libraries, selectedDestination) { + val destinations = remember(libraries) { listOf( TvDrawerDestinationItem( - destination = TvDrawerDestination.HOME, + destination = Route.Home, label = "Home", - icon = Icons.Outlined.Home, - selected = selectedDestination == TvDrawerDestination.HOME - ), - TvDrawerDestinationItem( - destination = TvDrawerDestination.LIBRARIES, - label = "Libraries", - icon = when { - libraries.any { it.type == LibraryKind.MOVIES } -> Icons.Outlined.Movie - libraries.any { it.type == LibraryKind.SERIES } -> Icons.Outlined.Tv - else -> Icons.Outlined.Collections - }, - selected = selectedDestination == TvDrawerDestination.LIBRARIES + icon = Icons.Outlined.Home ) - ) + ) + libraries.map { library -> + val destination = Route.LibraryRoute( + library = LibraryDto(id = library.id, name = library.name) + ) + TvDrawerDestinationItem( + destination = destination, + label = library.name, + icon = when (library.type) { + LibraryKind.MOVIES -> Icons.Outlined.Movie + LibraryKind.SERIES -> Icons.Outlined.Tv + } + ) + } } LifecycleResumeEffect(Unit) { @@ -61,34 +70,55 @@ fun TvAppScreen( onPauseOrDispose { } } + val tvEntryProvider = entryProvider { + entry { + TvHomeScreen( + libraries = libraries, + libraryContent = latestLibraryContent, + continueWatching = continueWatching, + nextUp = nextUp, + onMediaSelected = viewModel::onMediaSelected, + modifier = Modifier.fillMaxSize() + ) + } + entry { route -> + TvLibraryScreen( + library = route.library, + onMediaSelected = viewModel::onMediaSelected, + modifier = Modifier.fillMaxSize() + ) + } + } + TvNavigationDrawer( destinations = destinations, selectedDestination = selectedDestination, onDestinationSelected = { destination -> - selectedDestination = destination + if (selectedDestination != destination) { + backStack.clear() + backStack.add(Route.Home) + if (destination is Route.LibraryRoute) { + backStack.add(destination) + } + } }, modifier = modifier.fillMaxSize() ) { - when (selectedDestination) { - TvDrawerDestination.HOME -> { - TvHomeScreen( - libraries = libraries, - libraryContent = latestLibraryContent, - continueWatching = continueWatching, - nextUp = nextUp, - onMediaSelected = viewModel::onMediaSelected, - modifier = Modifier.fillMaxSize() - ) - } - TvDrawerDestination.LIBRARIES -> { - TvLibrariesOverviewScreen( - libraries = libraries, - onLibrarySelected = { library -> - viewModel.onLibrarySelected(library.id, library.name) - }, - modifier = Modifier.fillMaxSize() - ) - } - } + NavDisplay( + backStack = backStack, + onBack = { + if (backStack.size > 1) { + backStack.removeLastOrNull() + } else { + navigationManager.pop() + } + }, + entryDecorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator() + ), + entryProvider = tvEntryProvider, + modifier = Modifier.fillMaxSize() + ) } } diff --git a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/home/components/TvHomeModels.kt b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/home/components/TvHomeModels.kt index dc5c4602..b73205f1 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/home/components/TvHomeModels.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/home/components/TvHomeModels.kt @@ -1,15 +1,10 @@ package hu.bbara.purefin.ui.screen.home.components import androidx.compose.ui.graphics.vector.ImageVector - -enum class TvDrawerDestination { - HOME, - LIBRARIES -} +import hu.bbara.purefin.navigation.Route data class TvDrawerDestinationItem( - val destination: TvDrawerDestination, + val destination: Route, val label: String, - val icon: ImageVector, - val selected: Boolean = false + val icon: ImageVector ) diff --git a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/home/components/TvNavigationDrawer.kt b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/home/components/TvNavigationDrawer.kt index 057fe81f..6e540103 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/home/components/TvNavigationDrawer.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/home/components/TvNavigationDrawer.kt @@ -30,9 +30,10 @@ import androidx.tv.material3.Icon import androidx.tv.material3.NavigationDrawer import androidx.tv.material3.NavigationDrawerItem import androidx.tv.material3.Text -import androidx.tv.material3.MaterialTheme as TvMaterialTheme import androidx.tv.material3.darkColorScheme +import hu.bbara.purefin.navigation.Route import hu.bbara.purefin.tv.R +import androidx.tv.material3.MaterialTheme as TvMaterialTheme internal const val TvDrawerItemTagPrefix = "tv-drawer-item-" internal const val TvDrawerTitleTag = "tv-drawer-title" @@ -43,8 +44,8 @@ private val TvDrawerExpandedWidth = 280.dp @Composable fun TvNavigationDrawer( destinations: List, - selectedDestination: TvDrawerDestination, - onDestinationSelected: (TvDrawerDestination) -> Unit, + selectedDestination: Route, + onDestinationSelected: (Route) -> Unit, modifier: Modifier = Modifier, content: @Composable () -> Unit ) { @@ -132,15 +133,15 @@ fun TvDrawerHeader( private fun androidx.tv.material3.NavigationDrawerScope.TvNavigationDrawerRail( drawerValue: DrawerValue, destinations: List, - selectedDestination: TvDrawerDestination, - onDestinationSelected: (TvDrawerDestination) -> Unit, + selectedDestination: Route, + onDestinationSelected: (Route) -> Unit, ) { val expanded = drawerValue == DrawerValue.Open val drawerWidth = animateDpAsState( targetValue = if (expanded) TvDrawerExpandedWidth else TvDrawerCollapsedWidth, label = "tv-drawer-width" ) - val scheme = androidx.tv.material3.MaterialTheme.colorScheme + val scheme = MaterialTheme.colorScheme Box( modifier = Modifier diff --git a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/library/TvLibraryScreen.kt b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/library/TvLibraryScreen.kt index e3b91321..f8b5e2f8 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/library/TvLibraryScreen.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/library/TvLibraryScreen.kt @@ -4,15 +4,12 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.outlined.ArrowBack import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -22,16 +19,15 @@ import androidx.compose.runtime.collectAsState import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel -import hu.bbara.purefin.navigation.LibraryDto -import hu.bbara.purefin.ui.model.MediaUiModel import hu.bbara.purefin.feature.browse.library.LibraryViewModel -import hu.bbara.purefin.ui.common.button.PurefinIconButton +import hu.bbara.purefin.navigation.LibraryDto import hu.bbara.purefin.ui.common.card.PosterCard -import java.util.UUID +import hu.bbara.purefin.ui.model.MediaUiModel @Composable fun TvLibraryScreen( library: LibraryDto, + onMediaSelected: (MediaUiModel) -> Unit, viewModel: LibraryViewModel = hiltViewModel(), modifier: Modifier = Modifier ) { @@ -45,15 +41,13 @@ fun TvLibraryScreen( modifier = modifier, topBar = { TvLibraryTopBar( - title = library.name, - onBack = viewModel::onBack + title = library.name ) } ) { innerPadding -> TvLibraryContent( libraryItems = libraryItems.value, - onMovieSelected = viewModel::onMovieSelected, - onSeriesSelected = viewModel::onSeriesSelected, + onMediaSelected = onMediaSelected, modifier = Modifier.padding(innerPadding) ) } @@ -61,40 +55,23 @@ fun TvLibraryScreen( @Composable private fun TvLibraryTopBar( - title: String, - onBack: () -> Unit + title: String ) { - val scheme = MaterialTheme.colorScheme - Row( + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, modifier = Modifier .fillMaxWidth() .statusBarsPadding() - .padding(horizontal = 16.dp, vertical = 12.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - PurefinIconButton( - icon = Icons.AutoMirrored.Outlined.ArrowBack, - contentDescription = "Back", - onClick = onBack, - focusedScale = 1.1f, - focusedBorderWidth = 2.5.dp, - focusedBorderColor = scheme.onPrimary, - focusedBackgroundColor = scheme.primary - ) - Text( - text = title, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onBackground, - modifier = Modifier.padding(top = 12.dp) - ) - } + .padding(horizontal = 16.dp, vertical = 12.dp) + ) } @Composable fun TvLibraryContent( libraryItems: List, - onMovieSelected: (UUID) -> Unit, - onSeriesSelected: (UUID) -> Unit, + onMediaSelected: (MediaUiModel) -> Unit, modifier: Modifier = Modifier ) { Column(modifier = modifier) { @@ -108,9 +85,9 @@ fun TvLibraryContent( items(libraryItems, key = { item -> item.id }) { item -> PosterCard( item = item, - onMovieSelected = onMovieSelected, - onSeriesSelected = onSeriesSelected, - onEpisodeSelected = { _, _, _ -> } + onMovieSelected = { onMediaSelected(item) }, + onSeriesSelected = { onMediaSelected(item) }, + onEpisodeSelected = { _, _, _ -> onMediaSelected(item) } ) } }