Refactor app navigation to use Navigation3

Integrate `navigation3` into `AppScreen` to manage tab transitions and
backstack state. Replaced manual tab state management with `NavDisplay`
and `NavBackStack`, and added horizontal slide animations for switching
between Home, Libraries, and Downloads screens.
This commit is contained in:
2026-04-20 20:23:40 +02:00
parent a39581682f
commit d0e5e978be

View File

@@ -1,34 +1,52 @@
package hu.bbara.purefin.ui.screen
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.IntOffset
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.scene.Scene
import androidx.navigation3.ui.NavDisplay
import hu.bbara.purefin.feature.browse.home.AppViewModel
import hu.bbara.purefin.navigation.LocalNavigationManager
import hu.bbara.purefin.ui.screen.download.DownloadsScreen
import hu.bbara.purefin.ui.screen.home.HomeScreen
import hu.bbara.purefin.ui.screen.home.components.HomeNavItem
import hu.bbara.purefin.ui.screen.libraries.LibrariesScreen
import kotlinx.serialization.Serializable
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
@Composable
fun AppScreen(
viewModel: AppViewModel = hiltViewModel(),
modifier: Modifier = Modifier
) {
var selectedTab by remember { mutableIntStateOf(0) }
val libraries by viewModel.libraries.collectAsState()
val libraryContent by viewModel.latestLibraryContent.collectAsState()
val suggestions by viewModel.suggestions.collectAsState()
val continueWatching by viewModel.continueWatching.collectAsState()
val nextUp by viewModel.nextUp.collectAsState()
val isRefreshing by viewModel.isRefreshing.collectAsState()
val navigationManager = LocalNavigationManager.current
@Suppress("UNCHECKED_CAST")
val backStack = rememberNavBackStack(AppTabRoute.Home) as NavBackStack<AppTabRoute>
val currentRoute = backStack.lastOrNull() ?: AppTabRoute.Home
val selectedTab = currentRoute.toTabIndex()
val libraryNavItems = libraries.map {
HomeNavItem(
@@ -43,8 +61,18 @@ fun AppScreen(
onPauseOrDispose { }
}
when (selectedTab) {
0 -> HomeScreen(
val onTabSelected = remember(backStack) {
{ selectedIndex: Int ->
val route = selectedIndex.toAppTabRoute()
if (backStack.lastOrNull() != route) {
backStack.add(route)
}
}
}
val tabEntryProvider = entryProvider {
entry<AppTabRoute.Home>(metadata = appTabMetadata(AppTabRoute.Home)) {
HomeScreen(
libraries = libraries,
libraryContent = libraryContent,
suggestions = suggestions,
@@ -63,23 +91,101 @@ fun AppScreen(
onSettingsClick = {},
onLogoutClick = viewModel::logout,
selectedTab = selectedTab,
onTabSelected = { selectedTab = it },
modifier = modifier.fillMaxSize()
onTabSelected = onTabSelected,
modifier = Modifier.fillMaxSize()
)
1 -> LibrariesScreen(
}
entry<AppTabRoute.Libraries>(metadata = appTabMetadata(AppTabRoute.Libraries)) {
LibrariesScreen(
items = libraryNavItems,
onLibrarySelected = { item -> viewModel.onLibrarySelected(item.id, item.label) },
onProfileClick = {},
onSettingsClick = {},
onLogoutClick = viewModel::logout,
selectedTab = selectedTab,
onTabSelected = { selectedTab = it },
modifier = modifier.fillMaxSize()
onTabSelected = onTabSelected,
modifier = Modifier.fillMaxSize()
)
2 -> DownloadsScreen(
}
entry<AppTabRoute.Downloads>(metadata = appTabMetadata(AppTabRoute.Downloads)) {
DownloadsScreen(
selectedTab = selectedTab,
onTabSelected = { selectedTab = it },
modifier = modifier.fillMaxSize()
onTabSelected = onTabSelected,
modifier = Modifier.fillMaxSize()
)
}
}
NavDisplay(
backStack = backStack,
onBack = {
if (backStack.size > 1) {
backStack.removeLastOrNull()
} else {
navigationManager.pop()
}
},
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator()
),
transitionSpec = { appTabTransition() },
popTransitionSpec = { appTabTransition() },
predictivePopTransitionSpec = { _ -> appTabTransition() },
entryProvider = tabEntryProvider,
modifier = modifier.fillMaxSize()
)
}
@Serializable
private sealed interface AppTabRoute : NavKey {
@Serializable
data object Home : AppTabRoute
@Serializable
data object Libraries : AppTabRoute
@Serializable
data object Downloads : AppTabRoute
}
private fun AppTabRoute.toTabIndex(): Int = when (this) {
AppTabRoute.Home -> 0
AppTabRoute.Libraries -> 1
AppTabRoute.Downloads -> 2
}
private fun Int.toAppTabRoute(): AppTabRoute = when (this) {
0 -> AppTabRoute.Home
1 -> AppTabRoute.Libraries
2 -> AppTabRoute.Downloads
else -> AppTabRoute.Home
}
private fun AnimatedContentTransitionScope<Scene<AppTabRoute>>.appTabTransition(): ContentTransform {
val initialIndex = initialState.metadata.appTabIndex()
val targetIndex = targetState.metadata.appTabIndex()
val animationSpec = tween<IntOffset>(durationMillis = 140)
return when {
targetIndex > initialIndex -> {
slideInHorizontally(animationSpec = animationSpec) { fullWidth -> fullWidth } togetherWith
slideOutHorizontally(animationSpec = animationSpec) { fullWidth -> -fullWidth }
}
targetIndex < initialIndex -> {
slideInHorizontally(animationSpec = animationSpec) { fullWidth -> -fullWidth } togetherWith
slideOutHorizontally(animationSpec = animationSpec) { fullWidth -> fullWidth }
}
else -> {
slideInHorizontally(animationSpec = animationSpec) { 0 } togetherWith
slideOutHorizontally(animationSpec = animationSpec) { 0 }
}
}
}
private fun appTabMetadata(route: AppTabRoute): Map<String, Any> =
mapOf(APP_TAB_INDEX_METADATA to route.toTabIndex())
private fun Map<String, Any>.appTabIndex(): Int =
this[APP_TAB_INDEX_METADATA] as? Int ?: 0
private const val APP_TAB_INDEX_METADATA = "app_tab_index"