From 63f05491814e779c1d4a8de4d529d2a99b0ad9e9 Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:54:18 +0000 Subject: [PATCH] tristate starred filter, filter-aware empty state, keyboard-safe search bar --- .../com/subtracks/data/db/LibraryDao.kt | 106 +++++++------- .../subtracks/data/prefs/UserPreferences.kt | 8 +- .../subtracks/data/repo/LibraryRepository.kt | 53 +++---- .../com/subtracks/ui/components/Components.kt | 29 ++++ .../com/subtracks/ui/library/AlbumsContent.kt | 9 +- .../subtracks/ui/library/ArtistsContent.kt | 9 +- .../com/subtracks/ui/library/LibraryScreen.kt | 129 ++++++++++++++---- .../subtracks/ui/library/LibraryViewModel.kt | 6 +- .../subtracks/ui/library/PlaylistsContent.kt | 9 +- .../com/subtracks/ui/library/SongsContent.kt | 9 +- .../com/subtracks/data/db/LibraryDaoTest.kt | 21 +-- .../data/prefs/UserPreferencesTest.kt | 7 +- .../subtracks/data/sync/SyncServiceTest.kt | 12 +- .../com/subtracks/ui/LibraryOptionsTest.kt | 121 ++++++++++++++++ 14 files changed, 401 insertions(+), 127 deletions(-) create mode 100644 app/src/test/kotlin/com/subtracks/ui/LibraryOptionsTest.kt diff --git a/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt b/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt index 7aba6c3c..96f2edad 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt @@ -91,193 +91,193 @@ interface LibraryDao { @Query( "SELECT * FROM albums WHERE sourceId = :sourceId " + - "AND (:starredOnly = 0 OR starred IS NOT NULL) " + + "AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " + "AND (:search = '' OR name LIKE '%' || :search || '%' OR albumArtist LIKE '%' || :search || '%') " + "ORDER BY name COLLATE NOCASE", ) fun albumsByName( sourceId: Long, - starredOnly: Boolean, + starredFilter: Int, search: String, ): PagingSource @Query( "SELECT * FROM albums WHERE sourceId = :sourceId " + - "AND (:starredOnly = 0 OR starred IS NOT NULL) " + + "AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " + "AND (:search = '' OR name LIKE '%' || :search || '%' OR albumArtist LIKE '%' || :search || '%') " + "ORDER BY name COLLATE NOCASE DESC", ) fun albumsByNameReversed( sourceId: Long, - starredOnly: Boolean, + starredFilter: Int, search: String, ): PagingSource @Query( "SELECT * FROM albums WHERE sourceId = :sourceId " + - "AND (:starredOnly = 0 OR starred IS NOT NULL) " + + "AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " + "AND (:search = '' OR name LIKE '%' || :search || '%' OR albumArtist LIKE '%' || :search || '%') " + "ORDER BY albumArtist COLLATE NOCASE, year, name COLLATE NOCASE", ) fun albumsByArtist( sourceId: Long, - starredOnly: Boolean, + starredFilter: Int, search: String, ): PagingSource @Query( "SELECT * FROM albums WHERE sourceId = :sourceId " + - "AND (:starredOnly = 0 OR starred IS NOT NULL) " + + "AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " + "AND (:search = '' OR name LIKE '%' || :search || '%' OR albumArtist LIKE '%' || :search || '%') " + "ORDER BY albumArtist COLLATE NOCASE DESC, year DESC, name COLLATE NOCASE DESC", ) fun albumsByArtistReversed( sourceId: Long, - starredOnly: Boolean, + starredFilter: Int, search: String, ): PagingSource @Query( "SELECT * FROM albums WHERE sourceId = :sourceId " + - "AND (:starredOnly = 0 OR starred IS NOT NULL) " + + "AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " + "AND (:search = '' OR name LIKE '%' || :search || '%' OR albumArtist LIKE '%' || :search || '%') " + "ORDER BY year DESC, name COLLATE NOCASE", ) fun albumsByYear( sourceId: Long, - starredOnly: Boolean, + starredFilter: Int, search: String, ): PagingSource @Query( "SELECT * FROM albums WHERE sourceId = :sourceId " + - "AND (:starredOnly = 0 OR starred IS NOT NULL) " + + "AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " + "AND (:search = '' OR name LIKE '%' || :search || '%' OR albumArtist LIKE '%' || :search || '%') " + "ORDER BY year ASC, name COLLATE NOCASE", ) fun albumsByYearReversed( sourceId: Long, - starredOnly: Boolean, + starredFilter: Int, search: String, ): PagingSource @Query( "SELECT * FROM albums WHERE sourceId = :sourceId " + - "AND (:starredOnly = 0 OR starred IS NOT NULL) " + + "AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " + "AND (:search = '' OR name LIKE '%' || :search || '%' OR albumArtist LIKE '%' || :search || '%') " + "ORDER BY created DESC, name COLLATE NOCASE", ) fun albumsByRecentlyAdded( sourceId: Long, - starredOnly: Boolean, + starredFilter: Int, search: String, ): PagingSource @Query( "SELECT * FROM albums WHERE sourceId = :sourceId " + - "AND (:starredOnly = 0 OR starred IS NOT NULL) " + + "AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " + "AND (:search = '' OR name LIKE '%' || :search || '%' OR albumArtist LIKE '%' || :search || '%') " + "ORDER BY created ASC, name COLLATE NOCASE", ) fun albumsByRecentlyAddedReversed( sourceId: Long, - starredOnly: Boolean, + starredFilter: Int, search: String, ): PagingSource @Query( "SELECT * FROM albums WHERE sourceId = :sourceId " + - "AND (:starredOnly = 0 OR starred IS NOT NULL) " + + "AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " + "AND (:search = '' OR name LIKE '%' || :search || '%' OR albumArtist LIKE '%' || :search || '%') " + "ORDER BY starred IS NULL, starred DESC, name COLLATE NOCASE", ) fun albumsByStarred( sourceId: Long, - starredOnly: Boolean, + starredFilter: Int, search: String, ): PagingSource @Query( "SELECT * FROM albums WHERE sourceId = :sourceId " + - "AND (:starredOnly = 0 OR starred IS NOT NULL) " + + "AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " + "AND (:search = '' OR name LIKE '%' || :search || '%' OR albumArtist LIKE '%' || :search || '%') " + "ORDER BY starred IS NULL, starred ASC, name COLLATE NOCASE", ) fun albumsByStarredReversed( sourceId: Long, - starredOnly: Boolean, + starredFilter: Int, search: String, ): PagingSource @Query( "SELECT * FROM artists WHERE sourceId = :sourceId " + - "AND (:starredOnly = 0 OR starred IS NOT NULL) " + + "AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " + "AND (:search = '' OR name LIKE '%' || :search || '%') " + "ORDER BY name COLLATE NOCASE", ) fun artistsByName( sourceId: Long, - starredOnly: Boolean, + starredFilter: Int, search: String, ): PagingSource @Query( "SELECT * FROM artists WHERE sourceId = :sourceId " + - "AND (:starredOnly = 0 OR starred IS NOT NULL) " + + "AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " + "AND (:search = '' OR name LIKE '%' || :search || '%') " + "ORDER BY name COLLATE NOCASE DESC", ) fun artistsByNameReversed( sourceId: Long, - starredOnly: Boolean, + starredFilter: Int, search: String, ): PagingSource @Query( "SELECT * FROM artists WHERE sourceId = :sourceId " + - "AND (:starredOnly = 0 OR starred IS NOT NULL) " + + "AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " + "AND (:search = '' OR name LIKE '%' || :search || '%') " + "ORDER BY albumCount DESC, name COLLATE NOCASE", ) fun artistsByAlbumCount( sourceId: Long, - starredOnly: Boolean, + starredFilter: Int, search: String, ): PagingSource @Query( "SELECT * FROM artists WHERE sourceId = :sourceId " + - "AND (:starredOnly = 0 OR starred IS NOT NULL) " + + "AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " + "AND (:search = '' OR name LIKE '%' || :search || '%') " + "ORDER BY albumCount ASC, name COLLATE NOCASE", ) fun artistsByAlbumCountReversed( sourceId: Long, - starredOnly: Boolean, + starredFilter: Int, search: String, ): PagingSource @Query( "SELECT * FROM artists WHERE sourceId = :sourceId " + - "AND (:starredOnly = 0 OR starred IS NOT NULL) " + + "AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " + "AND (:search = '' OR name LIKE '%' || :search || '%') " + "ORDER BY starred IS NULL, starred DESC, name COLLATE NOCASE", ) fun artistsByStarred( sourceId: Long, - starredOnly: Boolean, + starredFilter: Int, search: String, ): PagingSource @Query( "SELECT * FROM artists WHERE sourceId = :sourceId " + - "AND (:starredOnly = 0 OR starred IS NOT NULL) " + + "AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " + "AND (:search = '' OR name LIKE '%' || :search || '%') " + "ORDER BY starred IS NULL, starred ASC, name COLLATE NOCASE", ) fun artistsByStarredReversed( sourceId: Long, - starredOnly: Boolean, + starredFilter: Int, search: String, ): PagingSource @@ -344,7 +344,9 @@ interface LibraryDao { @Query( "SELECT songs.*, albums.coverArt AS coverArt FROM songs " + "LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + - "WHERE songs.sourceId = :sourceId AND (:starredOnly = 0 OR songs.starred IS NOT NULL) " + + "WHERE songs.sourceId = :sourceId " + + "AND (:starredFilter = 0 OR (:starredFilter = 1 AND songs.starred IS NOT NULL) OR " + + "(:starredFilter = 2 AND songs.starred IS NULL)) " + "AND (:search = '' OR songs.title LIKE '%' || :search || '%' " + "OR songs.artist LIKE '%' || :search || '%' OR songs.album LIKE '%' || :search || '%') " + "ORDER BY albums.albumArtist COLLATE NOCASE, songs.album COLLATE NOCASE, songs.disc, songs.track, " + @@ -352,91 +354,103 @@ interface LibraryDao { ) fun songs( sourceId: Long, - starredOnly: Boolean, + starredFilter: Int, search: String, ): PagingSource @Query( "SELECT songs.*, albums.coverArt AS coverArt FROM songs " + "LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + - "WHERE songs.sourceId = :sourceId AND (:starredOnly = 0 OR songs.starred IS NOT NULL) " + + "WHERE songs.sourceId = :sourceId " + + "AND (:starredFilter = 0 OR (:starredFilter = 1 AND songs.starred IS NOT NULL) OR " + + "(:starredFilter = 2 AND songs.starred IS NULL)) " + "AND (:search = '' OR songs.title LIKE '%' || :search || '%' " + "OR songs.artist LIKE '%' || :search || '%' OR songs.album LIKE '%' || :search || '%') " + "ORDER BY songs.title COLLATE NOCASE, songs.id", ) fun songsByTitle( sourceId: Long, - starredOnly: Boolean, + starredFilter: Int, search: String, ): PagingSource @Query( "SELECT songs.*, albums.coverArt AS coverArt FROM songs " + "LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + - "WHERE songs.sourceId = :sourceId AND (:starredOnly = 0 OR songs.starred IS NOT NULL) " + + "WHERE songs.sourceId = :sourceId " + + "AND (:starredFilter = 0 OR (:starredFilter = 1 AND songs.starred IS NOT NULL) OR " + + "(:starredFilter = 2 AND songs.starred IS NULL)) " + "AND (:search = '' OR songs.title LIKE '%' || :search || '%' " + "OR songs.artist LIKE '%' || :search || '%' OR songs.album LIKE '%' || :search || '%') " + "ORDER BY songs.title COLLATE NOCASE DESC, songs.id", ) fun songsByTitleReversed( sourceId: Long, - starredOnly: Boolean, + starredFilter: Int, search: String, ): PagingSource @Query( "SELECT songs.*, albums.coverArt AS coverArt FROM songs " + "LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + - "WHERE songs.sourceId = :sourceId AND (:starredOnly = 0 OR songs.starred IS NOT NULL) " + + "WHERE songs.sourceId = :sourceId " + + "AND (:starredFilter = 0 OR (:starredFilter = 1 AND songs.starred IS NOT NULL) OR " + + "(:starredFilter = 2 AND songs.starred IS NULL)) " + "AND (:search = '' OR songs.title LIKE '%' || :search || '%' " + "OR songs.artist LIKE '%' || :search || '%' OR songs.album LIKE '%' || :search || '%') " + "ORDER BY songs.artist COLLATE NOCASE, songs.album COLLATE NOCASE, songs.disc, songs.track, songs.id", ) fun songsByArtist( sourceId: Long, - starredOnly: Boolean, + starredFilter: Int, search: String, ): PagingSource @Query( "SELECT songs.*, albums.coverArt AS coverArt FROM songs " + "LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + - "WHERE songs.sourceId = :sourceId AND (:starredOnly = 0 OR songs.starred IS NOT NULL) " + + "WHERE songs.sourceId = :sourceId " + + "AND (:starredFilter = 0 OR (:starredFilter = 1 AND songs.starred IS NOT NULL) OR " + + "(:starredFilter = 2 AND songs.starred IS NULL)) " + "AND (:search = '' OR songs.title LIKE '%' || :search || '%' " + "OR songs.artist LIKE '%' || :search || '%' OR songs.album LIKE '%' || :search || '%') " + "ORDER BY songs.artist COLLATE NOCASE DESC, songs.album COLLATE NOCASE, songs.disc, songs.track, songs.id", ) fun songsByArtistReversed( sourceId: Long, - starredOnly: Boolean, + starredFilter: Int, search: String, ): PagingSource @Query( "SELECT songs.*, albums.coverArt AS coverArt FROM songs " + "LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + - "WHERE songs.sourceId = :sourceId AND (:starredOnly = 0 OR songs.starred IS NOT NULL) " + + "WHERE songs.sourceId = :sourceId " + + "AND (:starredFilter = 0 OR (:starredFilter = 1 AND songs.starred IS NOT NULL) OR " + + "(:starredFilter = 2 AND songs.starred IS NULL)) " + "AND (:search = '' OR songs.title LIKE '%' || :search || '%' " + "OR songs.artist LIKE '%' || :search || '%' OR songs.album LIKE '%' || :search || '%') " + "ORDER BY songs.starred IS NULL, songs.starred DESC, songs.title COLLATE NOCASE, songs.id", ) fun songsByStarred( sourceId: Long, - starredOnly: Boolean, + starredFilter: Int, search: String, ): PagingSource @Query( "SELECT songs.*, albums.coverArt AS coverArt FROM songs " + "LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + - "WHERE songs.sourceId = :sourceId AND (:starredOnly = 0 OR songs.starred IS NOT NULL) " + + "WHERE songs.sourceId = :sourceId " + + "AND (:starredFilter = 0 OR (:starredFilter = 1 AND songs.starred IS NOT NULL) OR " + + "(:starredFilter = 2 AND songs.starred IS NULL)) " + "AND (:search = '' OR songs.title LIKE '%' || :search || '%' " + "OR songs.artist LIKE '%' || :search || '%' OR songs.album LIKE '%' || :search || '%') " + "ORDER BY songs.starred IS NULL, songs.starred ASC, songs.title COLLATE NOCASE, songs.id", ) fun songsByStarredReversed( sourceId: Long, - starredOnly: Boolean, + starredFilter: Int, search: String, ): PagingSource diff --git a/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt b/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt index 4a400784..e3bdf7ee 100644 --- a/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt +++ b/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt @@ -29,10 +29,12 @@ enum class LibraryListTab( Songs("songs", SongSort.Album.name, true), } +enum class StarredFilter { Any, Starred, NotStarred } + data class ListQuery( val sort: String, val descending: Boolean = false, - val starredOnly: Boolean = false, + val starred: StarredFilter = StarredFilter.Any, ) private val Context.preferences: DataStore by preferencesDataStore("user_prefs") @@ -65,7 +67,7 @@ class UserPreferences( private fun listQueryKey(tab: LibraryListTab) = stringPreferencesKey("list_query_${tab.key}") - private fun encode(query: ListQuery) = "${query.sort}|${if (query.descending) 1 else 0}|${if (query.starredOnly) 1 else 0}" + private fun encode(query: ListQuery) = "${query.sort}|${if (query.descending) 1 else 0}|${query.starred.ordinal}" private fun decode( stored: String?, @@ -76,7 +78,7 @@ class UserPreferences( return ListQuery( sort = parts[0].ifEmpty { defaultSort }, descending = parts[1] == "1", - starredOnly = parts[2] == "1", + starred = StarredFilter.entries.getOrElse(parts[2].toIntOrNull() ?: 0) { StarredFilter.Any }, ) } diff --git a/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt b/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt index 6db7c363..50fa4240 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt @@ -14,6 +14,7 @@ import com.subtracks.data.prefs.AlbumSort import com.subtracks.data.prefs.ArtistSort import com.subtracks.data.prefs.PlaylistSort import com.subtracks.data.prefs.SongSort +import com.subtracks.data.prefs.StarredFilter import kotlinx.coroutines.flow.Flow class LibraryRepository( @@ -26,7 +27,7 @@ class LibraryRepository( sourceId: Long, sort: AlbumSort, descending: Boolean, - starredOnly: Boolean, + starred: StarredFilter, search: String, ): Flow> = pager(40) { @@ -34,41 +35,41 @@ class LibraryRepository( when (sort) { AlbumSort.Name -> { if (descending) { - dao.albumsByNameReversed(sourceId, starredOnly, search) + dao.albumsByNameReversed(sourceId, starred.ordinal, search) } else { - dao.albumsByName(sourceId, starredOnly, search) + dao.albumsByName(sourceId, starred.ordinal, search) } } AlbumSort.Artist -> { if (descending) { - dao.albumsByArtistReversed(sourceId, starredOnly, search) + dao.albumsByArtistReversed(sourceId, starred.ordinal, search) } else { - dao.albumsByArtist(sourceId, starredOnly, search) + dao.albumsByArtist(sourceId, starred.ordinal, search) } } AlbumSort.Year -> { if (descending) { - dao.albumsByYearReversed(sourceId, starredOnly, search) + dao.albumsByYearReversed(sourceId, starred.ordinal, search) } else { - dao.albumsByYear(sourceId, starredOnly, search) + dao.albumsByYear(sourceId, starred.ordinal, search) } } AlbumSort.Added -> { if (descending) { - dao.albumsByRecentlyAddedReversed(sourceId, starredOnly, search) + dao.albumsByRecentlyAddedReversed(sourceId, starred.ordinal, search) } else { - dao.albumsByRecentlyAdded(sourceId, starredOnly, search) + dao.albumsByRecentlyAdded(sourceId, starred.ordinal, search) } } AlbumSort.Starred -> { if (descending) { - dao.albumsByStarredReversed(sourceId, starredOnly, search) + dao.albumsByStarredReversed(sourceId, starred.ordinal, search) } else { - dao.albumsByStarred(sourceId, starredOnly, search) + dao.albumsByStarred(sourceId, starred.ordinal, search) } } } @@ -78,7 +79,7 @@ class LibraryRepository( sourceId: Long, sort: ArtistSort, descending: Boolean, - starredOnly: Boolean, + starred: StarredFilter, search: String, ): Flow> = pager(60) { @@ -86,25 +87,25 @@ class LibraryRepository( when (sort) { ArtistSort.Name -> { if (descending) { - dao.artistsByNameReversed(sourceId, starredOnly, search) + dao.artistsByNameReversed(sourceId, starred.ordinal, search) } else { - dao.artistsByName(sourceId, starredOnly, search) + dao.artistsByName(sourceId, starred.ordinal, search) } } ArtistSort.AlbumCount -> { if (descending) { - dao.artistsByAlbumCountReversed(sourceId, starredOnly, search) + dao.artistsByAlbumCountReversed(sourceId, starred.ordinal, search) } else { - dao.artistsByAlbumCount(sourceId, starredOnly, search) + dao.artistsByAlbumCount(sourceId, starred.ordinal, search) } } ArtistSort.Starred -> { if (descending) { - dao.artistsByStarredReversed(sourceId, starredOnly, search) + dao.artistsByStarredReversed(sourceId, starred.ordinal, search) } else { - dao.artistsByStarred(sourceId, starredOnly, search) + dao.artistsByStarred(sourceId, starred.ordinal, search) } } } @@ -149,37 +150,37 @@ class LibraryRepository( sourceId: Long, sort: SongSort, descending: Boolean, - starredOnly: Boolean, + starred: StarredFilter, search: String, ): Flow> = pager(60) { val dao = db.libraryDao() when (sort) { SongSort.Album -> { - dao.songs(sourceId, starredOnly, search) + dao.songs(sourceId, starred.ordinal, search) } SongSort.Title -> { if (descending) { - dao.songsByTitleReversed(sourceId, starredOnly, search) + dao.songsByTitleReversed(sourceId, starred.ordinal, search) } else { - dao.songsByTitle(sourceId, starredOnly, search) + dao.songsByTitle(sourceId, starred.ordinal, search) } } SongSort.Artist -> { if (descending) { - dao.songsByArtistReversed(sourceId, starredOnly, search) + dao.songsByArtistReversed(sourceId, starred.ordinal, search) } else { - dao.songsByArtist(sourceId, starredOnly, search) + dao.songsByArtist(sourceId, starred.ordinal, search) } } SongSort.Starred -> { if (descending) { - dao.songsByStarredReversed(sourceId, starredOnly, search) + dao.songsByStarredReversed(sourceId, starred.ordinal, search) } else { - dao.songsByStarred(sourceId, starredOnly, search) + dao.songsByStarred(sourceId, starred.ordinal, search) } } } diff --git a/app/src/main/kotlin/com/subtracks/ui/components/Components.kt b/app/src/main/kotlin/com/subtracks/ui/components/Components.kt index 4ea8303e..b3b1a252 100644 --- a/app/src/main/kotlin/com/subtracks/ui/components/Components.kt +++ b/app/src/main/kotlin/com/subtracks/ui/components/Components.kt @@ -3,13 +3,16 @@ package com.subtracks.ui.components import android.content.Context import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -182,3 +185,29 @@ fun EmptyState( ) } } + +@Composable +fun FilteredEmptyState( + onClearFilters: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier.fillMaxSize().padding(32.dp), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = "Filters are hiding everything.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Button(onClick = onClearFilters) { + Text("Clear filters") + } + } + } +} diff --git a/app/src/main/kotlin/com/subtracks/ui/library/AlbumsContent.kt b/app/src/main/kotlin/com/subtracks/ui/library/AlbumsContent.kt index e1808b1d..78ff1163 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/AlbumsContent.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/AlbumsContent.kt @@ -27,6 +27,7 @@ import com.subtracks.data.model.Album import com.subtracks.data.model.CoverArtRef import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.EmptyState +import com.subtracks.ui.components.FilteredEmptyState import com.subtracks.ui.components.LoadingState import com.subtracks.ui.components.rememberViewportFill import com.subtracks.ui.theme.PrefetchArtworkSeeds @@ -39,6 +40,8 @@ fun AlbumsContent( coverArt: (String?, Boolean) -> CoverArtRef?, bottomInset: Dp, onAlbumClick: (Album) -> Unit, + filtered: Boolean = false, + onClearFilters: () -> Unit = {}, resetKey: Any? = null, modifier: Modifier = Modifier, ) { @@ -48,7 +51,11 @@ fun AlbumsContent( } items.itemCount == 0 -> { - EmptyState("No albums yet.\nSync with your server to fill your library.", modifier) + if (filtered) { + FilteredEmptyState(onClearFilters, modifier) + } else { + EmptyState("No albums yet.\nSync with your server to fill your library.", modifier) + } } else -> { diff --git a/app/src/main/kotlin/com/subtracks/ui/library/ArtistsContent.kt b/app/src/main/kotlin/com/subtracks/ui/library/ArtistsContent.kt index 4cadc3cd..ff19e3dd 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/ArtistsContent.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/ArtistsContent.kt @@ -27,6 +27,7 @@ import com.subtracks.data.model.Artist import com.subtracks.data.model.CoverArtRef import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.EmptyState +import com.subtracks.ui.components.FilteredEmptyState import com.subtracks.ui.components.LoadingState import com.subtracks.ui.components.rememberViewportFill import com.subtracks.ui.theme.PrefetchArtworkSeeds @@ -37,6 +38,8 @@ fun ArtistsContent( coverArt: (String?, Boolean) -> CoverArtRef?, bottomInset: Dp, onArtistClick: (Artist) -> Unit, + filtered: Boolean = false, + onClearFilters: () -> Unit = {}, resetKey: Any? = null, modifier: Modifier = Modifier, ) { @@ -46,7 +49,11 @@ fun ArtistsContent( } items.itemCount == 0 -> { - EmptyState("No artists yet.", modifier) + if (filtered) { + FilteredEmptyState(onClearFilters, modifier) + } else { + EmptyState("No artists yet.", modifier) + } } else -> { 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 422c6e27..bcdcab95 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt @@ -12,6 +12,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.imePadding import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding @@ -27,18 +28,21 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.PlaylistPlay import androidx.compose.material.icons.filled.Sort +import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.Album import androidx.compose.material.icons.rounded.ArrowDownward import androidx.compose.material.icons.rounded.ArrowUpward import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.MusicNote import androidx.compose.material.icons.rounded.Person +import androidx.compose.material.icons.rounded.Remove import androidx.compose.material.icons.rounded.Search import androidx.compose.material.icons.rounded.Settings import androidx.compose.material.icons.rounded.Sync import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -93,6 +97,7 @@ import com.subtracks.data.prefs.LibraryListTab import com.subtracks.data.prefs.ListQuery import com.subtracks.data.prefs.PlaylistSort import com.subtracks.data.prefs.SongSort +import com.subtracks.data.prefs.StarredFilter import org.koin.compose.viewmodel.koinViewModel import kotlin.math.abs import kotlin.math.roundToInt @@ -190,8 +195,19 @@ fun LibraryRoute( starredSupported = listTab.supportsStarred, onSortChange = { viewModel.setListQuery(listTab, listQuery.copy(sort = it)) }, onToggleSortDirection = { viewModel.setListQuery(listTab, listQuery.copy(descending = !listQuery.descending)) }, - onToggleStarred = { viewModel.setListQuery(listTab, listQuery.copy(starredOnly = !listQuery.starredOnly)) }, - onClearFilters = { viewModel.setListQuery(listTab, listQuery.copy(starredOnly = false)) }, + onCycleStarred = { + val next = + when (listQuery.starred) { + StarredFilter.Any -> StarredFilter.Starred + StarredFilter.Starred -> StarredFilter.NotStarred + StarredFilter.NotStarred -> StarredFilter.Any + } + viewModel.setListQuery(listTab, listQuery.copy(starred = next)) + }, + onClearFilters = { + viewModel.setListQuery(listTab, listQuery.copy(starred = StarredFilter.Any)) + viewModel.setSearch(listTab, "") + }, search = search, onSearchChange = { viewModel.setSearch(listTab, it) }, ) @@ -221,7 +237,7 @@ fun LibraryScreen( starredSupported: Boolean = false, onSortChange: (String) -> Unit = {}, onToggleSortDirection: () -> Unit = {}, - onToggleStarred: () -> Unit = {}, + onCycleStarred: () -> Unit = {}, onClearFilters: () -> Unit = {}, search: String = "", onSearchChange: (String) -> Unit = {}, @@ -261,8 +277,9 @@ fun LibraryScreen( val titleFraction = scrollBehavior.state.collapsedFraction val statusBarTop = with(density) { WindowInsets.statusBars.getTop(this).toDp() } - val listBottomInset = bottomInset + if (searchActive) 0.dp else FAB_CLEARANCE - val resetKey = listOf(listQuery.sort, listQuery.descending, listQuery.starredOnly, search) + val filtersActive = listQuery.starred != StarredFilter.Any || search.isNotEmpty() + val listBottomInset = bottomInset + if (searchActive) SEARCH_BAR_CLEARANCE else FAB_CLEARANCE + val resetKey = listOf(listQuery.sort, listQuery.descending, listQuery.starred, search) Box(modifier.fillMaxSize()) { Column( @@ -312,11 +329,27 @@ fun LibraryScreen( ) { page -> when (LibraryTab.entries[page]) { LibraryTab.Albums -> { - AlbumsContent(albums, coverArt, listBottomInset, onAlbumClick, resetKey = resetKey) + AlbumsContent( + albums, + coverArt, + listBottomInset, + onAlbumClick, + filtered = filtersActive, + onClearFilters = onClearFilters, + resetKey = resetKey, + ) } LibraryTab.Artists -> { - ArtistsContent(artists, coverArt, listBottomInset, onArtistClick, resetKey = resetKey) + ArtistsContent( + artists, + coverArt, + listBottomInset, + onArtistClick, + filtered = filtersActive, + onClearFilters = onClearFilters, + resetKey = resetKey, + ) } LibraryTab.Songs -> { @@ -326,28 +359,43 @@ fun LibraryScreen( listBottomInset, onSongClick, playingSongId, + filtered = filtersActive, + onClearFilters = onClearFilters, resetKey = resetKey, ) } LibraryTab.Playlists -> { - PlaylistsContent(playlists, coverArt, listBottomInset, onPlaylistClick, resetKey = resetKey) + PlaylistsContent( + playlists, + coverArt, + listBottomInset, + onPlaylistClick, + filtered = filtersActive, + onClearFilters = onClearFilters, + resetKey = resetKey, + ) } } } + } - if (searchActive) { - SearchField( - value = search, - onValueChange = onSearchChange, - onClose = { - searchActive = false - onSearchChange("") - }, - focusRequester = searchFocus, - modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), - ) - } + if (searchActive) { + SearchField( + value = search, + onValueChange = onSearchChange, + onClose = { + searchActive = false + onSearchChange("") + }, + focusRequester = searchFocus, + modifier = + Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .imePadding() + .padding(horizontal = 12.dp, vertical = 8.dp), + ) } if (!searchActive) { @@ -359,7 +407,7 @@ fun LibraryScreen( ) { Box { Icon(Icons.Filled.Sort, contentDescription = "List options") - if (listQuery.starredOnly) { + if (listQuery.starred != StarredFilter.Any) { Box( modifier = Modifier @@ -383,7 +431,7 @@ fun LibraryScreen( starredSupported = starredSupported, onSortChange = onSortChange, onToggleSortDirection = onToggleSortDirection, - onToggleStarred = onToggleStarred, + onCycleStarred = onCycleStarred, onClearFilters = onClearFilters, onSearch = { showOptions = false @@ -402,7 +450,7 @@ private fun ListOptionsSheet( starredSupported: Boolean, onSortChange: (String) -> Unit, onToggleSortDirection: () -> Unit, - onToggleStarred: () -> Unit, + onCycleStarred: () -> Unit, onClearFilters: () -> Unit, onSearch: () -> Unit, ) { @@ -456,12 +504,38 @@ private fun ListOptionsSheet( modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), ) { FilterChip( - selected = listQuery.starredOnly, - onClick = onToggleStarred, + selected = listQuery.starred != StarredFilter.Any, + onClick = onCycleStarred, label = { Text("Starred") }, + leadingIcon = + when (listQuery.starred) { + StarredFilter.Any -> { + null + } + + StarredFilter.Starred -> { + { + Icon( + Icons.Rounded.Add, + contentDescription = null, + modifier = Modifier.size(FilterChipDefaults.IconSize), + ) + } + } + + StarredFilter.NotStarred -> { + { + Icon( + Icons.Rounded.Remove, + contentDescription = null, + modifier = Modifier.size(FilterChipDefaults.IconSize), + ) + } + } + }, ) - if (listQuery.starredOnly) { - TextButton(onClick = onClearFilters) { Text("Clear") } + if (listQuery.starred != StarredFilter.Any) { + TextButton(onClick = onClearFilters) { Text("Clear filters") } } } } @@ -469,6 +543,7 @@ private fun ListOptionsSheet( } private val FAB_CLEARANCE = 80.dp +private val SEARCH_BAR_CLEARANCE = 80.dp @Composable private fun SearchField( diff --git a/app/src/main/kotlin/com/subtracks/ui/library/LibraryViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/library/LibraryViewModel.kt index efe8f756..6364c5b3 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/LibraryViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/LibraryViewModel.kt @@ -59,7 +59,7 @@ class LibraryViewModel( combine(listQueries.getValue(LibraryListTab.Albums), searches.getValue(LibraryListTab.Albums)) { query, search -> query to search }.flatMapLatest { (query, search) -> - libraryRepository.albums(sourceId, query.albumSort(), query.descending, query.starredOnly, search) + libraryRepository.albums(sourceId, query.albumSort(), query.descending, query.starred, search) } }.cachedIn(viewModelScope) @@ -70,7 +70,7 @@ class LibraryViewModel( combine(listQueries.getValue(LibraryListTab.Artists), searches.getValue(LibraryListTab.Artists)) { query, search -> query to search }.flatMapLatest { (query, search) -> - libraryRepository.artists(sourceId, query.artistSort(), query.descending, query.starredOnly, search) + libraryRepository.artists(sourceId, query.artistSort(), query.descending, query.starred, search) } }.cachedIn(viewModelScope) @@ -92,7 +92,7 @@ class LibraryViewModel( combine(listQueries.getValue(LibraryListTab.Songs), searches.getValue(LibraryListTab.Songs)) { query, search -> query to search }.flatMapLatest { (query, search) -> - libraryRepository.songs(sourceId, query.songSort(), query.descending, query.starredOnly, search) + libraryRepository.songs(sourceId, query.songSort(), query.descending, query.starred, search) } }.cachedIn(viewModelScope) diff --git a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistsContent.kt b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistsContent.kt index 9e83fba0..fb276179 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistsContent.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistsContent.kt @@ -27,6 +27,7 @@ import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.Playlist import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.EmptyState +import com.subtracks.ui.components.FilteredEmptyState import com.subtracks.ui.components.LoadingState import com.subtracks.ui.components.rememberViewportFill @@ -36,6 +37,8 @@ fun PlaylistsContent( coverArt: (String?, Boolean) -> CoverArtRef?, bottomInset: Dp, onPlaylistClick: (Playlist) -> Unit, + filtered: Boolean = false, + onClearFilters: () -> Unit = {}, resetKey: Any? = null, modifier: Modifier = Modifier, ) { @@ -45,7 +48,11 @@ fun PlaylistsContent( } items.itemCount == 0 -> { - EmptyState("No playlists yet.", modifier) + if (filtered) { + FilteredEmptyState(onClearFilters, modifier) + } else { + EmptyState("No playlists yet.", modifier) + } } else -> { diff --git a/app/src/main/kotlin/com/subtracks/ui/library/SongsContent.kt b/app/src/main/kotlin/com/subtracks/ui/library/SongsContent.kt index aa516750..8f4c1e65 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/SongsContent.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/SongsContent.kt @@ -38,6 +38,7 @@ import com.subtracks.data.model.Song import com.subtracks.data.model.SongListItem import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.EmptyState +import com.subtracks.ui.components.FilteredEmptyState import com.subtracks.ui.components.LoadingState import com.subtracks.ui.components.rememberViewportFill @@ -48,6 +49,8 @@ fun SongsContent( bottomInset: Dp, onSongClick: (Int) -> Unit, playingSongId: String? = null, + filtered: Boolean = false, + onClearFilters: () -> Unit = {}, resetKey: Any? = null, modifier: Modifier = Modifier, ) { @@ -57,7 +60,11 @@ fun SongsContent( } items.itemCount == 0 -> { - EmptyState("No songs yet.", modifier) + if (filtered) { + FilteredEmptyState(onClearFilters, modifier) + } else { + EmptyState("No songs yet.", modifier) + } } else -> { diff --git a/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt b/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt index 52e4dd70..9ab331d1 100644 --- a/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt @@ -50,17 +50,17 @@ class LibraryDaoTest { assertEquals( listOf("Alpha", "Beta", "Gamma"), - dao.albumsByName(sourceId, starredOnly = false, search = "").page().map { it.name }, + dao.albumsByName(sourceId, starredFilter = 0, search = "").page().map { it.name }, ) assertEquals( listOf("Gamma", "Beta", "Alpha"), - dao.albumsByNameReversed(sourceId, starredOnly = false, search = "").page().map { it.name }, + dao.albumsByNameReversed(sourceId, starredFilter = 0, search = "").page().map { it.name }, ) assertEquals( listOf("Alpha", "Beta", "Gamma"), - dao.albumsByYear(sourceId, starredOnly = false, search = "").page().map { it.name }, + dao.albumsByYear(sourceId, starredFilter = 0, search = "").page().map { it.name }, ) - assertEquals(listOf("Alpha"), dao.albumsByName(sourceId, starredOnly = true, search = "").page().map { it.name }) + assertEquals(listOf("Alpha"), dao.albumsByName(sourceId, starredFilter = 1, search = "").page().map { it.name }) } @Test @@ -77,13 +77,13 @@ class LibraryDaoTest { assertEquals( listOf("Apple", "Zebra"), - dao.songsByTitle(sourceId, starredOnly = false, search = "").page().map { it.song.title }, + dao.songsByTitle(sourceId, starredFilter = 0, search = "").page().map { it.song.title }, ) assertEquals( listOf("Zebra", "Apple"), - dao.songsByTitleReversed(sourceId, starredOnly = false, search = "").page().map { it.song.title }, + dao.songsByTitleReversed(sourceId, starredFilter = 0, search = "").page().map { it.song.title }, ) - assertEquals(listOf("Apple"), dao.songsByTitle(sourceId, starredOnly = true, search = "").page().map { it.song.title }) + assertEquals(listOf("Apple"), dao.songsByTitle(sourceId, starredFilter = 1, search = "").page().map { it.song.title }) } @Test @@ -101,13 +101,14 @@ class LibraryDaoTest { assertEquals( listOf("Beta", "Gamma", "Alpha"), - dao.albumsByRecentlyAdded(sourceId, starredOnly = false, search = "").page().map { it.name }, + dao.albumsByRecentlyAdded(sourceId, starredFilter = 0, search = "").page().map { it.name }, ) assertEquals( listOf("Alpha", "Gamma", "Beta"), - dao.albumsByStarred(sourceId, starredOnly = false, search = "").page().map { it.name }, + dao.albumsByStarred(sourceId, starredFilter = 0, search = "").page().map { it.name }, ) - assertEquals(listOf("Gamma"), dao.albumsByName(sourceId, starredOnly = false, search = "gam").page().map { it.name }) + assertEquals(listOf("Gamma"), dao.albumsByName(sourceId, starredFilter = 0, search = "gam").page().map { it.name }) + assertEquals(listOf("Beta"), dao.albumsByName(sourceId, starredFilter = 2, search = "").page().map { it.name }) } private suspend fun source(): Long = diff --git a/app/src/test/kotlin/com/subtracks/data/prefs/UserPreferencesTest.kt b/app/src/test/kotlin/com/subtracks/data/prefs/UserPreferencesTest.kt index 9e0669f7..6e8bc21b 100644 --- a/app/src/test/kotlin/com/subtracks/data/prefs/UserPreferencesTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/prefs/UserPreferencesTest.kt @@ -23,9 +23,12 @@ class UserPreferencesTest { assertEquals(ListQuery("Name"), prefs.listQuery(LibraryListTab.Albums).first()) assertEquals(ListQuery("Album"), prefs.listQuery(LibraryListTab.Songs).first()) - prefs.setListQuery(LibraryListTab.Albums, ListQuery("Added", descending = true, starredOnly = true)) + prefs.setListQuery(LibraryListTab.Albums, ListQuery("Added", descending = true, starred = StarredFilter.Starred)) - assertEquals(ListQuery("Added", descending = true, starredOnly = true), prefs.listQuery(LibraryListTab.Albums).first()) + assertEquals( + ListQuery("Added", descending = true, starred = StarredFilter.Starred), + prefs.listQuery(LibraryListTab.Albums).first(), + ) assertEquals(ListQuery("Album"), prefs.listQuery(LibraryListTab.Songs).first()) file.delete() diff --git a/app/src/test/kotlin/com/subtracks/data/sync/SyncServiceTest.kt b/app/src/test/kotlin/com/subtracks/data/sync/SyncServiceTest.kt index 7808b9c9..c3fbb00f 100644 --- a/app/src/test/kotlin/com/subtracks/data/sync/SyncServiceTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/sync/SyncServiceTest.kt @@ -60,7 +60,7 @@ class SyncServiceTest { 2, db .libraryDao() - .artistsByName(1, false, "") + .artistsByName(1, 0, "") .allRows() .size, ) @@ -68,7 +68,7 @@ class SyncServiceTest { 2, db .libraryDao() - .songs(1, false, "") + .songs(1, 0, "") .allRows() .size, ) @@ -90,7 +90,7 @@ class SyncServiceTest { listOf("a1"), db .libraryDao() - .artistsByName(1, false, "") + .artistsByName(1, 0, "") .allRows() .map { it.id }, ) @@ -98,7 +98,7 @@ class SyncServiceTest { listOf("s1"), db .libraryDao() - .songs(1, false, "") + .songs(1, 0, "") .allRows() .map { it.song.id }, ) @@ -200,7 +200,7 @@ class SyncServiceTest { "Old", db .libraryDao() - .artistsByName(1, false, "") + .artistsByName(1, 0, "") .allRows() .single() .name, @@ -213,7 +213,7 @@ class SyncServiceTest { "New", db .libraryDao() - .artistsByName(1, false, "") + .artistsByName(1, 0, "") .allRows() .single() .name, diff --git a/app/src/test/kotlin/com/subtracks/ui/LibraryOptionsTest.kt b/app/src/test/kotlin/com/subtracks/ui/LibraryOptionsTest.kt new file mode 100644 index 00000000..dede623c --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/ui/LibraryOptionsTest.kt @@ -0,0 +1,121 @@ +package com.subtracks.ui + +import androidx.activity.ComponentActivity +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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.performClick +import androidx.paging.PagingData +import androidx.paging.compose.collectAsLazyPagingItems +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers +import com.subtracks.data.model.Album +import com.subtracks.data.model.Artist +import com.subtracks.data.model.Playlist +import com.subtracks.data.model.SongListItem +import com.subtracks.data.prefs.ListQuery +import com.subtracks.data.prefs.StarredFilter +import com.subtracks.ui.library.ALBUM_COVER_TAG +import com.subtracks.ui.library.LibraryScreen +import com.subtracks.ui.library.LibraryTab +import com.subtracks.ui.library.sortOptionsFor +import com.subtracks.ui.theme.SubtracksTheme +import kotlinx.coroutines.flow.flowOf +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +@RunWith(AndroidJUnit4::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(qualifiers = RobolectricDeviceQualifiers.PixelXL) +class LibraryOptionsTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun theOptionsSheetAppliesSortFilterAndClear() { + var query by mutableStateOf(ListQuery("Name")) + composeRule.setContent { + SubtracksTheme { + LibraryScreen( + selectedTab = LibraryTab.Albums, + onTabSelected = {}, + albums = remember { flowOf(PagingData.from(albums())) }.collectAsLazyPagingItems(), + artists = remember { flowOf(PagingData.empty()) }.collectAsLazyPagingItems(), + songs = remember { flowOf(PagingData.empty()) }.collectAsLazyPagingItems(), + playlists = remember { flowOf(PagingData.empty()) }.collectAsLazyPagingItems(), + coverArt = { _, _ -> null }, + onAlbumClick = {}, + onArtistClick = {}, + onPlaylistClick = {}, + onSongClick = {}, + onSync = {}, + onOpenSettings = {}, + listQuery = query, + sortOptions = sortOptionsFor(LibraryTab.Albums), + starredSupported = true, + onSortChange = { query = query.copy(sort = it) }, + onCycleStarred = { + query = + query.copy( + starred = + when (query.starred) { + StarredFilter.Any -> StarredFilter.Starred + StarredFilter.Starred -> StarredFilter.NotStarred + StarredFilter.NotStarred -> StarredFilter.Any + }, + ) + }, + onClearFilters = { query = query.copy(starred = StarredFilter.Any) }, + ) + } + } + composeRule.waitUntil(timeoutMillis = 5_000) { + composeRule.onAllNodesWithTag(ALBUM_COVER_TAG).fetchSemanticsNodes().isNotEmpty() + } + + composeRule.onNodeWithContentDescription("List options").performClick() + composeRule.mainClock.advanceTimeBy(1_000) + composeRule.waitForIdle() + + composeRule.onNodeWithText("Added").performClick() + composeRule.waitForIdle() + assertEquals("Added", query.sort) + + composeRule.onAllNodesWithText("Starred")[1].performClick() + composeRule.waitForIdle() + assertEquals(StarredFilter.Starred, query.starred) + + composeRule.onNodeWithText("Clear filters").performClick() + composeRule.waitForIdle() + assertEquals(StarredFilter.Any, query.starred) + } + + private fun albums() = + (1..3).map { index -> + Album( + sourceId = 1, + id = "al-$index", + artistId = "ar-1", + name = "Album $index", + albumArtist = "Artist", + created = 0, + coverArt = null, + genre = null, + year = null, + starred = null, + songCount = 1, + frequentRank = null, + recentRank = null, + ) + } +}