From 7037fffb4e9c0b49e168275c832c3d794207f9da Mon Sep 17 00:00:00 2001 From: blurbery <271863866+blurbery@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:15:35 +1000 Subject: [PATCH] fix(navigation): keep downloads tab visible --- .../ui/navigation/MobileDownloadVisibility.kt | 5 -- .../android/ui/navigation/MobileMediaTabs.kt | 16 ++--- .../silo/android/ui/screens/MainScreen.kt | 59 ++----------------- .../MobileDownloadVisibilityTest.kt | 7 --- .../ui/navigation/MobileMediaTabsTest.kt | 51 +++------------- 5 files changed, 17 insertions(+), 121 deletions(-) diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/MobileDownloadVisibility.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/MobileDownloadVisibility.kt index 9479420d6..8cc5de489 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/MobileDownloadVisibility.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/MobileDownloadVisibility.kt @@ -18,11 +18,6 @@ fun hasLocalDownloadsForScope( profileId: String?, ): Boolean = scopedLocalDownloadBytes(storage, serverId, profileId) > 0L -fun shouldShowDownloadsTab( - serverRecordCount: Int, - activeScopeLocalBytes: Long, -): Boolean = serverRecordCount > 0 || activeScopeLocalBytes > 0L - fun shouldStartOnDownloads( hasLocalDownloads: Boolean, isDeviceOnline: Boolean, diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/MobileMediaTabs.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/MobileMediaTabs.kt index 9946847dc..d72ecfbb5 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/MobileMediaTabs.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/MobileMediaTabs.kt @@ -1,22 +1,18 @@ package org.siloserver.silo.android.ui.navigation -import org.siloserver.silo.model.navigation.MediaModeCapabilities - val Tab.isUtilityTab: Boolean get() = this == Tab.Downloads -// Apple-aligned shell: Home · Libraries · For You · Calendar, plus a Downloads -// tab only when the user has downloads. There is no per-media-type tab — -// library content (video / audio / reading) is reached through the Libraries picker. -fun visibleMobileTabs( - @Suppress("UNUSED_PARAMETER") capabilities: MediaModeCapabilities, - showDownloads: Boolean, -): List = buildList { +// Apple-aligned shell: Home · Libraries · For You · Calendar · Downloads. +// Downloads is a stable destination from the first frame; its screen owns the +// empty and unavailable states instead of making the navigation bar reflow +// after capability or local-record hydration completes. +fun visibleMobileTabs(): List = buildList { add(Tab.Home) add(Tab.Libraries) add(Tab.ForYou) add(Tab.Calendar) - if (showDownloads) add(Tab.Downloads) + add(Tab.Downloads) } fun fallbackMobileTab( diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/MainScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/MainScreen.kt index 009c1986d..b6413dae5 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/MainScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/MainScreen.kt @@ -23,7 +23,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable @@ -47,8 +46,6 @@ import org.siloserver.silo.android.ui.navigation.tabForRoute import org.siloserver.silo.android.ui.navigation.tabSwitchNavOptions import org.siloserver.silo.android.ui.navigation.bottomMostTabRoute import org.siloserver.silo.android.ui.navigation.fallbackMobileTab -import org.siloserver.silo.android.ui.navigation.scopedLocalDownloadBytes -import org.siloserver.silo.android.ui.navigation.shouldShowDownloadsTab import org.siloserver.silo.android.ui.navigation.visibleMobileTabs import org.siloserver.silo.android.ui.screens.calendar.CalendarScreen import org.siloserver.silo.android.ui.screens.home.HomeScreen @@ -64,17 +61,12 @@ import org.siloserver.silo.android.ui.screens.recommendations.headerTitle import org.siloserver.silo.android.ui.screens.watchtogether.WatchTogetherMenuEntrySheet import org.siloserver.silo.cast.SiloCastPlaybackRequest import org.siloserver.silo.model.feature.CLIENT_WATCH_TOGETHER_SURFACE_ENABLED -import org.siloserver.silo.model.navigation.MediaMode -import org.siloserver.silo.model.navigation.MediaModeCapabilities -import org.siloserver.silo.model.navigation.mobileMediaModeCapabilities import org.siloserver.silo.model.feature.MetadataAiFeatureStore import org.siloserver.silo.model.feature.RequestsFeatureStore import org.siloserver.silo.common.network.ServerReachabilityMonitor import org.siloserver.silo.common.network.ServerReachabilityStatus -import org.siloserver.silo.network.ApiResult import org.siloserver.silo.network.ServerRegistry import org.siloserver.silo.repository.AuthRepository -import org.siloserver.silo.repository.PersonalDataRepository import org.siloserver.silo.viewmodel.HomeViewModel import kotlinx.coroutines.launch import org.koin.compose.koinInject @@ -133,14 +125,6 @@ fun MainScreen( // reacts by scrolling back to the top. var homeScrollToTopTick by remember { mutableStateOf(0) } - // Downloads tab visibility: show whenever EITHER the server says there - // are records OR we have bytes on disk. The on-disk check is what makes - // the tab survive airplane mode — `repository.refresh()` returns an - // empty list when offline, but the downloaded files are still there - // and we want the user to reach them. - val personalDataRepository: PersonalDataRepository = koinInject() - val downloadsRepository: org.siloserver.silo.repository.DownloadsRepository = koinInject() - val downloadStorage: org.siloserver.silo.common.downloads.DownloadStorage = koinInject() val serverRegistry: ServerRegistry = koinInject() val authRepository: AuthRepository = koinInject() val reachabilityMonitor: ServerReachabilityMonitor = koinInject() @@ -150,45 +134,10 @@ fun MainScreen( val requestsEnabled by requestsFeatureStore.isEnabled.collectAsState() val reachabilityScope = rememberCoroutineScope() val activeEntry by serverRegistry.activeEntry.collectAsState() - val mediaCapabilities by produceState( - initialValue = MediaModeCapabilities( - listOf( - MediaMode.Video, - MediaMode.Audio, - MediaMode.Reading, - ), - ), - personalDataRepository, - ) { - value = when (val result = personalDataRepository.listUserLibraries()) { - is ApiResult.Success -> result.data.mobileMediaModeCapabilities() - else -> value - } - } - val downloadRecords by downloadsRepository.records.collectAsState() - val activeScopeLocalBytes by produceState( - initialValue = 0L, - downloadRecords, - activeEntry?.id, - activeEntry?.profileId, - headerState.activeProfile?.id, - ) { - value = scopedLocalDownloadBytes( - storage = downloadStorage, - serverId = activeEntry?.id, - profileId = activeEntry?.profileId ?: headerState.activeProfile?.id, - ) - } - val visibleTabs = remember(mediaCapabilities, downloadRecords, activeScopeLocalBytes) { - val hasAnyDownload = shouldShowDownloadsTab( - serverRecordCount = downloadRecords.size, - activeScopeLocalBytes = activeScopeLocalBytes, - ) - visibleMobileTabs( - capabilities = mediaCapabilities, - showDownloads = hasAnyDownload, - ) - } + // Keep the mobile shell stable while profile capabilities and local + // download records hydrate. DownloadsScreen handles empty/unavailable + // states without adding or removing a bottom-navigation destination. + val visibleTabs = remember { visibleMobileTabs() } // The shared top bar floats over content; its real height is the status-bar // inset plus the fixed bar body. Offset tab content by that so the bar can't diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/navigation/MobileDownloadVisibilityTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/navigation/MobileDownloadVisibilityTest.kt index 4489219af..af50f6e5d 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/navigation/MobileDownloadVisibilityTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/navigation/MobileDownloadVisibilityTest.kt @@ -25,13 +25,6 @@ class MobileDownloadVisibilityTest { assertTrue(hasLocalDownloadsForScope(storage, "srv1", "profA")) } - @Test - fun `download tab visibility uses records or active scoped local bytes`() { - assertFalse(shouldShowDownloadsTab(serverRecordCount = 0, activeScopeLocalBytes = 0L)) - assertTrue(shouldShowDownloadsTab(serverRecordCount = 1, activeScopeLocalBytes = 0L)) - assertTrue(shouldShowDownloadsTab(serverRecordCount = 0, activeScopeLocalBytes = 1L)) - } - @Test fun `startup routes to downloads when local media exists and server cannot be used`() { assertFalse( diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/navigation/MobileMediaTabsTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/navigation/MobileMediaTabsTest.kt index 854027ce8..652093439 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/navigation/MobileMediaTabsTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/navigation/MobileMediaTabsTest.kt @@ -1,58 +1,21 @@ package org.siloserver.silo.android.ui.navigation -import org.siloserver.silo.model.navigation.MediaMode -import org.siloserver.silo.model.navigation.MediaModeCapabilities import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertFalse import kotlin.test.assertTrue class MobileMediaTabsTest { - // Apple-aligned shell: Home · Libraries · For You · Calendar, independent of which - // media types the libraries contain. Library content (video / audio / - // reading) is reached through the Libraries picker. Downloads only appears - // when the user has downloads. - private val baseLabels = listOf("Home", "Libraries", "For You", "Calendar") + // The mobile shell is fixed from its first frame; content and capability + // hydration must not insert a tab later and reflow the navigation bar. + private val fixedLabels = listOf("Home", "Libraries", "For You", "Calendar", "Downloads") @Test - fun fixedTabsAppendDownloadsWhenPresent() { - val tabs = visibleMobileTabs( - capabilities = MediaModeCapabilities(listOf(MediaMode.Video)), - showDownloads = true, - ) - - assertEquals(baseLabels + "Downloads", tabs.map { it.label }) - } - - @Test - fun tabsAreFixedRegardlessOfLibraryTypes() { - assertEquals( - baseLabels, - visibleMobileTabs(MediaModeCapabilities(listOf(MediaMode.Audio)), showDownloads = false) - .map { it.label }, - ) - assertEquals( - baseLabels, - visibleMobileTabs(MediaModeCapabilities(listOf(MediaMode.Reading)), showDownloads = false) - .map { it.label }, - ) - assertEquals( - baseLabels, - visibleMobileTabs(MediaModeCapabilities(emptyList()), showDownloads = false) - .map { it.label }, - ) - } - - @Test - fun downloadsStaysHiddenWhenNoDownloadsExist() { - val tabs = visibleMobileTabs( - capabilities = MediaModeCapabilities(listOf(MediaMode.Video, MediaMode.Audio)), - showDownloads = false, - ) + fun fixedTabsAlwaysIncludeDownloads() { + val tabs = visibleMobileTabs() - assertEquals(baseLabels, tabs.map { it.label }) - assertFalse(Tab.Downloads in tabs) + assertEquals(fixedLabels, tabs.map { it.label }) + assertTrue(Tab.Downloads in tabs) } @Test