From 6d1fa9cc1207d2d4d3ed29d84c306918ef39366f Mon Sep 17 00:00:00 2001 From: PonceGL Date: Tue, 21 Jul 2026 19:00:46 -0600 Subject: [PATCH 1/7] refactor(components): extract reusable search filter text field Pulls the inline search field out of SongPickerBottomSheet into a standalone SearchFilterTextField composable so it can be reused by other song-list screens without duplicating the input styling. --- .../components/SearchFilterTextField.kt | 63 +++++++++++++++++++ .../components/SongPickerBottomSheet.kt | 44 +------------ 2 files changed, 64 insertions(+), 43 deletions(-) create mode 100644 app/src/main/java/com/theveloper/pixelplay/presentation/components/SearchFilterTextField.kt diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/SearchFilterTextField.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/SearchFilterTextField.kt new file mode 100644 index 0000000000..0ee069a5a1 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/SearchFilterTextField.kt @@ -0,0 +1,63 @@ +package com.theveloper.pixelplay.presentation.components + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.rounded.Search +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.theveloper.pixelplay.R + +/** + * Compact, reusable filter/search input field shared across song lists + * (song picker, playlist detail, etc.). Callers own the query state. + */ +@Composable +fun SearchFilterTextField( + searchQuery: String, + onSearchQueryChange: (String) -> Unit, + modifier: Modifier = Modifier, + label: String = stringResource(R.string.song_picker_search_label) +) { + OutlinedTextField( + value = searchQuery, + colors = TextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + unfocusedTrailingIconColor = Color.Transparent, + focusedSupportingTextColor = Color.Transparent, + ), + onValueChange = onSearchQueryChange, + label = { Text(label) }, + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + shape = CircleShape, + singleLine = true, + leadingIcon = { + Icon(Icons.Rounded.Search, contentDescription = null) + }, + trailingIcon = { + if (searchQuery.isNotEmpty()) { + IconButton(onClick = { onSearchQueryChange("") }) { + Icon(Icons.Filled.Clear, null) + } + } + } + ) +} diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/SongPickerBottomSheet.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/SongPickerBottomSheet.kt index 2c30d0f802..2d9c7a6fba 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/SongPickerBottomSheet.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/SongPickerBottomSheet.kt @@ -28,12 +28,10 @@ import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.AudioFile import androidx.compose.material.icons.rounded.Cloud import androidx.compose.material.icons.rounded.Favorite -import androidx.compose.material.icons.rounded.Search import androidx.compose.material3.Button import androidx.compose.material3.Checkbox import androidx.compose.material3.FilterChip @@ -48,12 +46,10 @@ import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.LargeExtendedFloatingActionButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.PrimaryTabRow import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextFieldDefaults import com.theveloper.pixelplay.data.model.StorageFilter import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable @@ -337,7 +333,7 @@ fun SongPickerSelectionPane( Column( modifier = modifier.fillMaxSize() ) { - SongPickerSearchField( + SearchFilterTextField( searchQuery = searchQuery, onSearchQueryChange = { searchQuery = it } ) @@ -430,44 +426,6 @@ fun SongPickerSelectionPane( } } -@Composable -private fun SongPickerSearchField( - searchQuery: String, - onSearchQueryChange: (String) -> Unit, - modifier: Modifier = Modifier -) { - OutlinedTextField( - value = searchQuery, - colors = TextFieldDefaults.colors( - focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - disabledIndicatorColor = Color.Transparent, - unfocusedTrailingIconColor = Color.Transparent, - focusedSupportingTextColor = Color.Transparent, - ), - onValueChange = onSearchQueryChange, - label = { Text(stringResource(R.string.song_picker_search_label)) }, - modifier = modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - shape = CircleShape, - singleLine = true, - leadingIcon = { - Icon(Icons.Rounded.Search, contentDescription = null) - }, - trailingIcon = { - if (searchQuery.isNotEmpty()) { - IconButton(onClick = { onSearchQueryChange("") }) { - Icon(Icons.Filled.Clear, null) - } - } - } - ) -} - @OptIn(UnstableApi::class) @Composable fun SongPickerPagingList( From 18c65dd5794eacf5b05c5f29fc75810c6c721eeb Mon Sep 17 00:00:00 2001 From: PonceGL Date: Tue, 21 Jul 2026 19:02:34 -0600 Subject: [PATCH 2/7] feat(playlist): add title/artist song matcher util Pure, testable predicate for filtering a song by title or artist, using the same field scope as the existing global song search. Will back the upcoming in-playlist search filter. --- .../pixelplay/utils/SongFilterUtils.kt | 13 ++++ .../pixelplay/utils/SongFilterUtilsTest.kt | 66 +++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 app/src/main/java/com/theveloper/pixelplay/utils/SongFilterUtils.kt create mode 100644 app/src/test/java/com/theveloper/pixelplay/utils/SongFilterUtilsTest.kt diff --git a/app/src/main/java/com/theveloper/pixelplay/utils/SongFilterUtils.kt b/app/src/main/java/com/theveloper/pixelplay/utils/SongFilterUtils.kt new file mode 100644 index 0000000000..0982db8807 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/utils/SongFilterUtils.kt @@ -0,0 +1,13 @@ +package com.theveloper.pixelplay.utils + +import com.theveloper.pixelplay.data.model.Song + +/** + * True when [query] is found in this song's title or artist, case-insensitively. + * Mirrors the field scope used by the global song search (title + artist). + * A blank query always matches, so callers can filter unconditionally. + */ +fun Song.matchesTitleOrArtist(query: String): Boolean { + if (query.isBlank()) return true + return title.contains(query, ignoreCase = true) || artist.contains(query, ignoreCase = true) +} diff --git a/app/src/test/java/com/theveloper/pixelplay/utils/SongFilterUtilsTest.kt b/app/src/test/java/com/theveloper/pixelplay/utils/SongFilterUtilsTest.kt new file mode 100644 index 0000000000..90c73c16ff --- /dev/null +++ b/app/src/test/java/com/theveloper/pixelplay/utils/SongFilterUtilsTest.kt @@ -0,0 +1,66 @@ +package com.theveloper.pixelplay.utils + +import com.theveloper.pixelplay.data.model.Song +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class SongFilterUtilsTest { + + @Test + fun matchesTitleOrArtist_blankQuery_alwaysMatches() { + val song = buildSong(title = "Anything", artist = "Anyone") + + assertTrue(song.matchesTitleOrArtist("")) + assertTrue(song.matchesTitleOrArtist(" ")) + } + + @Test + fun matchesTitleOrArtist_matchesTitleCaseInsensitively() { + val song = buildSong(title = "Bohemian Rhapsody", artist = "Queen") + + assertTrue(song.matchesTitleOrArtist("rhapsody")) + assertTrue(song.matchesTitleOrArtist("BOHEMIAN")) + } + + @Test + fun matchesTitleOrArtist_matchesArtistCaseInsensitively() { + val song = buildSong(title = "Bohemian Rhapsody", artist = "Queen") + + assertTrue(song.matchesTitleOrArtist("que")) + } + + @Test + fun matchesTitleOrArtist_noMatch_returnsFalse() { + val song = buildSong(title = "Bohemian Rhapsody", artist = "Queen") + + assertFalse(song.matchesTitleOrArtist("metallica")) + } + + @Test + fun matchesTitleOrArtist_ignoresAlbumField() { + val song = buildSong(title = "Bohemian Rhapsody", artist = "Queen", album = "A Night at the Opera") + + assertFalse(song.matchesTitleOrArtist("opera")) + } + + private fun buildSong( + title: String, + artist: String, + album: String = "Album" + ): Song = Song( + id = "song-1", + title = title, + artist = artist, + artistId = 1L, + album = album, + albumId = 1L, + path = "/tmp/song-1.mp3", + contentUriString = "content://pixelplay/song/1", + albumArtUriString = null, + duration = 180_000L, + mimeType = "audio/mpeg", + bitrate = 320_000, + sampleRate = 44_100 + ) +} From 00f56c98245ec3240364596b1785b422382ffa14 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Tue, 21 Jul 2026 19:04:47 -0600 Subject: [PATCH 3/7] feat(playlist): add search filter to playlist detail screen Adds a persistent SearchFilterTextField above the song list that filters the currently displayed songs by title/artist, scoped to the songs already loaded for this playlist (local or streaming-sourced). Reorder mode is not yet guarded against an active filter; that lands in the next commit. --- .../screens/PlaylistDetailScreen.kt | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/PlaylistDetailScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/PlaylistDetailScreen.kt index a2b8c0c670..208abca7f0 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/PlaylistDetailScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/PlaylistDetailScreen.kt @@ -138,8 +138,10 @@ import racra.compose.smooth_corner_rect_library.AbsoluteSmoothCornerShape import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.rememberReorderableLazyListState import com.theveloper.pixelplay.presentation.components.LibrarySortBottomSheet +import com.theveloper.pixelplay.presentation.components.SearchFilterTextField import com.theveloper.pixelplay.data.model.SortOption import com.theveloper.pixelplay.data.model.PlaylistShapeType +import com.theveloper.pixelplay.utils.matchesTitleOrArtist import kotlinx.coroutines.launch @androidx.annotation.OptIn(UnstableApi::class) @@ -200,6 +202,7 @@ fun PlaylistDetailScreen( var showPlaylistOptionsSheet by remember { mutableStateOf(false) } var showEditPlaylistDialog by remember { mutableStateOf(false) } var showDeleteConfirmation by remember { mutableStateOf(false) } + var searchQuery by remember(playlistId) { mutableStateOf("") } val m3uExportLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.CreateDocument("audio/x-mpegurl") @@ -224,6 +227,13 @@ fun PlaylistDetailScreen( val bottomBarHeightDp = resolveNavBarOccupiedHeight(systemNavBarInset, navBarCompactMode) var showPlaylistBottomSheet by remember { mutableStateOf(false) } var localReorderableSongs by remember(songsInPlaylist) { mutableStateOf(songsInPlaylist) } + val displayedSongs = remember(localReorderableSongs, searchQuery) { + if (searchQuery.isBlank()) { + localReorderableSongs + } else { + localReorderableSongs.filter { it.matchesTitleOrArtist(searchQuery) } + } + } val listState = rememberLazyListState() val scope = rememberCoroutineScope() @@ -658,6 +668,13 @@ fun PlaylistDetailScreen( } } + if (localReorderableSongs.isNotEmpty()) { + SearchFilterTextField( + searchQuery = searchQuery, + onSearchQueryChange = { searchQuery = it } + ) + } + if (localReorderableSongs.isEmpty()) { Box(Modifier .fillMaxSize() @@ -710,7 +727,7 @@ fun PlaylistDetailScreen( } ) { itemsIndexed( - localReorderableSongs, + displayedSongs, key = { _, item -> item.id }, contentType = { _, _ -> "playlist_song" }) { _, song -> ReorderableItem( From 269b83119b4cfffdf3c019e037647bb682a338da Mon Sep 17 00:00:00 2001 From: PonceGL Date: Tue, 21 Jul 2026 19:09:05 -0600 Subject: [PATCH 4/7] feat(playlist): disable playlist actions while filtering Hides the Play/Shuffle/Add/Remove/Reorder action row while a search filter is active, and force-exits reorder/remove mode as soon as the user starts typing. This keeps drag-to-reorder (index-based) from ever operating on a filtered view of the playlist. Also adds a dedicated "no results for query" empty state, distinct from the existing "playlist has no songs" state. --- .../screens/PlaylistDetailScreen.kt | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/PlaylistDetailScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/PlaylistDetailScreen.kt index 208abca7f0..a0b0a4e62c 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/PlaylistDetailScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/PlaylistDetailScreen.kt @@ -50,6 +50,7 @@ import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.DragIndicator import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material.icons.rounded.Search import androidx.compose.material.icons.rounded.Shuffle import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button @@ -103,6 +104,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.layout.Layout +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.dp @@ -204,6 +206,13 @@ fun PlaylistDetailScreen( var showDeleteConfirmation by remember { mutableStateOf(false) } var searchQuery by remember(playlistId) { mutableStateOf("") } + LaunchedEffect(searchQuery.isNotBlank()) { + if (searchQuery.isNotBlank()) { + isReorderModeEnabled = false + isRemoveModeEnabled = false + } + } + val m3uExportLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.CreateDocument("audio/x-mpegurl") ) { uri -> @@ -364,6 +373,7 @@ fun PlaylistDetailScreen( ) { val actionButtonsHeight = 42.dp val playbackControlBottomPadding = if (isFolderPlaylist) 8.dp else 6.dp + if (searchQuery.isBlank()) { Row( modifier = Modifier .fillMaxWidth() @@ -667,6 +677,7 @@ fun PlaylistDetailScreen( } } } + } if (localReorderableSongs.isNotEmpty()) { SearchFilterTextField( @@ -691,6 +702,20 @@ fun PlaylistDetailScreen( Text(emptyMessage, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) } } + } else if (displayedSongs.isEmpty()) { + Box(Modifier + .fillMaxSize() + .weight(1f), Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon(Icons.Rounded.Search, null, Modifier.size(48.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.height(8.dp)) + Text( + stringResource(R.string.search_no_results_for_query, searchQuery), + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center + ) + } + } } else { Box( modifier = Modifier From 7a44866bed330c919ffe612e34ee20286ec9f579 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Wed, 5 Aug 2026 18:22:15 -0600 Subject: [PATCH 5/7] test(playlist): add filterByQuery to SongFilterUtils with list coverage Encapsulates the displayedSongs filtering block from PlaylistDetailScreen into a pure, list-level utility so it's unit-testable in isolation, on top of the existing per-song matchesTitleOrArtist predicate. No functional change to PlaylistDetailScreen; same filtering behavior, now backed by 5 additional unit tests covering blank query, ordering, no-match, empty-list, and mixed title/artist matches. --- .../screens/PlaylistDetailScreen.kt | 8 +-- .../pixelplay/utils/SongFilterUtils.kt | 8 +++ .../pixelplay/utils/SongFilterUtilsTest.kt | 66 ++++++++++++++++++- 3 files changed, 74 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/PlaylistDetailScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/PlaylistDetailScreen.kt index a0b0a4e62c..9783bf6c7f 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/PlaylistDetailScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/PlaylistDetailScreen.kt @@ -143,7 +143,7 @@ import com.theveloper.pixelplay.presentation.components.LibrarySortBottomSheet import com.theveloper.pixelplay.presentation.components.SearchFilterTextField import com.theveloper.pixelplay.data.model.SortOption import com.theveloper.pixelplay.data.model.PlaylistShapeType -import com.theveloper.pixelplay.utils.matchesTitleOrArtist +import com.theveloper.pixelplay.utils.filterByQuery import kotlinx.coroutines.launch @androidx.annotation.OptIn(UnstableApi::class) @@ -237,11 +237,7 @@ fun PlaylistDetailScreen( var showPlaylistBottomSheet by remember { mutableStateOf(false) } var localReorderableSongs by remember(songsInPlaylist) { mutableStateOf(songsInPlaylist) } val displayedSongs = remember(localReorderableSongs, searchQuery) { - if (searchQuery.isBlank()) { - localReorderableSongs - } else { - localReorderableSongs.filter { it.matchesTitleOrArtist(searchQuery) } - } + localReorderableSongs.filterByQuery(searchQuery) } val listState = rememberLazyListState() diff --git a/app/src/main/java/com/theveloper/pixelplay/utils/SongFilterUtils.kt b/app/src/main/java/com/theveloper/pixelplay/utils/SongFilterUtils.kt index 0982db8807..cb1285d84c 100644 --- a/app/src/main/java/com/theveloper/pixelplay/utils/SongFilterUtils.kt +++ b/app/src/main/java/com/theveloper/pixelplay/utils/SongFilterUtils.kt @@ -11,3 +11,11 @@ fun Song.matchesTitleOrArtist(query: String): Boolean { if (query.isBlank()) return true return title.contains(query, ignoreCase = true) || artist.contains(query, ignoreCase = true) } + +/** + * Filters this list to songs matching [query] by title or artist, preserving order. + * A blank query returns this list unchanged (same reference), so callers relying on + * identity for `remember`/diffing keys don't do unnecessary work. + */ +fun List.filterByQuery(query: String): List = + if (query.isBlank()) this else filter { it.matchesTitleOrArtist(query) } diff --git a/app/src/test/java/com/theveloper/pixelplay/utils/SongFilterUtilsTest.kt b/app/src/test/java/com/theveloper/pixelplay/utils/SongFilterUtilsTest.kt index 90c73c16ff..acf4f5e9ef 100644 --- a/app/src/test/java/com/theveloper/pixelplay/utils/SongFilterUtilsTest.kt +++ b/app/src/test/java/com/theveloper/pixelplay/utils/SongFilterUtilsTest.kt @@ -1,7 +1,9 @@ package com.theveloper.pixelplay.utils import com.theveloper.pixelplay.data.model.Song +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame import org.junit.Assert.assertTrue import org.junit.Test @@ -44,12 +46,72 @@ class SongFilterUtilsTest { assertFalse(song.matchesTitleOrArtist("opera")) } + @Test + fun filterByQuery_blankQuery_returnsOriginalListUnfiltered() { + val songs = listOf( + buildSong(id = "song-1", title = "Bohemian Rhapsody", artist = "Queen"), + buildSong(id = "song-2", title = "Yesterday", artist = "The Beatles") + ) + + val result = songs.filterByQuery("") + + assertSame(songs, result) + } + + @Test + fun filterByQuery_returnsOnlyMatchingSongsPreservingOrder() { + val songs = listOf( + buildSong(id = "song-1", title = "Bohemian Rhapsody", artist = "Queen"), + buildSong(id = "song-2", title = "Yesterday", artist = "The Beatles"), + buildSong(id = "song-3", title = "Under Pressure", artist = "Queen"), + buildSong(id = "song-4", title = "Imagine", artist = "John Lennon") + ) + + val result = songs.filterByQuery("queen") + + assertEquals(listOf(songs[0], songs[2]), result) + } + + @Test + fun filterByQuery_noMatches_returnsEmptyList() { + val songs = listOf( + buildSong(id = "song-1", title = "Bohemian Rhapsody", artist = "Queen"), + buildSong(id = "song-2", title = "Yesterday", artist = "The Beatles") + ) + + val result = songs.filterByQuery("metallica") + + assertTrue(result.isEmpty()) + } + + @Test + fun filterByQuery_emptyInputList_returnsEmptyList() { + val result = emptyList().filterByQuery("queen") + + assertTrue(result.isEmpty()) + } + + @Test + fun filterByQuery_matchesAcrossTitleAndArtistMixed() { + val songs = listOf( + buildSong(id = "song-1", title = "Bohemian Rhapsody", artist = "Queen"), + buildSong(id = "song-2", title = "Yesterday", artist = "The Beatles") + ) + + val result = songs.filterByQuery("rhapsody") + val resultByArtist = songs.filterByQuery("beatles") + + assertEquals(listOf(songs[0]), result) + assertEquals(listOf(songs[1]), resultByArtist) + } + private fun buildSong( title: String, artist: String, - album: String = "Album" + album: String = "Album", + id: String = "song-1" ): Song = Song( - id = "song-1", + id = id, title = title, artist = artist, artistId = 1L, From 5dd2c734103249cea546be01c5238783801cafef Mon Sep 17 00:00:00 2001 From: PonceGL Date: Wed, 5 Aug 2026 19:06:33 -0600 Subject: [PATCH 6/7] test(playlist): add Compose instrumentation test for playlist search Exercises the real PlaylistDetailScreen composable end-to-end with relaxed mocks for PlaylistViewModel/PlayerViewModel, covering: - typing a query filters the visible songs - a non-matching query shows the "no results" empty state - clearing the query restores the full list and the actions row - the actions row (Play it/Shuffle/Add/Remove/Reorder) is hidden while a search query is active - reorder mode is force-disabled when search starts and stays disabled after clearing the query (not just hidden momentarily) - tapping a filtered song plays the FULL unfiltered playlist starting from that song (the highest-risk behavior of this feature) - an empty playlist never shows the search field Swaps androidTestImplementation from io.mockk:mockk to io.mockk:mockk-android, required for mocking concrete classes (PlaylistViewModel/PlayerViewModel) on-device; plain mockk only ships a JVM instrumentation agent that ART can't load. --- app/build.gradle.kts | 4 +- .../screens/PlaylistDetailScreenSearchTest.kt | 234 ++++++++++++++++++ gradle/libs.versions.toml | 1 + 3 files changed, 238 insertions(+), 1 deletion(-) create mode 100644 app/src/androidTest/java/com/theveloper/pixelplay/presentation/screens/PlaylistDetailScreenSearchTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9f2583065f..1aeb8770cb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -370,7 +370,9 @@ dependencies { androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.test.core) androidTestImplementation(libs.truth) - androidTestImplementation(libs.mockk) + // Android-specific artifact: plain io.mockk:mockk can't mock classes on ART + // (needs a JVM instrumentation agent that isn't available on-device). + androidTestImplementation(libs.mockk.android) androidTestImplementation(libs.worktesting) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.ui.test.junit4) diff --git a/app/src/androidTest/java/com/theveloper/pixelplay/presentation/screens/PlaylistDetailScreenSearchTest.kt b/app/src/androidTest/java/com/theveloper/pixelplay/presentation/screens/PlaylistDetailScreenSearchTest.kt new file mode 100644 index 0000000000..b145d98d58 --- /dev/null +++ b/app/src/androidTest/java/com/theveloper/pixelplay/presentation/screens/PlaylistDetailScreenSearchTest.kt @@ -0,0 +1,234 @@ +package com.theveloper.pixelplay.presentation.screens + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithContentDescription +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextClearance +import androidx.compose.ui.test.performTextInput +import androidx.navigation.compose.rememberNavController +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.theveloper.pixelplay.R +import com.theveloper.pixelplay.data.model.Playlist +import com.theveloper.pixelplay.data.model.Song +import com.theveloper.pixelplay.presentation.viewmodel.PlayerViewModel +import com.theveloper.pixelplay.presentation.viewmodel.PlaylistUiState +import com.theveloper.pixelplay.presentation.viewmodel.PlaylistViewModel +import com.theveloper.pixelplay.presentation.viewmodel.StablePlayerState +import com.theveloper.pixelplay.ui.theme.PixelPlayTheme +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Instrumented Compose coverage for the in-playlist search filter added to + * [PlaylistDetailScreen]. Exercises the real composable (no ViewModel is + * introduced for this feature; state lives in the composable itself) with + * relaxed mocks for [PlayerViewModel]/[PlaylistViewModel], following the + * same pattern already used for concrete-class mocking in this project's + * instrumented tests (see SyncWorkerTest). + */ +@RunWith(AndroidJUnit4::class) +class PlaylistDetailScreenSearchTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + private val songBohemianRhapsody = buildSong(id = "song-1", title = "Bohemian Rhapsody", artist = "Queen") + private val songYesterday = buildSong(id = "song-2", title = "Yesterday", artist = "The Beatles") + private val songUnderPressure = buildSong(id = "song-3", title = "Under Pressure", artist = "Queen") + private val songImagine = buildSong(id = "song-4", title = "Imagine", artist = "John Lennon") + + private val fakeSongs = listOf(songBohemianRhapsody, songYesterday, songUnderPressure, songImagine) + private val fakePlaylist = Playlist( + id = "playlist-1", + name = "Road Trip", + songIds = fakeSongs.map { it.id } + ) + + @Test + fun searchField_typingQuery_filtersVisibleSongs() { + setPlaylistDetailContent(songs = fakeSongs, playlist = fakePlaylist) + + composeTestRule.onNode(hasSetTextAction()).performTextInput("queen") + + composeTestRule.onNodeWithText("Bohemian Rhapsody").assertExists() + composeTestRule.onNodeWithText("Under Pressure").assertExists() + composeTestRule.onNodeWithText("Yesterday").assertDoesNotExist() + composeTestRule.onNodeWithText("Imagine").assertDoesNotExist() + } + + @Test + fun searchField_typingNonMatchingQuery_showsEmptyStateWithQuery() { + setPlaylistDetailContent(songs = fakeSongs, playlist = fakePlaylist) + + composeTestRule.onNode(hasSetTextAction()).performTextInput("zzz") + + val expectedEmptyState = composeTestRule.activity.getString(R.string.search_no_results_for_query, "zzz") + composeTestRule.onNodeWithText(expectedEmptyState).assertExists() + } + + @Test + fun searchField_clearingQuery_restoresFullListAndActionsRow() { + setPlaylistDetailContent(songs = fakeSongs, playlist = fakePlaylist) + // "Play it"/"Shuffle" are drawn by TightWrapText directly on a Canvas (no semantics + // text node), so the icon's content description is what's actually queryable here. + val playCd = composeTestRule.activity.getString(R.string.common_play) + + composeTestRule.onNode(hasSetTextAction()).performTextInput("queen") + composeTestRule.onNodeWithText("Yesterday").assertDoesNotExist() + composeTestRule.onNodeWithContentDescription(playCd).assertDoesNotExist() + + composeTestRule.onNode(hasSetTextAction()).performTextClearance() + + composeTestRule.onNodeWithText("Yesterday").assertExists() + composeTestRule.onNodeWithContentDescription(playCd).assertExists() + } + + @Test + fun actionsRow_hiddenWhileSearchQueryIsNotBlank() { + setPlaylistDetailContent(songs = fakeSongs, playlist = fakePlaylist) + val playCd = composeTestRule.activity.getString(R.string.common_play) + val shuffleCd = composeTestRule.activity.getString(R.string.common_shuffle) + + composeTestRule.onNodeWithContentDescription(playCd).assertExists() + composeTestRule.onNodeWithContentDescription(shuffleCd).assertExists() + + composeTestRule.onNode(hasSetTextAction()).performTextInput("xyz") + + composeTestRule.onNodeWithContentDescription(playCd).assertDoesNotExist() + composeTestRule.onNodeWithContentDescription(shuffleCd).assertDoesNotExist() + } + + @Test + fun reorderMode_disabledAutomaticallyWhenSearchStartsAndStaysDisabledAfterClearing() { + setPlaylistDetailContent(songs = fakeSongs, playlist = fakePlaylist) + val reorderSongsCd = composeTestRule.activity.getString(R.string.playlist_cd_reorder_songs) + val reorderLabel = composeTestRule.activity.getString(R.string.playlist_action_reorder_songs) + + // Only the toggle button itself exposes this content description before reorder mode is on. + assertReorderCdCount(reorderSongsCd, expectedCount = 1) + + composeTestRule.onNodeWithText(reorderLabel).performClick() + composeTestRule.waitForIdle() + // Toggle button + one drag handle per visible song. + assertReorderCdCount(reorderSongsCd, expectedCount = 1 + fakeSongs.size) + + composeTestRule.onNode(hasSetTextAction()).performTextInput("queen") + composeTestRule.waitForIdle() + // Actions row (and its toggle button) is hidden entirely while filtering. + assertReorderCdCount(reorderSongsCd, expectedCount = 0) + + composeTestRule.onNode(hasSetTextAction()).performTextClearance() + composeTestRule.waitForIdle() + // Reorder mode was force-disabled by the LaunchedEffect, not just hidden: only the + // toggle button reappears, the drag handles do not come back on their own. + assertReorderCdCount(reorderSongsCd, expectedCount = 1) + } + + @Test + fun clickingFilteredSong_playsFullPlaylistFromClickedSong() { + val playerViewModel = mockPlayerViewModel() + val playlistViewModel = mockPlaylistViewModel(fakeSongs, fakePlaylist) + setPlaylistDetailContent(playerViewModel = playerViewModel, playlistViewModel = playlistViewModel) + + // Filter down to a single song that isn't the first in the playlist. + composeTestRule.onNode(hasSetTextAction()).performTextInput("yesterday") + composeTestRule.onNodeWithText("Yesterday").performClick() + + verify(exactly = 1) { + playerViewModel.playSongs(fakeSongs, songYesterday, fakePlaylist.name, fakePlaylist.id) + } + } + + @Test + fun emptyPlaylist_doesNotShowSearchField() { + setPlaylistDetailContent(songs = emptyList(), playlist = fakePlaylist) + + val searchLabel = composeTestRule.activity.getString(R.string.song_picker_search_label) + composeTestRule.onNodeWithText(searchLabel).assertDoesNotExist() + } + + private fun assertReorderCdCount(contentDescription: String, expectedCount: Int) { + val actualCount = composeTestRule + .onAllNodesWithContentDescription(contentDescription) + .fetchSemanticsNodes() + .size + assert(actualCount == expectedCount) { + "Expected $expectedCount node(s) with content description \"$contentDescription\", found $actualCount" + } + } + + private fun setPlaylistDetailContent( + songs: List, + playlist: Playlist, + playerViewModel: PlayerViewModel = mockPlayerViewModel(), + playlistViewModel: PlaylistViewModel = mockPlaylistViewModel(songs, playlist) + ) { + setPlaylistDetailContent(playerViewModel = playerViewModel, playlistViewModel = playlistViewModel) + } + + private fun setPlaylistDetailContent( + playerViewModel: PlayerViewModel, + playlistViewModel: PlaylistViewModel + ) { + composeTestRule.setContent { + PixelPlayTheme { + PlaylistDetailScreen( + playlistId = fakePlaylist.id, + onBackClick = {}, + onDeletePlayListClick = {}, + playerViewModel = playerViewModel, + playlistViewModel = playlistViewModel, + navController = rememberNavController() + ) + } + } + } + + private fun mockPlaylistViewModel(songs: List, playlist: Playlist): PlaylistViewModel { + val viewModel = mockk(relaxed = true) + every { viewModel.uiState } returns MutableStateFlow( + PlaylistUiState(currentPlaylistDetails = playlist, currentPlaylistSongs = songs) + ) + return viewModel + } + + private fun mockPlayerViewModel(): PlayerViewModel { + val viewModel = mockk(relaxed = true) + every { viewModel.stablePlayerState } returns MutableStateFlow(StablePlayerState()) + every { viewModel.selectedSongForInfo } returns MutableStateFlow(null) + every { viewModel.favoriteSongIds } returns MutableStateFlow(emptySet()) + every { viewModel.navBarCompactMode } returns MutableStateFlow(false) + every { viewModel.isSortingSheetVisible } returns MutableStateFlow(false) + return viewModel + } + + private fun buildSong( + id: String, + title: String, + artist: String, + album: String = "Album" + ): Song = Song( + id = id, + title = title, + artist = artist, + artistId = 1L, + album = album, + albumId = 1L, + path = "/tmp/$id.mp3", + contentUriString = "content://pixelplay/song/$id", + albumArtUriString = null, + duration = 180_000L, + mimeType = "audio/mpeg", + bitrate = 320_000, + sampleRate = 44_100 + ) +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6196203923..d42caf9b62 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -233,6 +233,7 @@ junit-jupiter-api = { group = "org.junit.jupiter", name = "junit-jupiter-api", v junit-jupiter-engine = { group = "org.junit.jupiter", name = "junit-jupiter-engine", version.ref = "junitJupiter" } junit-vintage-engine = { group = "org.junit.vintage", name = "junit-vintage-engine", version.ref = "junitJupiter" } mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } +mockk-android = { group = "io.mockk", name = "mockk-android", version.ref = "mockk" } turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" } truth = { group = "com.google.truth", name = "truth", version.ref = "truth" } androidx-room-testing = { module = "androidx.room:room-testing", version.ref = "roomRuntime" } From ba9b3cc6a67d913db63600fb24893c5e2533ffbb Mon Sep 17 00:00:00 2001 From: PonceGL Date: Wed, 5 Aug 2026 20:35:56 -0600 Subject: [PATCH 7/7] fix(playlist): fold diacritics when filtering songs in a playlist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit matchesTitleOrArtist only ignored case, not accents, so searching "que gan" inside a playlist never matched a song titled "Qué ganas de...". Adds String.foldDiacritics() (NFD normalize + strip combining marks) in Extensions.kt as a general-purpose reusable utility, and uses it on both the query and the title/artist before comparing. Verified this is unrelated to the main library search: that path goes through SQLite FTS4 (MusicDao.searchSongsMatch) with the unicode61 tokenizer, which already folds diacritics by default — confirmed with a standalone sqlite3 repro (MATCH 'que* AND gan*' already finds 'Qué ganas de bailar' pre-existing, no code change needed there). --- .../theveloper/pixelplay/utils/Extensions.kt | 15 +++++++ .../pixelplay/utils/SongFilterUtils.kt | 10 +++-- .../pixelplay/utils/ExtensionsTest.kt | 45 +++++++++++++++++++ .../pixelplay/utils/SongFilterUtilsTest.kt | 21 +++++++++ 4 files changed, 87 insertions(+), 4 deletions(-) create mode 100644 app/src/test/java/com/theveloper/pixelplay/utils/ExtensionsTest.kt diff --git a/app/src/main/java/com/theveloper/pixelplay/utils/Extensions.kt b/app/src/main/java/com/theveloper/pixelplay/utils/Extensions.kt index 53209450a9..37b325d89c 100644 --- a/app/src/main/java/com/theveloper/pixelplay/utils/Extensions.kt +++ b/app/src/main/java/com/theveloper/pixelplay/utils/Extensions.kt @@ -6,11 +6,26 @@ import java.nio.charset.Charset import java.text.Normalizer private val WINDOWS_1252: Charset = Charset.forName("windows-1252") +private val COMBINING_DIACRITICAL_MARKS = Regex("\\p{Mn}+") fun Color.toHexString(): String { return String.format("#%08X", this.toArgb()) } +/** + * Strips diacritics (accents, tildes, etc.) so accent-insensitive matching can be done + * with a plain [String.contains]. Decomposes to NFD (splitting each accented character + * into its base letter + combining mark, e.g. "é" -> "e" + U+0301) then drops every + * combining mark (Unicode category Mn). Case is untouched — combine with `ignoreCase` + * at the call site if needed. + * + * Example: "Qué ganas".foldDiacritics() == "Que ganas" + */ +fun String.foldDiacritics(): String { + val decomposed = Normalizer.normalize(this, Normalizer.Form.NFD) + return COMBINING_DIACRITICAL_MARKS.replace(decomposed, "") +} + /** * Attempts to fix incorrectly encoded metadata strings that frequently appear when * tags are saved using Windows-1252/ISO-8859-1 but are later read as UTF-8. This results diff --git a/app/src/main/java/com/theveloper/pixelplay/utils/SongFilterUtils.kt b/app/src/main/java/com/theveloper/pixelplay/utils/SongFilterUtils.kt index cb1285d84c..99a286fb54 100644 --- a/app/src/main/java/com/theveloper/pixelplay/utils/SongFilterUtils.kt +++ b/app/src/main/java/com/theveloper/pixelplay/utils/SongFilterUtils.kt @@ -3,13 +3,15 @@ package com.theveloper.pixelplay.utils import com.theveloper.pixelplay.data.model.Song /** - * True when [query] is found in this song's title or artist, case-insensitively. - * Mirrors the field scope used by the global song search (title + artist). - * A blank query always matches, so callers can filter unconditionally. + * True when [query] is found in this song's title or artist, case- and accent-insensitively + * (e.g. "que gan" matches "Qué ganas..."). Mirrors the field scope used by the global song + * search (title + artist). A blank query always matches, so callers can filter unconditionally. */ fun Song.matchesTitleOrArtist(query: String): Boolean { if (query.isBlank()) return true - return title.contains(query, ignoreCase = true) || artist.contains(query, ignoreCase = true) + val foldedQuery = query.foldDiacritics() + return title.foldDiacritics().contains(foldedQuery, ignoreCase = true) || + artist.foldDiacritics().contains(foldedQuery, ignoreCase = true) } /** diff --git a/app/src/test/java/com/theveloper/pixelplay/utils/ExtensionsTest.kt b/app/src/test/java/com/theveloper/pixelplay/utils/ExtensionsTest.kt new file mode 100644 index 0000000000..ace182e297 --- /dev/null +++ b/app/src/test/java/com/theveloper/pixelplay/utils/ExtensionsTest.kt @@ -0,0 +1,45 @@ +package com.theveloper.pixelplay.utils + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ExtensionsTest { + + @Test + fun foldDiacritics_stripsSpanishAccentsAndTilde() { + assertEquals("Que ganas de bailar", "Qué ganas de bailar".foldDiacritics()) + assertEquals("Manana", "Mañana".foldDiacritics()) + assertEquals("Cancion", "Canción".foldDiacritics()) + } + + @Test + fun foldDiacritics_stripsAccentsAcrossOtherLatinLanguages() { + assertEquals("Deja vu", "Déjà vu".foldDiacritics()) + assertEquals("uber", "über".foldDiacritics()) + assertEquals("naive", "naïve".foldDiacritics()) + } + + @Test + fun foldDiacritics_stringWithoutDiacritics_isUnchanged() { + assertEquals("Bohemian Rhapsody", "Bohemian Rhapsody".foldDiacritics()) + } + + @Test + fun foldDiacritics_emptyString_returnsEmptyString() { + assertEquals("", "".foldDiacritics()) + } + + @Test + fun foldDiacritics_preservesCase() { + // Folding removes marks only; it does not lowercase — callers combine with + // ignoreCase separately (see SongFilterUtils.matchesTitleOrArtist). + assertEquals("QUE", "QUÉ".foldDiacritics()) + assertEquals("que", "qué".foldDiacritics()) + } + + @Test + fun foldDiacritics_nonLatinScript_isUnaffected() { + // No combining marks to strip; the string round-trips unchanged. + assertEquals("こんにちは", "こんにちは".foldDiacritics()) + } +} diff --git a/app/src/test/java/com/theveloper/pixelplay/utils/SongFilterUtilsTest.kt b/app/src/test/java/com/theveloper/pixelplay/utils/SongFilterUtilsTest.kt index acf4f5e9ef..9ee736ae21 100644 --- a/app/src/test/java/com/theveloper/pixelplay/utils/SongFilterUtilsTest.kt +++ b/app/src/test/java/com/theveloper/pixelplay/utils/SongFilterUtilsTest.kt @@ -46,6 +46,27 @@ class SongFilterUtilsTest { assertFalse(song.matchesTitleOrArtist("opera")) } + @Test + fun matchesTitleOrArtist_matchesAccentedTitleWithUnaccentedQuery() { + val song = buildSong(title = "Qué ganas de bailar", artist = "Some Artist") + + assertTrue(song.matchesTitleOrArtist("que gan")) + } + + @Test + fun matchesTitleOrArtist_matchesUnaccentedTitleWithAccentedQuery() { + val song = buildSong(title = "Cancion sin nombre", artist = "Some Artist") + + assertTrue(song.matchesTitleOrArtist("canción")) + } + + @Test + fun matchesTitleOrArtist_matchesAccentedArtist() { + val song = buildSong(title = "Some Title", artist = "Café Tacvba") + + assertTrue(song.matchesTitleOrArtist("cafe tacvba")) + } + @Test fun filterByQuery_blankQuery_returnsOriginalListUnfiltered() { val songs = listOf(