Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Tab> = 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<Tab> = buildList {
add(Tab.Home)
add(Tab.Libraries)
add(Tab.ForYou)
add(Tab.Calendar)
if (showDownloads) add(Tab.Downloads)
add(Tab.Downloads)
}

fun fallbackMobileTab(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Loading