mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
library: tap-to-top and shuffle fab for long song lists
This commit is contained in:
@@ -261,6 +261,15 @@ class PlaybackController(
|
||||
artistId: String,
|
||||
) = shuffleContext(queueRepository.artistEntry(sourceId, artistId))
|
||||
|
||||
fun shuffleGenre(
|
||||
sourceId: Long,
|
||||
genre: String,
|
||||
) = shuffleContext(queueRepository.genreEntry(sourceId, genre))
|
||||
|
||||
fun shuffleStarred(sourceId: Long) = shuffleContext(queueRepository.starredEntry(sourceId))
|
||||
|
||||
fun shuffleDownloaded(sourceId: Long) = shuffleContext(queueRepository.downloadedEntry(sourceId))
|
||||
|
||||
private fun playContext(
|
||||
entry: QueueEntry,
|
||||
start: Long,
|
||||
|
||||
@@ -51,6 +51,7 @@ import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -77,6 +78,7 @@ import com.subtracks.ui.theme.ArtworkTheme
|
||||
import com.subtracks.ui.theme.HeroGradient
|
||||
import com.subtracks.ui.theme.baseArtworkColors
|
||||
import com.subtracks.ui.theme.heroBarColor
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private const val FADE_DISTANCE_DP = 64
|
||||
|
||||
@@ -96,12 +98,14 @@ fun HeroDetailScaffold(
|
||||
header: @Composable (controlsModifier: Modifier, topInset: Dp) -> Unit,
|
||||
content: LazyListScope.(rowModifier: Modifier) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onShuffle: (() -> Unit)? = null,
|
||||
) {
|
||||
val effectiveArtwork = artwork ?: baseArtworkColors()
|
||||
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) {
|
||||
ArtworkTheme(effectiveArtwork) {
|
||||
BoxWithConstraints(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
||||
val listState = rememberLazyListState()
|
||||
val scope = rememberCoroutineScope()
|
||||
val fill = rememberViewportFill(listState)
|
||||
val density = LocalDensity.current
|
||||
val screenHeightPx = with(density) { maxHeight.toPx() }
|
||||
@@ -147,7 +151,7 @@ fun HeroDetailScaffold(
|
||||
state = listState,
|
||||
contentPadding =
|
||||
PaddingValues(
|
||||
bottom = 16.dp + with(density) { navBarBottom.toDp() },
|
||||
bottom = (if (onShuffle != null) 88.dp else 16.dp) + with(density) { navBarBottom.toDp() },
|
||||
),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
@@ -195,8 +199,20 @@ fun HeroDetailScaffold(
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
) {},
|
||||
onClickLabel = stringResource(R.string.scroll_to_top),
|
||||
) { scope.launch { listState.animateScrollToItem(0) } },
|
||||
)
|
||||
|
||||
if (onShuffle != null) {
|
||||
ShuffleFab(
|
||||
onClick = onShuffle,
|
||||
visible = barFraction > 0f,
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(end = 16.dp, bottom = 16.dp + with(density) { navBarBottom.toDp() }),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.subtracks.ui.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Shuffle
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.subtracks.R
|
||||
|
||||
@Composable
|
||||
fun ShuffleFab(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
visible: Boolean = true,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = fadeIn() + scaleIn(),
|
||||
exit = fadeOut() + scaleOut(),
|
||||
modifier = modifier,
|
||||
) {
|
||||
FloatingActionButton(onClick = onClick) {
|
||||
Icon(Icons.Rounded.Shuffle, contentDescription = stringResource(R.string.controls_shuffle_play))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -88,3 +88,32 @@ fun rememberViewportFill(state: LazyGridState): Dp {
|
||||
}
|
||||
return with(density) { fillPx.toDp() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrolls to the top when [request] changes to a new non-null value, but not when it merely becomes
|
||||
* non-null. Selecting a page passes it the current request; only a fresh request (a tab re-tap)
|
||||
* should move the list, so a stale scroll position survives tab switches.
|
||||
*/
|
||||
@Composable
|
||||
fun ScrollToTopOnRequest(
|
||||
request: Any?,
|
||||
state: LazyListState,
|
||||
) {
|
||||
var previous by remember { mutableStateOf(request) }
|
||||
LaunchedEffect(request) {
|
||||
if (request != null && previous != null && request != previous) state.animateScrollToItem(0)
|
||||
previous = request
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ScrollToTopOnRequest(
|
||||
request: Any?,
|
||||
state: LazyGridState,
|
||||
) {
|
||||
var previous by remember { mutableStateOf(request) }
|
||||
LaunchedEffect(request) {
|
||||
if (request != null && previous != null && request != previous) state.animateScrollToItem(0)
|
||||
previous = request
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,9 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -31,6 +33,7 @@ import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -59,8 +62,10 @@ import com.subtracks.ui.components.CoverArt
|
||||
import com.subtracks.ui.components.ItemActions
|
||||
import com.subtracks.ui.components.LoadingState
|
||||
import com.subtracks.ui.components.MenuTarget
|
||||
import com.subtracks.ui.components.ShuffleFab
|
||||
import com.subtracks.ui.library.PlaylistsContent
|
||||
import com.subtracks.ui.library.SongRow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.compose.koinInject
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
@@ -110,6 +115,7 @@ fun HomeListRoute(
|
||||
onPlaylistClick = onPlaylistClick,
|
||||
onSongClick = viewModel::play,
|
||||
onLongClick = { contextMenuHost?.show(it, actions) },
|
||||
onShuffle = if (viewModel.canShuffle) viewModel::shuffle else null,
|
||||
onGenreClick = onGenreClick,
|
||||
onDecadeClick = onDecadeClick,
|
||||
)
|
||||
@@ -134,11 +140,18 @@ fun HomeListScreen(
|
||||
onPlaylistClick: (Playlist) -> Unit = {},
|
||||
onSongClick: (Song) -> Unit = {},
|
||||
onLongClick: (MenuTarget) -> Unit = {},
|
||||
onShuffle: (() -> Unit)? = null,
|
||||
onGenreClick: (String) -> Unit = {},
|
||||
onDecadeClick: (Long) -> Unit = {},
|
||||
) {
|
||||
val now = remember { System.currentTimeMillis() }
|
||||
val resources = LocalResources.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val songListState = rememberLazyListState()
|
||||
val isSongList =
|
||||
request.downloaded == OfflineListKind.Songs ||
|
||||
request.section == HomeSection.RecentlyStarredSongs ||
|
||||
request.genre != null
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
@@ -149,8 +162,21 @@ fun HomeListScreen(
|
||||
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = stringResource(R.string.navigation_back))
|
||||
}
|
||||
},
|
||||
modifier =
|
||||
if (isSongList) {
|
||||
Modifier.clickable(onClickLabel = stringResource(R.string.scroll_to_top)) {
|
||||
scope.launch { songListState.animateScrollToItem(0) }
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
if (onShuffle != null && songs.itemCount > 0) {
|
||||
ShuffleFab(onClick = onShuffle)
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
val downloaded = request.downloaded
|
||||
if (downloaded != null) {
|
||||
@@ -184,6 +210,7 @@ fun HomeListScreen(
|
||||
playingSongId = playingSongId,
|
||||
onSongClick = onSongClick,
|
||||
onLongClick = onLongClick,
|
||||
listState = songListState,
|
||||
modifier = Modifier.padding(padding),
|
||||
)
|
||||
}
|
||||
@@ -237,6 +264,7 @@ fun HomeListScreen(
|
||||
playingSongId = playingSongId,
|
||||
onSongClick = onSongClick,
|
||||
onLongClick = onLongClick,
|
||||
listState = songListState,
|
||||
modifier = Modifier.padding(padding),
|
||||
)
|
||||
}
|
||||
@@ -254,6 +282,7 @@ fun HomeListScreen(
|
||||
playingSongId = playingSongId,
|
||||
onSongClick = onSongClick,
|
||||
onLongClick = onLongClick,
|
||||
listState = songListState,
|
||||
modifier = Modifier.padding(padding),
|
||||
)
|
||||
} else {
|
||||
@@ -297,6 +326,7 @@ private fun SongsList(
|
||||
playingSongId: String?,
|
||||
onSongClick: (Song) -> Unit,
|
||||
onLongClick: (MenuTarget) -> Unit,
|
||||
listState: LazyListState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (songs.itemCount == 0 && songs.loadState.refresh is LoadState.Loading) {
|
||||
@@ -304,8 +334,9 @@ private fun SongsList(
|
||||
return
|
||||
}
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = 24.dp),
|
||||
contentPadding = PaddingValues(bottom = 88.dp),
|
||||
) {
|
||||
items(count = songs.itemCount, key = songs.itemKey { it.song.id }) { index ->
|
||||
val item = songs[index]
|
||||
|
||||
@@ -21,9 +21,11 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class HomeListRequest(
|
||||
val title: String,
|
||||
@@ -51,6 +53,11 @@ class HomeListViewModel(
|
||||
) : ViewModel() {
|
||||
private val sourceId = libraryRepository.activeSourceId.filterNotNull()
|
||||
|
||||
val canShuffle: Boolean =
|
||||
request.genre != null ||
|
||||
request.section == HomeSection.RecentlyStarredSongs ||
|
||||
request.downloaded == OfflineListKind.Songs
|
||||
|
||||
val albums: Flow<PagingData<Album>> =
|
||||
sourceId
|
||||
.flatMapLatest { id ->
|
||||
@@ -173,4 +180,18 @@ class HomeListViewModel(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun shuffle() {
|
||||
val genre = request.genre
|
||||
val downloaded = request.downloaded
|
||||
val starred = request.section == HomeSection.RecentlyStarredSongs
|
||||
viewModelScope.launch {
|
||||
val id = sourceId.first()
|
||||
when {
|
||||
genre != null -> playbackController.shuffleGenre(id, genre)
|
||||
downloaded == OfflineListKind.Songs -> playbackController.shuffleDownloaded(id)
|
||||
starred -> playbackController.shuffleStarred(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,6 +248,7 @@ fun AlbumDetailScreen(
|
||||
}
|
||||
},
|
||||
modifier = modifier,
|
||||
onShuffle = onShuffle.takeIf { songs.isNotEmpty() },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ import com.subtracks.ui.components.ListDownloadIndicator
|
||||
import com.subtracks.ui.components.LoadingState
|
||||
import com.subtracks.ui.components.MenuTarget
|
||||
import com.subtracks.ui.components.ResetScrollOnChange
|
||||
import com.subtracks.ui.components.ScrollToTopOnRequest
|
||||
import com.subtracks.ui.components.rememberViewportFill
|
||||
import com.subtracks.ui.theme.PrefetchArtworkSeeds
|
||||
|
||||
@@ -56,6 +57,7 @@ fun AlbumsContent(
|
||||
topInset: Dp = 0.dp,
|
||||
onSync: () -> Unit = {},
|
||||
downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(),
|
||||
scrollToTopKey: Any? = null,
|
||||
) {
|
||||
when {
|
||||
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> {
|
||||
@@ -78,6 +80,7 @@ fun AlbumsContent(
|
||||
else -> {
|
||||
val gridState = rememberLazyGridState()
|
||||
ResetScrollOnChange(resetKey, { items.loadState.refresh }) { gridState.scrollToItem(0) }
|
||||
ScrollToTopOnRequest(scrollToTopKey, gridState)
|
||||
val fill = rememberViewportFill(gridState)
|
||||
LazyVerticalGrid(
|
||||
state = gridState,
|
||||
|
||||
@@ -36,6 +36,7 @@ import com.subtracks.ui.components.ListDownloadIndicator
|
||||
import com.subtracks.ui.components.LoadingState
|
||||
import com.subtracks.ui.components.MenuTarget
|
||||
import com.subtracks.ui.components.ResetScrollOnChange
|
||||
import com.subtracks.ui.components.ScrollToTopOnRequest
|
||||
import com.subtracks.ui.components.rememberViewportFill
|
||||
import com.subtracks.ui.theme.PrefetchArtworkSeeds
|
||||
|
||||
@@ -53,6 +54,7 @@ fun ArtistsContent(
|
||||
topInset: Dp = 0.dp,
|
||||
onSync: () -> Unit = {},
|
||||
downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(),
|
||||
scrollToTopKey: Any? = null,
|
||||
) {
|
||||
when {
|
||||
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> {
|
||||
@@ -70,6 +72,7 @@ fun ArtistsContent(
|
||||
else -> {
|
||||
val listState = rememberLazyListState()
|
||||
ResetScrollOnChange(resetKey, { items.loadState.refresh }) { listState.scrollToItem(0) }
|
||||
ScrollToTopOnRequest(scrollToTopKey, listState)
|
||||
val fill = rememberViewportFill(listState)
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
|
||||
@@ -418,6 +418,7 @@ fun LibraryScreen(
|
||||
)
|
||||
var showOptions by rememberSaveable { mutableStateOf(false) }
|
||||
var searchActive by rememberSaveable { mutableStateOf(false) }
|
||||
var topRequest by remember { mutableIntStateOf(0) }
|
||||
val searchFocus = remember { FocusRequester() }
|
||||
|
||||
// Search mode is left open so the typed query survives; MainNavigation dismisses the keyboard.
|
||||
@@ -470,6 +471,7 @@ fun LibraryScreen(
|
||||
) { page ->
|
||||
val pageTab = LibraryTab.entries[page]
|
||||
val resetKey = resetKeys[pageTab]
|
||||
val scrollToTopKey = if (pageTab == selectedTab) topRequest else null
|
||||
val pullToRefreshState = rememberPullToRefreshState()
|
||||
PullToRefreshBox(
|
||||
isRefreshing = syncing,
|
||||
@@ -507,6 +509,7 @@ fun LibraryScreen(
|
||||
topInset = listTopInset,
|
||||
onSync = onSync,
|
||||
downloadStatuses = albumDownloads,
|
||||
scrollToTopKey = scrollToTopKey,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -526,6 +529,7 @@ fun LibraryScreen(
|
||||
topInset = listTopInset,
|
||||
onSync = onSync,
|
||||
downloadStatuses = artistDownloads,
|
||||
scrollToTopKey = scrollToTopKey,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -545,6 +549,7 @@ fun LibraryScreen(
|
||||
topInset = listTopInset,
|
||||
onSync = onSync,
|
||||
downloadStatuses = playlistDownloads,
|
||||
scrollToTopKey = scrollToTopKey,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -570,7 +575,9 @@ fun LibraryScreen(
|
||||
) {
|
||||
LibraryTabs(
|
||||
pagerState = pagerState,
|
||||
onTabSelected = onTabSelected,
|
||||
onTabSelected = { tab ->
|
||||
if (tab == selectedTab) topRequest++ else onTabSelected(tab)
|
||||
},
|
||||
onOpenSettings = onOpenSettings,
|
||||
offline = offline,
|
||||
onExitOffline = onExitOffline,
|
||||
|
||||
@@ -178,6 +178,7 @@ fun PlaylistDetailScreen(
|
||||
}
|
||||
},
|
||||
modifier = modifier,
|
||||
onShuffle = onShuffle.takeIf { songs.itemCount > 0 },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ import com.subtracks.ui.components.ListDownloadIndicator
|
||||
import com.subtracks.ui.components.LoadingState
|
||||
import com.subtracks.ui.components.MenuTarget
|
||||
import com.subtracks.ui.components.ResetScrollOnChange
|
||||
import com.subtracks.ui.components.ScrollToTopOnRequest
|
||||
import com.subtracks.ui.components.rememberViewportFill
|
||||
|
||||
@Composable
|
||||
@@ -52,6 +53,7 @@ fun PlaylistsContent(
|
||||
topInset: Dp = 0.dp,
|
||||
onSync: () -> Unit = {},
|
||||
downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(),
|
||||
scrollToTopKey: Any? = null,
|
||||
) {
|
||||
when {
|
||||
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> {
|
||||
@@ -74,6 +76,7 @@ fun PlaylistsContent(
|
||||
else -> {
|
||||
val listState = rememberLazyListState()
|
||||
ResetScrollOnChange(resetKey, { items.loadState.refresh }) { listState.scrollToItem(0) }
|
||||
ScrollToTopOnRequest(scrollToTopKey, listState)
|
||||
val fill = rememberViewportFill(listState)
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
<string name="controls_more">More</string>
|
||||
|
||||
<string name="navigation_back">Back</string>
|
||||
<string name="scroll_to_top">Scroll to top</string>
|
||||
<string name="navigation_tabs_home">Home</string>
|
||||
<string name="navigation_tabs_library">Library</string>
|
||||
<string name="navigation_tabs_settings">Settings</string>
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.content.Context
|
||||
import android.content.res.Resources
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.room3.Room
|
||||
import androidx.room3.useWriterConnection
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
@@ -2391,6 +2392,27 @@ class PlaybackControllerTest {
|
||||
assertEquals(setOf("a1", "a2", "a3", "b1", "b2"), handle.items.map { it.id }.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shuffleGenrePlaysOnlyThatGenresSongs() {
|
||||
seedGenre()
|
||||
|
||||
controller.shuffleGenre(1, "Rock")
|
||||
await { controller.state.value.item != null && controller.state.value.shuffle }
|
||||
|
||||
assertTrue(controller.state.value.shuffle)
|
||||
assertEquals(
|
||||
QueueKind.Genre,
|
||||
controller.state.value.context
|
||||
?.kind,
|
||||
)
|
||||
assertEquals(
|
||||
"Rock",
|
||||
controller.state.value.context
|
||||
?.refId,
|
||||
)
|
||||
assertEquals(setOf("r1", "r2"), handle.items.map { it.id }.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun artistQueueRowsFollowTheFlatShuffleIdOrder() {
|
||||
seedArtist()
|
||||
@@ -3100,6 +3122,26 @@ class PlaybackControllerTest {
|
||||
}
|
||||
}
|
||||
|
||||
private fun seedGenre() {
|
||||
runBlocking {
|
||||
db.sourcesDao().upsertSource(Source(1, "source 1", "http://localhost:1", true, 1))
|
||||
db.libraryDao().upsertSongs(
|
||||
listOf(
|
||||
Song(1, "r1", "al1", "ar1", "Rock 1", "Album", "Artist", 100, 1, 1, null, null),
|
||||
Song(1, "r2", "al1", "ar1", "Rock 2", "Album", "Artist", 100, 2, 1, null, null),
|
||||
Song(1, "j1", "al1", "ar1", "Jazz 1", "Album", "Artist", 100, 3, 1, null, null),
|
||||
),
|
||||
)
|
||||
db.useWriterConnection { connection ->
|
||||
listOf("r1" to "Rock", "r2" to "Rock", "j1" to "Jazz").forEach { (songId, genre) ->
|
||||
connection.usePrepared(
|
||||
"INSERT INTO song_genres (sourceId, songId, position, genre) VALUES (1, '$songId', 0, '$genre')",
|
||||
) { it.step() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun seedAlbumTracks(
|
||||
albumId: String,
|
||||
count: Int,
|
||||
|
||||
@@ -9,6 +9,7 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
@@ -55,4 +56,33 @@ class ViewportFillTest {
|
||||
|
||||
assertEquals(initial, fillHeight)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun scrollToTopOnRequestMovesOnlyOnANewRequest() {
|
||||
lateinit var state: LazyListState
|
||||
val request = mutableStateOf<Any?>(null)
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
state = rememberLazyListState()
|
||||
ScrollToTopOnRequest(request.value, state)
|
||||
LazyColumn(state = state, modifier = Modifier.fillMaxSize()) {
|
||||
items(50) { Text("item $it", modifier = Modifier.height(56.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.runOnIdle { runBlocking { state.scrollToItem(10) } }
|
||||
composeRule.waitForIdle()
|
||||
assertEquals(10, state.firstVisibleItemIndex)
|
||||
|
||||
composeRule.runOnIdle { request.value = 0 }
|
||||
composeRule.waitForIdle()
|
||||
assertEquals("selecting a page must keep its scroll position", 10, state.firstVisibleItemIndex)
|
||||
|
||||
composeRule.runOnIdle { request.value = 1 }
|
||||
composeRule.mainClock.advanceTimeBy(2_000)
|
||||
composeRule.waitForIdle()
|
||||
assertEquals("a new request should scroll back to the top", 0, state.firstVisibleItemIndex)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user