From 47787182d0b7943cc49d411b04ecf7d5a91ceb29 Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Wed, 21 Oct 2026 03:41:24 +0000 Subject: [PATCH] ui: scroll the library tab strip and explain offline mode The tab bar measured the selected tab's full label before the trailing controls, so a long translation or a large font scale squeezed the settings gear and eventually removed it, and an indicator in the bar made that happen sooner. Put the tabs in a weighted, horizontally scrollable strip and keep the gear outside it, then scroll the selected tab into view when the pager settles, in either layout direction. Move the offline indicator out of the bar into a strip above the tabs that names the mode and offers to go online. Offline mode limits every list, and Home already swaps to downloaded content without saying why; an icon in the bar could not explain that and competed for the same width. --- .../com/subtracks/ui/library/LibraryScreen.kt | 200 ++++++++++++------ app/src/main/res/values/strings.xml | 3 +- .../com/subtracks/ui/LibraryScreenTest.kt | 171 +++++++++++++++ 3 files changed, 314 insertions(+), 60 deletions(-) diff --git a/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt index 61dd3a61..26d0f9fb 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -16,6 +17,7 @@ import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -25,6 +27,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -47,6 +50,7 @@ import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChipDefaults import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButtonDefaults +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LocalContentColor @@ -94,12 +98,14 @@ import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.paging.PagingData @@ -586,14 +592,15 @@ fun LibraryScreen( .onSizeChanged { tabBarHeightPx = it.height.toFloat() } .padding(bottom = bottomInset), ) { + if (offline) { + OfflineStrip(onGoOnline = onExitOffline) + } LibraryTabs( pagerState = pagerState, onTabSelected = { tab -> if (tab == selectedTab) topRequest++ else onTabSelected(tab) }, onOpenSettings = onOpenSettings, - offline = offline, - onExitOffline = onExitOffline, artwork = artwork, syncing = syncing, ) @@ -856,13 +863,12 @@ internal fun LibraryTabs( pagerState: PagerState, onTabSelected: (LibraryTab) -> Unit, onOpenSettings: () -> Unit, - offline: Boolean = false, - onExitOffline: () -> Unit = {}, artwork: ArtworkColors?, syncing: Boolean = false, ) { val indicatorStretch = 18.dp val density = LocalDensity.current + val layoutDirection = LocalLayoutDirection.current val bounds = remember { mutableStateMapOf() } val page = pagerState.currentPage val fraction = pagerState.currentPageOffsetFraction @@ -894,6 +900,28 @@ internal fun LibraryTabs( val indicatorColor = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.onBackground val tabContentHeightPx = with(density) { (TAB_ICON_SIZE + TAB_VERTICAL_PADDING * 2).toPx() } + val scrollState = rememberScrollState() + var viewportWidth by remember { mutableIntStateOf(0) } + var contentWidth by remember { mutableIntStateOf(0) } + var revealed by remember { mutableStateOf(false) } + LaunchedEffect(pagerState.settledPage, viewportWidth, contentWidth, layoutDirection, bounds[pagerState.settledPage]) { + if (viewportWidth == 0) return@LaunchedEffect + val settled = bounds[pagerState.settledPage] ?: return@LaunchedEffect + val start = if (layoutDirection == LayoutDirection.Rtl) contentWidth - settled.right else settled.left + val end = if (layoutDirection == LayoutDirection.Rtl) contentWidth - settled.left else settled.right + val target = + when { + start < scrollState.value -> start + end > scrollState.value + viewportWidth -> end - viewportWidth + else -> null + } + if (target != null) { + val offset = target.roundToInt().coerceIn(0, scrollState.maxValue) + if (revealed) scrollState.animateScrollTo(offset) else scrollState.scrollTo(offset) + } + revealed = true + } + Box(Modifier.fillMaxWidth()) { Box( modifier = @@ -902,71 +930,125 @@ internal fun LibraryTabs( .background(headerColor) .padding(horizontal = 8.dp, vertical = 2.dp), ) { - Box( - modifier = - Modifier.drawBehind { - if (current != null && indicatorLeft != null && indicatorRight != null && indicatorRight > indicatorLeft) { - val height = tabContentHeightPx.coerceAtMost(current.height) - drawRoundRect( - color = indicatorColor, - topLeft = Offset(indicatorLeft, current.top + (current.height - height) / 2f), - size = Size(indicatorRight - indicatorLeft, height), - cornerRadius = CornerRadius(8.dp.toPx()), - ) - } - }, + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), ) { Row( - modifier = Modifier.fillMaxWidth(), + modifier = + Modifier + .weight(1f) + .onSizeChanged { viewportWidth = it.width } + .horizontalScroll(scrollState), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), ) { - LibraryTab.entries.forEachIndexed { index, tab -> - TabButton( - tab = tab, - artwork = artwork, - progress = - when (index) { - page -> 1f - transition - page + (if (fraction >= 0f) 1 else -1) -> transition - else -> 0f + Box( + modifier = + Modifier + .onSizeChanged { contentWidth = it.width } + .drawBehind { + if (current != null && + indicatorLeft != null && + indicatorRight != null && + indicatorRight > indicatorLeft + ) { + val height = tabContentHeightPx.coerceAtMost(current.height) + drawRoundRect( + color = indicatorColor, + topLeft = Offset(indicatorLeft, current.top + (current.height - height) / 2f), + size = Size(indicatorRight - indicatorLeft, height), + cornerRadius = CornerRadius(8.dp.toPx()), + ) + } }, - indicatorLeft = indicatorLeft, - indicatorRight = indicatorRight, - onClick = { onTabSelected(tab) }, - modifier = - Modifier.onGloballyPositioned { coordinates -> - bounds[index] = coordinates.boundsInParent() - }, - ) - } - Spacer(Modifier.weight(1f)) - if (offline) { - IconButton(onClick = onExitOffline) { - Icon( - imageVector = Icons.Rounded.CloudOff, - contentDescription = stringResource(R.string.offline_mode_status), - tint = MaterialTheme.colorScheme.onBackground, - ) - } - } - IconButton(onClick = onOpenSettings) { - Box(contentAlignment = Alignment.Center) { - Icon( - imageVector = Icons.Rounded.Settings, - contentDescription = stringResource(R.string.navigation_tabs_settings), - tint = MaterialTheme.colorScheme.onBackground, - ) - if (syncing) { - CircularProgressIndicator( - modifier = Modifier.size(32.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.primary, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + LibraryTab.entries.forEachIndexed { index, tab -> + TabButton( + tab = tab, + artwork = artwork, + progress = + when (index) { + page -> 1f - transition + page + (if (fraction >= 0f) 1 else -1) -> transition + else -> 0f + }, + indicatorLeft = indicatorLeft, + indicatorRight = indicatorRight, + onClick = { onTabSelected(tab) }, + modifier = + Modifier.onGloballyPositioned { coordinates -> + bounds[index] = coordinates.boundsInParent() + }, ) } } } } + IconButton(onClick = onOpenSettings) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Rounded.Settings, + contentDescription = stringResource(R.string.navigation_tabs_settings), + tint = MaterialTheme.colorScheme.onBackground, + ) + if (syncing) { + CircularProgressIndicator( + modifier = Modifier.size(32.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } + } + } + } +} + +@Composable +private fun OfflineStrip( + onGoOnline: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier.fillMaxWidth()) { + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + Surface( + onClick = onGoOnline, + color = Color.Transparent, + contentColor = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = 44.dp) + .padding(horizontal = 16.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = Icons.Rounded.CloudOff, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), + ) + Text( + text = stringResource(R.string.offline_mode_banner), + style = MaterialTheme.typography.bodyMedium, + maxLines = 2, + modifier = Modifier.weight(1f), + ) + Text( + text = stringResource(R.string.offline_go_online), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + ) } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index af58820e..012e119e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -175,7 +175,8 @@ Filters Downloaded Close search - Offline mode; tap to go online + Offline mode — downloads only + Go online Shuffle artist Playing diff --git a/app/src/test/kotlin/com/subtracks/ui/LibraryScreenTest.kt b/app/src/test/kotlin/com/subtracks/ui/LibraryScreenTest.kt index 3cd0a327..9819fc37 100644 --- a/app/src/test/kotlin/com/subtracks/ui/LibraryScreenTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/LibraryScreenTest.kt @@ -2,8 +2,10 @@ package com.subtracks.ui import androidx.activity.ComponentActivity import androidx.compose.foundation.pager.PagerState +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.ui.test.assertWidthIsEqualTo import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule import androidx.compose.ui.test.onAllNodesWithContentDescription import androidx.compose.ui.test.onAllNodesWithTag @@ -16,6 +18,9 @@ import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.test.swipeDown import androidx.compose.ui.test.swipeLeft import androidx.compose.ui.test.swipeRight +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.unit.dp import androidx.paging.LoadState import androidx.paging.LoadStates import androidx.paging.PagingData @@ -342,6 +347,170 @@ class LibraryScreenTest { assertTrue("the tab row should reflow in one direction, but the icons moved $samples", monotonic) } + @Test + @Config(qualifiers = "de-rDE-w320dp-h640dp-xhdpi") + fun aLongTabKeepsTheGearFullSizeAndScrollsIntoView() { + var labelWidth = 0f + composeRule.setContent { + SubtracksTheme { + labelWidth = + rememberTextMeasurer() + .measure( + AnnotatedString(composeRule.activity.getString(LibraryTab.Playlists.label)), + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + softWrap = false, + ).size.width + .toFloat() + LibraryTabs( + pagerState = + remember { + PagerState( + currentPage = LibraryTab.Playlists.ordinal, + pageCount = { LibraryTab.entries.size }, + ) + }, + onTabSelected = {}, + onOpenSettings = {}, + artwork = null, + ) + } + } + composeRule.waitForIdle() + + composeRule + .onNodeWithContentDescription(composeRule.activity.getString(R.string.navigation_tabs_settings)) + .assertWidthIsEqualTo(40.dp) + assertTabFullyVisible(LibraryTab.Playlists, labelWidth) + } + + @Test + @Config(qualifiers = "ar-rEG-w320dp-h640dp-xhdpi") + fun aLongTabScrollsIntoViewInRtl() { + var labelWidth = 0f + composeRule.setContent { + SubtracksTheme { + labelWidth = + rememberTextMeasurer() + .measure( + AnnotatedString(composeRule.activity.getString(LibraryTab.Playlists.label)), + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + softWrap = false, + ).size.width + .toFloat() + LibraryTabs( + pagerState = + remember { + PagerState( + currentPage = LibraryTab.Playlists.ordinal, + pageCount = { LibraryTab.entries.size }, + ) + }, + onTabSelected = {}, + onOpenSettings = {}, + artwork = null, + ) + } + } + composeRule.waitForIdle() + + composeRule + .onNodeWithContentDescription(composeRule.activity.getString(R.string.navigation_tabs_settings)) + .assertWidthIsEqualTo(40.dp) + assertTabFullyVisible(LibraryTab.Playlists, labelWidth) + } + + @Test + @Config(qualifiers = "de-rDE-w320dp-h640dp-xhdpi") + fun movingToALongTabScrollsItIntoView() { + val selectedTab = mutableStateOf(LibraryTab.Home) + var labelWidth = 0f + composeRule.setContent { + SubtracksTheme { + labelWidth = + rememberTextMeasurer() + .measure( + AnnotatedString(composeRule.activity.getString(LibraryTab.Playlists.label)), + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + softWrap = false, + ).size.width + .toFloat() + LibraryScreen( + selectedTab = selectedTab.value, + onTabSelected = { selectedTab.value = it }, + albums = remember { flowOf(PagingData.empty()) }, + artists = remember { flowOf(PagingData.empty()) }, + playlists = remember { flowOf(PagingData.empty()) }, + coverArt = { _, _ -> null }, + onAlbumClick = {}, + onArtistClick = {}, + onPlaylistClick = {}, + onSync = {}, + onOpenSettings = {}, + ) + } + } + composeRule.waitForIdle() + + composeRule.runOnIdle { selectedTab.value = LibraryTab.Playlists } + val expected = with(composeRule.density) { TAB_LABEL_CHROME.toPx() } + labelWidth + composeRule.waitUntil(5_000) { tabBounds(LibraryTab.Playlists).width >= expected - 1f } + + composeRule + .onNodeWithContentDescription(composeRule.activity.getString(R.string.navigation_tabs_settings)) + .assertWidthIsEqualTo(40.dp) + assertTabFullyVisible(LibraryTab.Playlists, labelWidth) + } + + private fun assertTabFullyVisible( + tab: LibraryTab, + labelWidth: Float, + ) { + val bounds = tabBounds(tab) + val expected = with(composeRule.density) { TAB_LABEL_CHROME.toPx() } + labelWidth + assertEquals("the selected tab should be shown in full, but was $bounds", expected, bounds.width, 1f) + } + + @Test + @Config(qualifiers = "de-rDE-w320dp-h640dp-xhdpi") + fun theOfflineStripExplainsTheModeAndGoesOnline() { + var exited = false + composeRule.setContent { + SubtracksTheme { + LibraryScreen( + selectedTab = LibraryTab.Playlists, + onTabSelected = {}, + albums = remember { flowOf(PagingData.empty()) }, + artists = remember { flowOf(PagingData.empty()) }, + playlists = remember { flowOf(PagingData.empty()) }, + coverArt = { _, _ -> null }, + onAlbumClick = {}, + onArtistClick = {}, + onPlaylistClick = {}, + onSync = {}, + onOpenSettings = {}, + offline = true, + onExitOffline = { exited = true }, + ) + } + } + composeRule.waitForIdle() + + composeRule.onNodeWithText(composeRule.activity.getString(R.string.offline_mode_banner)).performClick() + assertTrue("the offline strip should leave offline mode", exited) + composeRule + .onNodeWithContentDescription(composeRule.activity.getString(R.string.navigation_tabs_settings)) + .assertWidthIsEqualTo(40.dp) + } + + private fun tabBounds(tab: LibraryTab) = + composeRule + .onNodeWithContentDescription(composeRule.activity.getString(tab.label)) + .fetchSemanticsNode() + .boundsInRoot + private val settledLoadStates = LoadStates( refresh = LoadState.NotLoading(endOfPaginationReached = true), @@ -378,3 +547,5 @@ class LibraryScreenTest { ) } } + +private val TAB_LABEL_CHROME = 56.dp