From 59bfb3f26b1be911c7af927ade59b1edab8a9eba Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sat, 16 May 2026 21:59:07 +0530 Subject: [PATCH] feat: add scroll to top functionality across root screens --- .../commonMain/kotlin/com/nuvio/app/App.kt | 71 +++++++++++++------ .../com/nuvio/app/core/ui/NativeTabBridge.kt | 12 ++-- .../com/nuvio/app/features/home/HomeScreen.kt | 9 +++ .../app/features/library/LibraryScreen.kt | 12 ++++ .../nuvio/app/features/search/SearchScreen.kt | 9 +++ .../app/features/settings/SettingsScreen.kt | 35 ++++++++- 6 files changed, 119 insertions(+), 29 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index ae2f3728..30e9ef75 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -181,6 +181,8 @@ import com.nuvio.app.features.watchprogress.WatchProgressRepository import com.nuvio.app.features.watchprogress.nextUpDismissKey import com.nuvio.app.features.watching.application.WatchingActions import com.nuvio.app.features.watching.application.WatchingState +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import kotlinx.serialization.Serializable @@ -544,8 +546,11 @@ private fun MainAppContent( val coroutineScope = rememberCoroutineScope() var selectedTab by rememberSaveable { mutableStateOf(AppScreenTab.Home) } var searchFocusRequestCount by remember { mutableStateOf(0) } + val homeScrollToTopRequests = remember { MutableSharedFlow(extraBufferCapacity = 1) } + val searchScrollToTopRequests = remember { MutableSharedFlow(extraBufferCapacity = 1) } + val libraryScrollToTopRequests = remember { MutableSharedFlow(extraBufferCapacity = 1) } + val settingsRootActionRequests = remember { MutableSharedFlow(extraBufferCapacity = 1) } val currentBackStackEntry by navController.currentBackStackEntryAsState() - val nativeRequestedTab by remember { NativeTabBridge.requestedTab }.collectAsStateWithLifecycle() val liquidGlassNativeTabBarEnabled by remember { ThemeSettingsRepository.liquidGlassNativeTabBarEnabled }.collectAsStateWithLifecycle() @@ -602,9 +607,28 @@ private fun MainAppContent( .sorted() } - LaunchedEffect(nativeRequestedTab) { - if (liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled) { - selectedTab = nativeRequestedTab.toAppScreenTab() + fun handleRootTabClick(tab: AppScreenTab) { + if (selectedTab != tab) { + selectedTab = tab + return + } + + when (tab) { + AppScreenTab.Home -> homeScrollToTopRequests.tryEmit(Unit) + AppScreenTab.Search -> { + searchFocusRequestCount++ + searchScrollToTopRequests.tryEmit(Unit) + } + AppScreenTab.Library -> libraryScrollToTopRequests.tryEmit(Unit) + AppScreenTab.Settings -> settingsRootActionRequests.tryEmit(Unit) + } + } + + LaunchedEffect(liquidGlassNativeTabBarSupported, liquidGlassNativeTabBarEnabled) { + NativeTabBridge.requestedTabs.collectLatest { requestedTab -> + if (liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled) { + handleRootTabClick(requestedTab.toAppScreenTab()) + } } } @@ -1059,35 +1083,29 @@ private fun MainAppContent( NuvioNavigationBar { NavItem( selected = selectedTab == AppScreenTab.Home, - onClick = { selectedTab = AppScreenTab.Home }, + onClick = { handleRootTabClick(AppScreenTab.Home) }, icon = Icons.Filled.Home, contentDescription = stringResource(Res.string.compose_nav_home), ) NavItem( selected = selectedTab == AppScreenTab.Search, - onClick = { - if (selectedTab == AppScreenTab.Search) { - searchFocusRequestCount++ - } else { - selectedTab = AppScreenTab.Search - } - }, + onClick = { handleRootTabClick(AppScreenTab.Search) }, icon = Res.drawable.sidebar_search, contentDescription = stringResource(Res.string.compose_nav_search), ) NavItem( selected = selectedTab == AppScreenTab.Library, - onClick = { selectedTab = AppScreenTab.Library }, + onClick = { handleRootTabClick(AppScreenTab.Library) }, icon = Res.drawable.sidebar_library, contentDescription = stringResource(Res.string.compose_nav_library), ) NavItem( selected = selectedTab == AppScreenTab.Settings, - onClick = { selectedTab = AppScreenTab.Settings }, + onClick = { handleRootTabClick(AppScreenTab.Settings) }, ) { ProfileSwitcherTab( selected = selectedTab == AppScreenTab.Settings, - onClick = { selectedTab = AppScreenTab.Settings }, + onClick = { handleRootTabClick(AppScreenTab.Settings) }, onProfileSelected = onProfileSelected, onAddProfileRequested = onSwitchProfile, ) @@ -1106,6 +1124,11 @@ private fun MainAppContent( .padding(innerPadding), selectedTab = selectedTab, searchFocusRequestCount = searchFocusRequestCount, + rootActionsEnabled = tabsRouteActive, + homeScrollToTopRequests = homeScrollToTopRequests, + searchScrollToTopRequests = searchScrollToTopRequests, + libraryScrollToTopRequests = libraryScrollToTopRequests, + settingsRootActionRequests = settingsRootActionRequests, animateHomeCollectionGifs = tabsRouteActive, onCatalogClick = onCatalogClick, onPosterClick = { meta -> @@ -1160,13 +1183,7 @@ private fun MainAppContent( if (isTabletLayout && !useNativeBottomTabs) { TabletFloatingTopBar( selectedTab = selectedTab, - onTabSelected = { tab -> - if (tab == AppScreenTab.Search && selectedTab == AppScreenTab.Search) { - searchFocusRequestCount++ - } else { - selectedTab = tab - } - }, + onTabSelected = ::handleRootTabClick, onProfileSelected = onProfileSelected, onAddProfileRequested = onSwitchProfile, ) @@ -2196,6 +2213,11 @@ private fun AppTabHost( selectedTab: AppScreenTab, modifier: Modifier = Modifier, searchFocusRequestCount: Int = 0, + rootActionsEnabled: Boolean = true, + homeScrollToTopRequests: Flow, + searchScrollToTopRequests: Flow, + libraryScrollToTopRequests: Flow, + settingsRootActionRequests: Flow, animateHomeCollectionGifs: Boolean = true, onCatalogClick: ((HomeCatalogSection) -> Unit)? = null, onPosterClick: ((MetaPreview) -> Unit)? = null, @@ -2228,6 +2250,7 @@ private fun AppTabHost( HomeScreen( modifier = Modifier.fillMaxSize(), animateCollectionGifs = animateHomeCollectionGifs, + scrollToTopRequests = homeScrollToTopRequests, onCatalogClick = onCatalogClick, onPosterClick = onPosterClick, onPosterLongClick = onPosterLongClick, @@ -2244,12 +2267,14 @@ private fun AppTabHost( onPosterClick = onPosterClick, onPosterLongClick = onPosterLongClick, searchFocusRequestCount = searchFocusRequestCount, + scrollToTopRequests = searchScrollToTopRequests, ) } AppScreenTab.Library -> { LibraryScreen( modifier = Modifier.fillMaxSize(), + scrollToTopRequests = libraryScrollToTopRequests, onPosterClick = onLibraryPosterClick, onSectionViewAllClick = onLibrarySectionViewAllClick, ) @@ -2258,6 +2283,8 @@ private fun AppTabHost( AppScreenTab.Settings -> { SettingsScreen( modifier = Modifier.fillMaxSize(), + rootActionRequests = settingsRootActionRequests, + rootActionsEnabled = rootActionsEnabled, onSwitchProfile = onSwitchProfile, onHomescreenClick = onHomescreenSettingsClick, onMetaScreenClick = onMetaScreenSettingsClick, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.kt index d7422533..aa426d02 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.kt @@ -1,8 +1,8 @@ package com.nuvio.app.core.ui -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow internal enum class NativeNavigationTab { Home, @@ -18,11 +18,11 @@ internal enum class NativeNavigationTab { } internal object NativeTabBridge { - private val _requestedTab = MutableStateFlow(NativeNavigationTab.Home) - val requestedTab: StateFlow = _requestedTab.asStateFlow() + private val _requestedTabs = MutableSharedFlow(extraBufferCapacity = 1) + val requestedTabs: SharedFlow = _requestedTabs.asSharedFlow() fun requestTab(tabName: String) { - _requestedTab.value = NativeNavigationTab.fromName(tabName) + _requestedTabs.tryEmit(NativeNavigationTab.fromName(tabName)) } fun publishSelectedTab(tab: NativeNavigationTab) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt index c3c1a2a6..aa4be057 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt @@ -60,6 +60,8 @@ import com.nuvio.app.features.home.components.HomeCollectionRowSection import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import com.nuvio.app.features.home.components.ContinueWatchingLayout @@ -72,6 +74,7 @@ import org.jetbrains.compose.resources.stringResource fun HomeScreen( modifier: Modifier = Modifier, animateCollectionGifs: Boolean = true, + scrollToTopRequests: Flow = emptyFlow(), onCatalogClick: ((HomeCatalogSection) -> Unit)? = null, onPosterClick: ((MetaPreview) -> Unit)? = null, onPosterLongClick: ((MetaPreview) -> Unit)? = null, @@ -107,6 +110,12 @@ fun HomeScreen( }.collectAsStateWithLifecycle() var observedOfflineState by remember { mutableStateOf(false) } + LaunchedEffect(scrollToTopRequests) { + scrollToTopRequests.collect { + homeListState.animateScrollToItem(0) + } + } + LaunchedEffect(networkStatusUiState.condition) { when (networkStatusUiState.condition) { NetworkCondition.NoInternet, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt index 4a8f78c3..abc078a9 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -32,6 +33,8 @@ import com.nuvio.app.features.home.components.HomeEmptyStateCard import com.nuvio.app.features.home.components.HomePosterCard import com.nuvio.app.features.home.components.HomeSkeletonRow import com.nuvio.app.features.profiles.ProfileRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.launch import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.getString @@ -46,6 +49,7 @@ private data class LibraryRemovalTarget( @Composable fun LibraryScreen( modifier: Modifier = Modifier, + scrollToTopRequests: Flow = emptyFlow(), onPosterClick: ((LibraryItem) -> Unit)? = null, onSectionViewAllClick: ((LibrarySection) -> Unit)? = null, ) { @@ -57,6 +61,7 @@ fun LibraryScreen( var pendingRemovalTarget by remember { mutableStateOf(null) } var observedOfflineState by remember { mutableStateOf(false) } val coroutineScope = rememberCoroutineScope() + val listState = rememberLazyListState() val isTraktSource = uiState.sourceMode == LibrarySourceMode.TRAKT val retryLibraryLoad: () -> Unit = { NetworkStatusRepository.requestRefresh(force = true) @@ -89,9 +94,16 @@ fun LibraryScreen( } } + LaunchedEffect(scrollToTopRequests) { + scrollToTopRequests.collect { + listState.animateScrollToItem(0) + } + } + NuvioScreen( modifier = modifier, horizontalPadding = 0.dp, + listState = listState, ) { stickyHeader { androidx.compose.foundation.layout.Column( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt index 3d5cc814..3720ce52 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt @@ -57,7 +57,9 @@ import com.nuvio.app.features.home.components.homeSectionHorizontalPaddingForWid import com.nuvio.app.features.home.components.HomeSkeletonRow import com.nuvio.app.features.watched.WatchedRepository import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map import nuvio.composeapp.generated.resources.Res @@ -83,6 +85,7 @@ fun SearchScreen( onPosterClick: ((MetaPreview) -> Unit)? = null, onPosterLongClick: ((MetaPreview) -> Unit)? = null, searchFocusRequestCount: Int = 0, + scrollToTopRequests: Flow = emptyFlow(), ) { val focusRequester = remember { FocusRequester() } @@ -115,6 +118,12 @@ fun SearchScreen( } } + LaunchedEffect(scrollToTopRequests) { + scrollToTopRequests.collect { + listState.animateScrollToItem(0) + } + } + val addonRefreshKey = remember(addonsUiState.addons) { addonsUiState.addons.mapNotNull { addon -> val manifest = addon.manifest ?: return@mapNotNull null diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt index 519dc8d5..21442208 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt @@ -80,6 +80,9 @@ import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesUiState import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.compose_settings_page_root import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource @@ -90,6 +93,8 @@ private const val SettingsSearchRevealHapticDelayMillis = 90L @Composable fun SettingsScreen( modifier: Modifier = Modifier, + rootActionRequests: Flow = emptyFlow(), + rootActionsEnabled: Boolean = true, onSwitchProfile: (() -> Unit)? = null, onHomescreenClick: () -> Unit = {}, onMetaScreenClick: () -> Unit = {}, @@ -200,17 +205,31 @@ fun SettingsScreen( } var currentPage by rememberSaveable { mutableStateOf(SettingsPage.Root.name) } + val scrollToTopRequests = remember { MutableSharedFlow(extraBufferCapacity = 1) } val page = remember(currentPage) { SettingsPage.valueOf(currentPage) } val previousPage = page.previousPage() + LaunchedEffect(rootActionRequests, rootActionsEnabled, page) { + rootActionRequests.collect { + if (!rootActionsEnabled) return@collect + val pageToOpen = page.previousPage() + if (pageToOpen != null) { + currentPage = pageToOpen.name + } else { + scrollToTopRequests.tryEmit(Unit) + } + } + } + PlatformBackHandler( - enabled = previousPage != null, + enabled = rootActionsEnabled && previousPage != null, onBack = { previousPage?.let { currentPage = it.name } }, ) if (maxWidth >= 768.dp) { TabletSettingsScreen( page = page, + scrollToTopRequests = scrollToTopRequests, onPageChange = { currentPage = it.name }, showLoadingOverlay = playerSettingsUiState.showLoadingOverlay, holdToSpeedEnabled = playerSettingsUiState.holdToSpeedEnabled, @@ -259,6 +278,7 @@ fun SettingsScreen( } else { MobileSettingsScreen( page = page, + scrollToTopRequests = scrollToTopRequests, onPageChange = { currentPage = it.name }, showLoadingOverlay = playerSettingsUiState.showLoadingOverlay, holdToSpeedEnabled = playerSettingsUiState.holdToSpeedEnabled, @@ -317,6 +337,7 @@ fun SettingsScreen( @Composable private fun MobileSettingsScreen( page: SettingsPage, + scrollToTopRequests: Flow, onPageChange: (SettingsPage) -> Unit, showLoadingOverlay: Boolean, holdToSpeedEnabled: Boolean, @@ -427,6 +448,12 @@ private fun MobileSettingsScreen( } } + LaunchedEffect(scrollToTopRequests) { + scrollToTopRequests.collect { + listState.animateScrollToItem(0) + } + } + NuvioScreen( modifier = Modifier.nestedScroll(rootSearchRevealConnection), listState = listState, @@ -624,6 +651,7 @@ private fun rememberSettingsRootSearchRevealConnection( @Composable private fun TabletSettingsScreen( page: SettingsPage, + scrollToTopRequests: Flow, onPageChange: (SettingsPage) -> Unit, showLoadingOverlay: Boolean, holdToSpeedEnabled: Boolean, @@ -773,6 +801,11 @@ private fun TabletSettingsScreen( rootSearchRevealAnimating = false } } + LaunchedEffect(scrollToTopRequests) { + scrollToTopRequests.collect { + listState.animateScrollToItem(0) + } + } LazyColumn( state = listState, modifier = Modifier