mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 23:21:59 +01:00
address the code review findings
- offer a Sync button on a truly empty library tab so an empty library is not stranded (pull-to-refresh needs scrollable content) - do not close the search field when it auto-syncs from a tab with an empty term - make rememberArtworkColors nullable so the no-track fallbacks are live and the idle theme is the monochrome background - measure the tab overlay content offset so the colour clip accounts for the touch-target centering - put the pull indicator below the status-bar scrim, and seed the tab bar height estimate to avoid a first-frame inset jump - collapse the two equal clearance constants, drop the redundant search imePadding, reuse the hoisted artwork for the mini player, and de-duplicate the mini player screenshots
This commit is contained in:
@@ -289,7 +289,7 @@ private fun MainNavigation() {
|
||||
MiniPlayer(
|
||||
state = playback,
|
||||
coverArt = miniArt,
|
||||
artwork = rememberArtworkColors(miniArt),
|
||||
artwork = artwork,
|
||||
onExpand = { settleNowPlaying(true) },
|
||||
onPlayPause = playbackController::togglePlayPause,
|
||||
onNext = playbackController::next,
|
||||
|
||||
@@ -172,17 +172,29 @@ fun LoadingState(modifier: Modifier = Modifier) {
|
||||
fun EmptyState(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
actionLabel: String? = null,
|
||||
onAction: (() -> Unit)? = null,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier.fillMaxSize().padding(32.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
if (actionLabel != null && onAction != null) {
|
||||
Button(onClick = onAction) {
|
||||
Text(actionLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ fun AlbumsContent(
|
||||
onClearFilters: () -> Unit = {},
|
||||
resetKey: Any? = null,
|
||||
topInset: Dp = 0.dp,
|
||||
onSync: () -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
when {
|
||||
@@ -55,7 +56,12 @@ fun AlbumsContent(
|
||||
if (filtered) {
|
||||
FilteredEmptyState(onClearFilters, modifier)
|
||||
} else {
|
||||
EmptyState("No albums yet.\nSync with your server to fill your library.", modifier)
|
||||
EmptyState(
|
||||
text = "No albums yet.\nSync with your server to fill your library.",
|
||||
modifier = modifier,
|
||||
actionLabel = "Sync",
|
||||
onAction = onSync,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ fun ArtistsContent(
|
||||
onClearFilters: () -> Unit = {},
|
||||
resetKey: Any? = null,
|
||||
topInset: Dp = 0.dp,
|
||||
onSync: () -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
when {
|
||||
@@ -53,7 +54,7 @@ fun ArtistsContent(
|
||||
if (filtered) {
|
||||
FilteredEmptyState(onClearFilters, modifier)
|
||||
} else {
|
||||
EmptyState("No artists yet.", modifier)
|
||||
EmptyState("No artists yet.", modifier, actionLabel = "Sync", onAction = onSync)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,11 +13,11 @@ 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.imePadding
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
@@ -90,6 +90,7 @@ import androidx.compose.ui.layout.boundsInParent
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
@@ -302,16 +303,16 @@ fun LibraryScreen(
|
||||
if (pagerState.currentPage != selectedTab.ordinal) {
|
||||
pagerState.animateScrollToPage(selectedTab.ordinal)
|
||||
}
|
||||
searchActive = search.isNotEmpty()
|
||||
if (search.isNotEmpty()) searchActive = true
|
||||
}
|
||||
|
||||
val density = LocalDensity.current
|
||||
val statusBarTop = with(density) { WindowInsets.statusBars.getTop(this).toDp() }
|
||||
val filtersActive = listQuery.starred != StarredFilter.Any || search.isNotEmpty()
|
||||
var tabBarHeightPx by remember { mutableFloatStateOf(0f) }
|
||||
var tabBarHeightPx by remember { mutableFloatStateOf(with(density) { (TAB_BAR_CONTENT_HEIGHT + bottomInset).toPx() }) }
|
||||
val tabBarHeight = with(density) { tabBarHeightPx.toDp() }
|
||||
val listTopInset = statusBarTop
|
||||
val listBottomInset = tabBarHeight + if (searchActive) SEARCH_BAR_CLEARANCE else FAB_CLEARANCE
|
||||
val listBottomInset = tabBarHeight + BOTTOM_CLEARANCE
|
||||
val headerColor = artwork?.let(::playerSurfaceColor) ?: MaterialTheme.colorScheme.background
|
||||
|
||||
Box(
|
||||
@@ -335,7 +336,7 @@ fun LibraryScreen(
|
||||
PullToRefreshDefaults.Indicator(
|
||||
state = pullToRefreshState,
|
||||
isRefreshing = syncing,
|
||||
modifier = Modifier.align(Alignment.TopCenter),
|
||||
modifier = Modifier.align(Alignment.TopCenter).statusBarsPadding(),
|
||||
containerColor = headerColor,
|
||||
color = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
@@ -353,6 +354,7 @@ fun LibraryScreen(
|
||||
onClearFilters = onClearFilters,
|
||||
resetKey = resetKey,
|
||||
topInset = listTopInset,
|
||||
onSync = onSync,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -366,6 +368,7 @@ fun LibraryScreen(
|
||||
onClearFilters = onClearFilters,
|
||||
resetKey = resetKey,
|
||||
topInset = listTopInset,
|
||||
onSync = onSync,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -380,6 +383,7 @@ fun LibraryScreen(
|
||||
onClearFilters = onClearFilters,
|
||||
resetKey = resetKey,
|
||||
topInset = listTopInset,
|
||||
onSync = onSync,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -393,6 +397,7 @@ fun LibraryScreen(
|
||||
onClearFilters = onClearFilters,
|
||||
resetKey = resetKey,
|
||||
topInset = listTopInset,
|
||||
onSync = onSync,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -444,7 +449,6 @@ fun LibraryScreen(
|
||||
Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.imePadding()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
@@ -618,8 +622,8 @@ private fun ListOptionsSheet(
|
||||
}
|
||||
}
|
||||
|
||||
private val FAB_CLEARANCE = 80.dp
|
||||
private val SEARCH_BAR_CLEARANCE = 80.dp
|
||||
private val BOTTOM_CLEARANCE = 80.dp
|
||||
private val TAB_BAR_CONTENT_HEIGHT = 52.dp
|
||||
private val TAB_ICON_SIZE = 24.dp
|
||||
private val TAB_VERTICAL_PADDING = 6.dp
|
||||
|
||||
@@ -768,47 +772,53 @@ private fun TabButton(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val selectedContent = artwork?.scheme?.onPrimary ?: MaterialTheme.colorScheme.background
|
||||
val paddingStart = with(LocalDensity.current) { 8.dp.toPx() }
|
||||
var tabLeft by remember { mutableFloatStateOf(0f) }
|
||||
val clipStart = indicatorLeft?.minus(tabLeft + paddingStart)
|
||||
val clipEnd = indicatorRight?.minus(tabLeft + paddingStart)
|
||||
var tabRootX by remember { mutableFloatStateOf(0f) }
|
||||
var contentRootX by remember { mutableFloatStateOf(0f) }
|
||||
val contentLeft = tabLeft + (contentRootX - tabRootX)
|
||||
val clipStart = indicatorLeft?.minus(contentLeft)
|
||||
val clipEnd = indicatorRight?.minus(contentLeft)
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.onGloballyPositioned { tabLeft = it.boundsInParent().left }
|
||||
.clickable(onClick = onClick)
|
||||
.onGloballyPositioned {
|
||||
tabLeft = it.boundsInParent().left
|
||||
tabRootX = it.positionInRoot().x
|
||||
}.clickable(onClick = onClick)
|
||||
.minimumInteractiveComponentSize()
|
||||
.padding(start = 8.dp, end = 12.dp, top = TAB_VERTICAL_PADDING, bottom = TAB_VERTICAL_PADDING),
|
||||
) {
|
||||
if (clipStart != null && clipEnd != null) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.drawWithContent {
|
||||
val hole =
|
||||
Path().apply {
|
||||
addRect(Rect(clipStart, 0f, clipEnd, size.height))
|
||||
}
|
||||
clipPath(hole, clipOp = ClipOp.Difference) {
|
||||
this@drawWithContent.drawContent()
|
||||
}
|
||||
},
|
||||
) {
|
||||
TabContent(tab, progress, MaterialTheme.colorScheme.onBackground)
|
||||
}
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.clearAndSetSemantics {}
|
||||
.drawWithContent {
|
||||
clipRect(clipStart, 0f, clipEnd, size.height) {
|
||||
Box(Modifier.onGloballyPositioned { contentRootX = it.positionInRoot().x }) {
|
||||
if (clipStart != null && clipEnd != null) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.drawWithContent {
|
||||
val hole =
|
||||
Path().apply {
|
||||
addRect(Rect(clipStart, 0f, clipEnd, size.height))
|
||||
}
|
||||
clipPath(hole, clipOp = ClipOp.Difference) {
|
||||
this@drawWithContent.drawContent()
|
||||
}
|
||||
},
|
||||
) {
|
||||
TabContent(tab, progress, selectedContent)
|
||||
) {
|
||||
TabContent(tab, progress, MaterialTheme.colorScheme.onBackground)
|
||||
}
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.clearAndSetSemantics {}
|
||||
.drawWithContent {
|
||||
clipRect(clipStart, 0f, clipEnd, size.height) {
|
||||
this@drawWithContent.drawContent()
|
||||
}
|
||||
},
|
||||
) {
|
||||
TabContent(tab, progress, selectedContent)
|
||||
}
|
||||
} else {
|
||||
TabContent(tab, progress, MaterialTheme.colorScheme.onBackground)
|
||||
}
|
||||
} else {
|
||||
TabContent(tab, progress, MaterialTheme.colorScheme.onBackground)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ fun PlaylistsContent(
|
||||
onClearFilters: () -> Unit = {},
|
||||
resetKey: Any? = null,
|
||||
topInset: Dp = 0.dp,
|
||||
onSync: () -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
when {
|
||||
@@ -52,7 +53,7 @@ fun PlaylistsContent(
|
||||
if (filtered) {
|
||||
FilteredEmptyState(onClearFilters, modifier)
|
||||
} else {
|
||||
EmptyState("No playlists yet.", modifier)
|
||||
EmptyState("No playlists yet.", modifier, actionLabel = "Sync", onAction = onSync)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ fun SongsContent(
|
||||
onClearFilters: () -> Unit = {},
|
||||
resetKey: Any? = null,
|
||||
topInset: Dp = 0.dp,
|
||||
onSync: () -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
when {
|
||||
@@ -64,7 +65,7 @@ fun SongsContent(
|
||||
if (filtered) {
|
||||
FilteredEmptyState(onClearFilters, modifier)
|
||||
} else {
|
||||
EmptyState("No songs yet.", modifier)
|
||||
EmptyState("No songs yet.", modifier, actionLabel = "Sync", onAction = onSync)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -182,12 +182,13 @@ private fun blendHue(
|
||||
fun rememberArtworkColors(
|
||||
ref: CoverArtRef?,
|
||||
durationMillis: Int = ARTWORK_THEME_TRANSITION_MS,
|
||||
): ArtworkColors {
|
||||
): ArtworkColors? {
|
||||
val cached = remember(ref?.cacheKey) { ref?.cacheKey?.let(ArtworkSeedCache::cached) }
|
||||
val seeds by rememberArtworkSeed(ref)
|
||||
val effective = seeds ?: cached
|
||||
val target = remember(effective) { effective?.let { (primary, secondary) -> artworkColorsFromSeeds(primary, secondary) } }
|
||||
return rememberAnimatedArtworkColors(target, durationMillis)
|
||||
val colors = rememberAnimatedArtworkColors(target, durationMillis)
|
||||
return if (target == null) null else colors
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -147,49 +147,18 @@ class AppScreenshotTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun libraryWithMiniPlayer() {
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
val artwork = artworkColorsFromSeed(Color.rgb(120, 80, 200))
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
LibraryScreen(
|
||||
selectedTab = LibraryTab.Albums,
|
||||
onTabSelected = {},
|
||||
albums = remember { flowOf(PagingData.from(Fixtures.albums)) }.collectAsLazyPagingItems(),
|
||||
artists = remember { flowOf(PagingData.from(Fixtures.artists)) }.collectAsLazyPagingItems(),
|
||||
songs = remember { flowOf(PagingData.from(Fixtures.songItems)) }.collectAsLazyPagingItems(),
|
||||
playlists = remember { flowOf(PagingData.from(Fixtures.playlists)) }.collectAsLazyPagingItems(),
|
||||
coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } },
|
||||
onAlbumClick = {},
|
||||
onArtistClick = {},
|
||||
onPlaylistClick = {},
|
||||
onSongClick = {},
|
||||
onSync = {},
|
||||
onOpenSettings = {},
|
||||
artwork = artwork,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
MiniPlayer(
|
||||
state = Fixtures.playbackState(),
|
||||
coverArt = CoverArtRef("art-al-kid-a", "test:art-al-kid-a"),
|
||||
artwork = artwork,
|
||||
onExpand = {},
|
||||
onPlayPause = {},
|
||||
onNext = {},
|
||||
progressInset = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
awaitTag(ALBUM_COVER_TAG)
|
||||
composeRule.onRoot().captureRoboImage("src/test/screenshots/library_with_mini_player.png")
|
||||
}
|
||||
fun libraryWithMiniPlayer() = libraryWithMiniPlayerShot(Color.rgb(120, 80, 200), "library_with_mini_player.png")
|
||||
|
||||
@Test
|
||||
fun libraryWithMiniPlayerWarm() {
|
||||
fun libraryWithMiniPlayerWarm() = libraryWithMiniPlayerShot(Color.rgb(222, 150, 30), "library_with_mini_player_warm.png")
|
||||
|
||||
private fun libraryWithMiniPlayerShot(
|
||||
seed: Int,
|
||||
file: String,
|
||||
) {
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
val artwork = artworkColorsFromSeed(Color.rgb(222, 150, 30))
|
||||
val artwork = artworkColorsFromSeed(seed)
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
LibraryScreen(
|
||||
selectedTab = LibraryTab.Albums,
|
||||
@@ -221,7 +190,7 @@ class AppScreenshotTest {
|
||||
}
|
||||
}
|
||||
awaitTag(ALBUM_COVER_TAG)
|
||||
composeRule.onRoot().captureRoboImage("src/test/screenshots/library_with_mini_player_warm.png")
|
||||
composeRule.onRoot().captureRoboImage("src/test/screenshots/$file")
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -4,11 +4,15 @@ import androidx.activity.ComponentActivity
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performTouchInput
|
||||
import androidx.compose.ui.test.swipeDown
|
||||
import androidx.paging.LoadState
|
||||
import androidx.paging.LoadStates
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
@@ -72,6 +76,44 @@ class LibraryScreenTest {
|
||||
assertTrue("the settings control should open settings", settingsOpened)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anEmptyTabOffersSync() {
|
||||
var synced = false
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
LibraryScreen(
|
||||
selectedTab = LibraryTab.Albums,
|
||||
onTabSelected = {},
|
||||
albums = remember { flowOf(PagingData.empty<Album>(settledLoadStates)) }.collectAsLazyPagingItems(),
|
||||
artists = remember { flowOf(PagingData.empty<Artist>()) }.collectAsLazyPagingItems(),
|
||||
songs = remember { flowOf(PagingData.empty<SongListItem>()) }.collectAsLazyPagingItems(),
|
||||
playlists = remember { flowOf(PagingData.empty<Playlist>()) }.collectAsLazyPagingItems(),
|
||||
coverArt = { _, _ -> null },
|
||||
onAlbumClick = {},
|
||||
onArtistClick = {},
|
||||
onPlaylistClick = {},
|
||||
onSongClick = {},
|
||||
onSync = { synced = true },
|
||||
onOpenSettings = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.waitUntil(timeoutMillis = 5_000) {
|
||||
composeRule.onAllNodesWithText("Sync").fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
composeRule.onNodeWithText("Sync").performClick()
|
||||
|
||||
assertTrue("an empty library should offer a sync", synced)
|
||||
}
|
||||
|
||||
private val settledLoadStates =
|
||||
LoadStates(
|
||||
refresh = LoadState.NotLoading(endOfPaginationReached = true),
|
||||
prepend = LoadState.NotLoading(endOfPaginationReached = true),
|
||||
append = LoadState.NotLoading(endOfPaginationReached = true),
|
||||
)
|
||||
|
||||
private fun albums() =
|
||||
(1..60).map { index ->
|
||||
Album(
|
||||
|
||||
Reference in New Issue
Block a user