diff --git a/modules/services/localization/src/main/res/values/strings.xml b/modules/services/localization/src/main/res/values/strings.xml index 7fa1f41ab94..75fc752b16c 100644 --- a/modules/services/localization/src/main/res/values/strings.xml +++ b/modules/services/localization/src/main/res/values/strings.xml @@ -2883,6 +2883,7 @@ Top Results @string/podcasts @string/episodes + @string/folders View all for \"%1$s\" No Results Try a new search, or by adding a podcast feed URL diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchFilters.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchFilters.kt index 716454f8d65..8ada47c3bb4 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchFilters.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchFilters.kt @@ -38,6 +38,7 @@ internal fun TvSearchFilters( selected: TvSearchFilter, onFilterSelect: (TvSearchFilter) -> Unit, modifier: Modifier = Modifier, + filters: List = TvSearchFilter.entries, upFocusRequester: FocusRequester? = null, ) { Row( @@ -47,6 +48,7 @@ internal fun TvSearchFilters( FilterDivider(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.width(20.dp)) TvSearchFilterPills( + filters = filters, selected = selected, onFilterSelect = onFilterSelect, upFocusRequester = upFocusRequester, @@ -58,11 +60,12 @@ internal fun TvSearchFilters( @Composable private fun TvSearchFilterPills( + filters: List, selected: TvSearchFilter, onFilterSelect: (TvSearchFilter) -> Unit, upFocusRequester: FocusRequester? = null, ) { - val selectedIndex = TvSearchFilter.entries.indexOf(selected) + val selectedIndex = filters.indexOf(selected) val focusRequester = remember { FocusRequester() } Box( modifier = Modifier @@ -86,7 +89,7 @@ private fun TvSearchFilterPills( } }, ) { - TvSearchFilter.entries.forEachIndexed { index, filter -> + filters.forEachIndexed { index, filter -> Tab( selected = index == selectedIndex, onFocus = { onFilterSelect(filter) }, diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchScreen.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchScreen.kt index 4d022cf5c01..1447a76441c 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchScreen.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchScreen.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.withFrameNanos @@ -51,6 +52,7 @@ import au.com.shiftyjelly.pocketcasts.component.TvEmptyState import au.com.shiftyjelly.pocketcasts.component.TvEpisodeActionContext import au.com.shiftyjelly.pocketcasts.component.TvEpisodeActionsModal import au.com.shiftyjelly.pocketcasts.component.TvEpisodeInfoModal +import au.com.shiftyjelly.pocketcasts.component.TvFolderCard import au.com.shiftyjelly.pocketcasts.component.TvPodcastGridScaffold import au.com.shiftyjelly.pocketcasts.component.TvPodcastTile import au.com.shiftyjelly.pocketcasts.component.TvPodcastTileDefaults @@ -63,8 +65,13 @@ import au.com.shiftyjelly.pocketcasts.discover.TvDiscoverRow import au.com.shiftyjelly.pocketcasts.discover.TvOpenedCategory import au.com.shiftyjelly.pocketcasts.discover.TvOpenedCategorySaver import au.com.shiftyjelly.pocketcasts.discover.tvDiscoverRow +import au.com.shiftyjelly.pocketcasts.models.entity.Folder +import au.com.shiftyjelly.pocketcasts.models.entity.Podcast import au.com.shiftyjelly.pocketcasts.models.entity.PodcastEpisode +import au.com.shiftyjelly.pocketcasts.models.to.FolderItem import au.com.shiftyjelly.pocketcasts.models.to.ImprovedSearchResultItem +import au.com.shiftyjelly.pocketcasts.models.type.PodcastsSortType +import au.com.shiftyjelly.pocketcasts.podcasts.TvFolderDetailScreen import au.com.shiftyjelly.pocketcasts.podcasts.TvPodcastDetailsScreen import au.com.shiftyjelly.pocketcasts.repositories.images.PodcastImage import au.com.shiftyjelly.pocketcasts.servers.model.DiscoverCategory @@ -80,9 +87,17 @@ import au.com.shiftyjelly.pocketcasts.localization.R as LR private val ContentHorizontalPadding = 48.dp private val ContentPadding = PaddingValues(horizontal = ContentHorizontalPadding) private const val SEARCH_ROW_LIMIT = 10 +private const val FOLDER_COVER_COUNT = 4 private val SearchEpisodeCardWidth = 360.dp private const val EPISODE_GRID_COLUMNS = 2 +private data class SearchOpenedFolder(val uuid: String, val name: String) + +private val SearchOpenedFolderSaver = listSaver( + save = { folder -> folder?.let { listOf(it.uuid, it.name) } ?: emptyList() }, + restore = { saved -> saved.takeIf { it.size == 2 }?.let { (uuid, name) -> SearchOpenedFolder(uuid, name) } }, +) + @Composable fun TvSearchScreen( modifier: Modifier = Modifier, @@ -91,6 +106,7 @@ fun TvSearchScreen( val query by viewModel.query.collectAsStateWithLifecycle() val searchState by viewModel.searchState.collectAsStateWithLifecycle() val filter by viewModel.filter.collectAsStateWithLifecycle() + val hasFolderResults by viewModel.hasFolderResults.collectAsStateWithLifecycle() val categories by viewModel.categories.collectAsStateWithLifecycle() val discoverRows by viewModel.discoverRows.collectAsStateWithLifecycle() val suggestions by viewModel.suggestions.collectAsStateWithLifecycle() @@ -99,11 +115,14 @@ fun TvSearchScreen( var openedPodcastUuid by rememberSaveable { mutableStateOf(null) } var openedCategory by rememberSaveable(stateSaver = TvOpenedCategorySaver) { mutableStateOf(null) } + var openedFolder by rememberSaveable(stateSaver = SearchOpenedFolderSaver) { mutableStateOf(null) } var detailsEpisode by remember { mutableStateOf(null) } var restoreFocusTrigger by remember { mutableIntStateOf(0) } var categoryRestoreTrigger by remember { mutableIntStateOf(0) } + var folderRestoreTrigger by remember { mutableIntStateOf(0) } val podcastUuid = openedPodcastUuid val category = openedCategory + val folder = openedFolder val openNowPlaying = LocalOpenNowPlaying.current val toastHostState = LocalTvToastHostState.current @@ -120,11 +139,13 @@ fun TvSearchScreen( query = query, searchState = searchState, filter = filter, + hasFolderResults = hasFolderResults, categories = categories, discoverRows = discoverRows, onQueryChange = viewModel::onQueryChange, onFilterSelect = viewModel::onFilterSelected, onOpenPodcast = { openedPodcastUuid = it }, + onOpenFolder = { openedFolder = SearchOpenedFolder(it.folder.uuid, it.folder.name) }, onOpenCategory = { openedCategory = TvOpenedCategory(it.id, it.name, it.source) }, onPlayEpisode = viewModel::playEpisode, onOpenEpisodeActions = viewModel::openEpisodeActions, @@ -140,8 +161,24 @@ fun TvSearchScreen( modifier = Modifier .fillMaxSize() .padding(top = TvTopBarHeight) - .tvFocusInactiveWhen(podcastUuid != null || category != null), + .tvFocusInactiveWhen(podcastUuid != null || category != null || folder != null), ) + TvDetailOverlay( + target = folder, + onBack = { openedFolder = null }, + modifier = Modifier.tvFocusInactiveWhen(podcastUuid != null), + onHide = { restoreFocusTrigger++ }, + ) { openFolder -> + TvFolderDetailScreen( + folderUuid = openFolder.uuid, + folderName = openFolder.name, + getFolderPodcasts = viewModel::folderPodcasts, + onOpenPodcast = { openedPodcastUuid = it }, + onClose = { openedFolder = null }, + onFolderImpression = {}, + restoreFocusTrigger = folderRestoreTrigger, + ) + } TvDetailOverlay( target = category, onBack = { openedCategory = null }, @@ -160,7 +197,13 @@ fun TvSearchScreen( TvDetailOverlay( target = podcastUuid, onBack = { openedPodcastUuid = null }, - onHide = { if (openedCategory != null) categoryRestoreTrigger++ else restoreFocusTrigger++ }, + onHide = { + when { + openedCategory != null -> categoryRestoreTrigger++ + openedFolder != null -> folderRestoreTrigger++ + else -> restoreFocusTrigger++ + } + }, ) { uuid -> TvPodcastDetailsScreen( podcastUuid = uuid, @@ -197,11 +240,13 @@ private fun TvSearchContent( query: String, searchState: TvSearchState, filter: TvSearchFilter, + hasFolderResults: Boolean, categories: List, discoverRows: List, onQueryChange: (String) -> Unit, onFilterSelect: (TvSearchFilter) -> Unit, onOpenPodcast: (String) -> Unit, + onOpenFolder: (FolderItem.Folder) -> Unit, onOpenCategory: (DiscoverCategory) -> Unit, onPlayEpisode: (ImprovedSearchResultItem.EpisodeItem) -> Unit, onOpenEpisodeActions: (ImprovedSearchResultItem.EpisodeItem) -> Unit, @@ -249,10 +294,14 @@ private fun TvSearchContent( Spacer(modifier = Modifier.height(24.dp)) } + val filters = remember(hasFolderResults) { TvSearchFilter.entries.filter { it != TvSearchFilter.Folders || hasFolderResults } } + val effectiveFilter = if (filter in filters) filter else TvSearchFilter.TopResults + if (searchState !is TvSearchState.Idle) { TvSearchFilters( - selected = filter, + selected = effectiveFilter, onFilterSelect = onFilterSelect, + filters = filters, modifier = Modifier.padding(ContentPadding), upFocusRequester = searchFieldFocusRequester, ) @@ -291,9 +340,10 @@ private fun TvSearchContent( is TvSearchState.Results -> TvSearchResults( results = searchState, - filter = filter, + filter = effectiveFilter, searchTerm = query.trim(), onOpenPodcast = onOpenPodcast, + onOpenFolder = onOpenFolder, onPlayEpisode = onPlayEpisode, onOpenEpisodeActions = onOpenEpisodeActions, restoreFocusTrigger = restoreFocusTrigger, @@ -417,6 +467,7 @@ private fun TvSearchResults( filter: TvSearchFilter, searchTerm: String, onOpenPodcast: (String) -> Unit, + onOpenFolder: (FolderItem.Folder) -> Unit, onPlayEpisode: (ImprovedSearchResultItem.EpisodeItem) -> Unit, onOpenEpisodeActions: (ImprovedSearchResultItem.EpisodeItem) -> Unit, restoreFocusTrigger: Int, @@ -425,7 +476,9 @@ private fun TvSearchResults( TvSearchFilter.TopResults -> TvSearchTopResults( podcasts = results.podcasts, episodes = results.episodes, + folders = results.folders, onOpenPodcast = onOpenPodcast, + onOpenFolder = onOpenFolder, onPlayEpisode = onPlayEpisode, onOpenEpisodeActions = onOpenEpisodeActions, restoreFocusTrigger = restoreFocusTrigger, @@ -475,6 +528,32 @@ private fun TvSearchResults( restoreFocusTrigger = restoreFocusTrigger, ) } + + TvSearchFilter.Folders -> if (results.folders.isEmpty()) { + if (results.isPartial) { + TvSearchLoading() + } else { + TvSearchMessage( + title = stringResource(LR.string.tv_search_no_results_for_title, searchTerm), + subtitle = stringResource(LR.string.tv_search_no_results_subtitle), + ) + } + } else { + TvPodcastGridScaffold( + itemKeys = results.folders.map { it.folder.uuid }, + modifier = Modifier.fillMaxSize(), + horizontalContentPadding = ContentHorizontalPadding, + restoreFocusTrigger = restoreFocusTrigger, + ) { index, itemModifier -> + val folderItem = results.folders[index] + TvFolderCard( + folder = folderItem.folder, + coverUrls = folderItem.podcasts.take(FOLDER_COVER_COUNT).map { PodcastImage.getMediumArtworkUrl(it.uuid) }, + onClick = { onOpenFolder(folderItem) }, + modifier = itemModifier, + ) + } + } } } @@ -482,7 +561,9 @@ private fun TvSearchResults( private fun TvSearchTopResults( podcasts: List, episodes: List, + folders: List, onOpenPodcast: (String) -> Unit, + onOpenFolder: (FolderItem.Folder) -> Unit, onPlayEpisode: (ImprovedSearchResultItem.EpisodeItem) -> Unit, onOpenEpisodeActions: (ImprovedSearchResultItem.EpisodeItem) -> Unit, restoreFocusTrigger: Int, @@ -499,10 +580,12 @@ private fun TvSearchTopResults( val featured = episodes.filter { it.hasVideo }.take(SEARCH_ROW_LIMIT) val otherEpisodes = episodes.filterNot { it.hasVideo }.take(SEARCH_ROW_LIMIT) + val topFolders = folders.take(SEARCH_ROW_LIMIT) val topPodcasts = podcasts.take(SEARCH_ROW_LIMIT) val featuredFirst = featured.isNotEmpty() val episodesFirst = !featuredFirst && otherEpisodes.isNotEmpty() - val podcastsFirst = !featuredFirst && !episodesFirst + val foldersFirst = !featuredFirst && !episodesFirst && topFolders.isNotEmpty() + val podcastsFirst = !featuredFirst && !episodesFirst && !foldersFirst LazyColumn(modifier = Modifier.fillMaxSize()) { item { Spacer(modifier = Modifier.height(8.dp)) } @@ -529,6 +612,14 @@ private fun TvSearchTopResults( ) } } + if (topFolders.isNotEmpty()) { + item { Spacer(modifier = Modifier.height(24.dp)) } + tvSearchFoldersRow( + folders = topFolders, + onOpenFolder = onOpenFolder, + focusRequester = restoreFocusRequester.takeIf { foldersFirst }, + ) + } if (topPodcasts.isNotEmpty()) { item { Spacer(modifier = Modifier.height(24.dp)) } tvSearchPodcastsRow( @@ -541,6 +632,29 @@ private fun TvSearchTopResults( } } +private fun LazyListScope.tvSearchFoldersRow( + folders: List, + onOpenFolder: (FolderItem.Folder) -> Unit, + focusRequester: FocusRequester?, +) { + item { + TvRow( + title = stringResource(LR.string.folders), + items = folders, + contentPadding = ContentPadding, + key = { it.folder.uuid }, + focusRequester = focusRequester, + ) { folderItem -> + TvFolderCard( + folder = folderItem.folder, + coverUrls = folderItem.podcasts.take(FOLDER_COVER_COUNT).map { PodcastImage.getMediumArtworkUrl(it.uuid) }, + onClick = { onOpenFolder(folderItem) }, + modifier = Modifier.width(TvPodcastTileDefaults.RowImageWidth), + ) + } + } +} + @Composable private fun TvSearchEpisodeCarousel( title: String, @@ -717,6 +831,7 @@ private fun TvSearchScreenPreview() { query = "", searchState = TvSearchState.Idle, filter = TvSearchFilter.TopResults, + hasFolderResults = false, categories = listOf( DiscoverCategory(id = 1, name = "Comedy", icon = "", source = ""), DiscoverCategory(id = 2, name = "True Crime", icon = "", source = ""), @@ -726,6 +841,7 @@ private fun TvSearchScreenPreview() { onQueryChange = {}, onFilterSelect = {}, onOpenPodcast = {}, + onOpenFolder = {}, onOpenCategory = {}, onPlayEpisode = {}, onOpenEpisodeActions = {}, @@ -743,6 +859,7 @@ private fun TvSearchIdleWithHistoryPreview() { query = "", searchState = TvSearchState.Idle, filter = TvSearchFilter.TopResults, + hasFolderResults = false, categories = emptyList(), discoverRows = emptyList(), onQueryChange = {}, @@ -751,6 +868,7 @@ private fun TvSearchIdleWithHistoryPreview() { onOpenCategory = {}, onPlayEpisode = {}, onOpenEpisodeActions = {}, + onOpenFolder = {}, history = listOf("Freakonomics", "Business Daily", "Science Weekly"), ) } @@ -784,8 +902,24 @@ private fun TvSearchResultsPreview() { hasVideo = it == 0, ) }, + folders = listOf( + FolderItem.Folder( + folder = Folder( + uuid = "folder-1", + name = "Business & Finance", + color = 3, + addedDate = Date(0), + sortPosition = 0, + podcastsSortType = PodcastsSortType.NAME_A_TO_Z, + deleted = false, + syncModified = 0, + ), + podcasts = List(4) { Podcast(uuid = "folder-podcast-$it") }, + ), + ), ), filter = TvSearchFilter.TopResults, + hasFolderResults = true, categories = emptyList(), discoverRows = emptyList(), onQueryChange = {}, @@ -794,6 +928,7 @@ private fun TvSearchResultsPreview() { onOpenCategory = {}, onPlayEpisode = {}, onOpenEpisodeActions = {}, + onOpenFolder = {}, ) } } diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchViewModel.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchViewModel.kt index 774cb9712f4..338196b5485 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchViewModel.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchViewModel.kt @@ -10,11 +10,14 @@ import au.com.shiftyjelly.pocketcasts.discover.TvDiscoverPodcast import au.com.shiftyjelly.pocketcasts.discover.TvDiscoverRow import au.com.shiftyjelly.pocketcasts.models.entity.Podcast import au.com.shiftyjelly.pocketcasts.models.entity.PodcastEpisode +import au.com.shiftyjelly.pocketcasts.models.to.FolderItem import au.com.shiftyjelly.pocketcasts.models.to.ImprovedSearchResultItem import au.com.shiftyjelly.pocketcasts.models.to.SearchAutoCompleteItem import au.com.shiftyjelly.pocketcasts.models.to.SearchHistoryEntry +import au.com.shiftyjelly.pocketcasts.models.type.PodcastsSortType import au.com.shiftyjelly.pocketcasts.repositories.playback.PlaybackManager import au.com.shiftyjelly.pocketcasts.repositories.podcast.EpisodeManager +import au.com.shiftyjelly.pocketcasts.repositories.podcast.FolderManager import au.com.shiftyjelly.pocketcasts.repositories.podcast.PodcastManager import au.com.shiftyjelly.pocketcasts.repositories.search.ImprovedSearchManager import au.com.shiftyjelly.pocketcasts.repositories.searchhistory.SearchHistoryManager @@ -47,6 +50,7 @@ class TvSearchViewModel @Inject constructor( private val episodeManager: EpisodeManager, private val playbackManager: PlaybackManager, private val searchHistoryManager: SearchHistoryManager, + private val folderManager: FolderManager, ) : ViewModel() { private val _categories = MutableStateFlow>(emptyList()) @@ -64,6 +68,9 @@ class TvSearchViewModel @Inject constructor( private val _filter = MutableStateFlow(TvSearchFilter.TopResults) val filter: StateFlow = _filter.asStateFlow() + private val _hasFolderResults = MutableStateFlow(false) + val hasFolderResults: StateFlow = _hasFolderResults.asStateFlow() + private val _suggestions = MutableStateFlow>(emptyList()) val suggestions: StateFlow> = _suggestions.asStateFlow() @@ -123,6 +130,7 @@ class TvSearchViewModel @Inject constructor( if (term.isEmpty()) { _suggestions.value = emptyList() _searchState.value = TvSearchState.Idle + updateFolderResults(hasFolders = false) return } searchJob = viewModelScope.launch { @@ -130,6 +138,16 @@ class TvSearchViewModel @Inject constructor( _searchState.value = TvSearchState.Searching _searchState.value = try { val fullSearch = async { runCatching { improvedSearchManager.combinedSearch(term) } } + val foldersSearch = async { + try { + searchFolders(term) + } catch (exception: CancellationException) { + throw exception + } catch (exception: Exception) { + Timber.e(exception, "Failed to search TV folders") + emptyList() + } + } val localPodcasts = podcastManager.findSubscribedFlow(term).first().map(Podcast::toSearchItem) val localUuids = localPodcasts.mapTo(HashSet(), ImprovedSearchResultItem.PodcastItem::uuid) val predictiveResults = try { @@ -156,15 +174,18 @@ class TvSearchViewModel @Inject constructor( .map { if (it.uuid in localUuids) it.copy(isFollowed = true) else it } val episodes = remoteResults.filterIsInstance() .distinctBy(ImprovedSearchResultItem.EpisodeItem::uuid) - if (podcasts.isEmpty() && episodes.isEmpty()) { + val folders = foldersSearch.await() + updateFolderResults(hasFolders = folders.isNotEmpty()) + if (podcasts.isEmpty() && episodes.isEmpty() && folders.isEmpty()) { TvSearchState.NoResults } else { - TvSearchState.Results(podcasts = podcasts, episodes = episodes) + TvSearchState.Results(podcasts = podcasts, episodes = episodes, folders = folders) } } catch (exception: CancellationException) { throw exception } catch (exception: Exception) { Timber.e(exception, "Failed to search on TV") + updateFolderResults(hasFolders = false) TvSearchState.Error } } @@ -174,6 +195,27 @@ class TvSearchViewModel @Inject constructor( _filter.value = filter } + private fun updateFolderResults(hasFolders: Boolean) { + _hasFolderResults.value = hasFolders + if (!hasFolders && _filter.value == TvSearchFilter.Folders) { + _filter.value = TvSearchFilter.TopResults + } + } + + private suspend fun searchFolders(term: String): List { + if (!syncManager.isLoggedIn()) { + return emptyList() + } + return folderManager.getAll() + .filter { it.name.contains(term, ignoreCase = true) } + .sortedBy { PodcastsSortType.cleanStringForSort(it.name) } + .map { folder -> FolderItem.Folder(folder = folder, podcasts = folderManager.findFolderPodcastsSorted(folder.uuid)) } + } + + suspend fun folderPodcasts(folderUuid: String): List { + return folderManager.findFolderPodcastsSorted(folderUuid) + } + fun saveSearchTerm(term: String) { val trimmed = term.trim() if (trimmed.isEmpty()) { @@ -279,6 +321,7 @@ enum class TvSearchFilter( TopResults(LR.string.search_filters_top_results), Podcasts(LR.string.search_filters_podcasts), Episodes(LR.string.search_filters_episodes), + Folders(LR.string.search_filters_folders), } sealed interface TvSearchState { @@ -289,6 +332,7 @@ sealed interface TvSearchState { data class Results( val podcasts: List, val episodes: List, + val folders: List = emptyList(), val isPartial: Boolean = false, ) : TvSearchState } diff --git a/tv/src/test/java/au/com/shiftyjelly/pocketcasts/search/TvSearchViewModelTest.kt b/tv/src/test/java/au/com/shiftyjelly/pocketcasts/search/TvSearchViewModelTest.kt index 307432c8575..fa508c22825 100644 --- a/tv/src/test/java/au/com/shiftyjelly/pocketcasts/search/TvSearchViewModelTest.kt +++ b/tv/src/test/java/au/com/shiftyjelly/pocketcasts/search/TvSearchViewModelTest.kt @@ -4,15 +4,18 @@ import android.content.Context import android.content.res.Resources import au.com.shiftyjelly.pocketcasts.discover.TvDiscoverFeedLoader import au.com.shiftyjelly.pocketcasts.discover.TvDiscoverRow +import au.com.shiftyjelly.pocketcasts.models.entity.Folder import au.com.shiftyjelly.pocketcasts.models.entity.Podcast import au.com.shiftyjelly.pocketcasts.models.to.ImprovedSearchResultItem import au.com.shiftyjelly.pocketcasts.models.to.SearchAutoCompleteItem import au.com.shiftyjelly.pocketcasts.models.to.SearchHistoryEntry +import au.com.shiftyjelly.pocketcasts.models.type.PodcastsSortType import au.com.shiftyjelly.pocketcasts.preferences.Settings import au.com.shiftyjelly.pocketcasts.preferences.UserSetting import au.com.shiftyjelly.pocketcasts.repositories.lists.ListRepository import au.com.shiftyjelly.pocketcasts.repositories.playback.PlaybackManager import au.com.shiftyjelly.pocketcasts.repositories.podcast.EpisodeManager +import au.com.shiftyjelly.pocketcasts.repositories.podcast.FolderManager import au.com.shiftyjelly.pocketcasts.repositories.podcast.PodcastManager import au.com.shiftyjelly.pocketcasts.repositories.search.ImprovedSearchManager import au.com.shiftyjelly.pocketcasts.repositories.searchhistory.SearchHistoryManager @@ -34,6 +37,7 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test @@ -73,6 +77,7 @@ class TvSearchViewModelTest { private val episodeManager = mock() private val playbackManager = mock() private val searchHistoryManager = mock() + private val folderManager = mock() init { whenever { improvedSearchManager.autoCompleteSearch(any()) }.thenReturn(emptyList()) @@ -452,6 +457,132 @@ class TvSearchViewModelTest { private fun subscribedPodcast(uuid: String) = Podcast(uuid = uuid, title = "Podcast $uuid", author = "Author") + @Test + fun `matching folders are surfaced for a signed-in user`() = runTest { + whenever(listRepository.getSearchDiscoverFeed()).thenReturn(discover()) + whenever { improvedSearchManager.combinedSearch(any()) }.thenReturn(emptyList()) + whenever(syncManager.isLoggedIn()).thenReturn(true) + whenever { folderManager.getAll() }.thenReturn(listOf(folderEntity("Sugar Shows"), folderEntity("Comedy"))) + whenever { folderManager.findFolderPodcastsSorted(any()) }.thenReturn(emptyList()) + + val viewModel = createViewModel() + viewModel.onQueryChange("sugar") + advanceUntilIdle() + + val state = viewModel.searchState.value as TvSearchState.Results + assertEquals(listOf("Sugar Shows"), state.folders.map { it.folder.name }) + } + + @Test + fun `matching folders are sorted by name`() = runTest { + whenever(listRepository.getSearchDiscoverFeed()).thenReturn(discover()) + whenever { improvedSearchManager.combinedSearch(any()) }.thenReturn(emptyList()) + whenever(syncManager.isLoggedIn()).thenReturn(true) + whenever { folderManager.getAll() }.thenReturn(listOf(folderEntity("Sugar Rush"), folderEntity("Sugar Beats"))) + whenever { folderManager.findFolderPodcastsSorted(any()) }.thenReturn(emptyList()) + + val viewModel = createViewModel() + viewModel.onQueryChange("sugar") + advanceUntilIdle() + + val state = viewModel.searchState.value as TvSearchState.Results + assertEquals(listOf("Sugar Beats", "Sugar Rush"), state.folders.map { it.folder.name }) + } + + @Test + fun `folders are not searched for a signed-out user`() = runTest { + whenever(listRepository.getSearchDiscoverFeed()).thenReturn(discover()) + whenever { improvedSearchManager.combinedSearch(any()) }.thenReturn(listOf(podcastItem("podcast-1"))) + + val viewModel = createViewModel() + viewModel.onQueryChange("sugar") + advanceUntilIdle() + + val state = viewModel.searchState.value as TvSearchState.Results + assertTrue(state.folders.isEmpty()) + verifyBlocking(folderManager, never()) { getAll() } + } + + @Test + fun `a matching folder alone counts as a result`() = runTest { + whenever(listRepository.getSearchDiscoverFeed()).thenReturn(discover()) + whenever { improvedSearchManager.combinedSearch(any()) }.thenReturn(emptyList()) + whenever(syncManager.isLoggedIn()).thenReturn(true) + whenever { folderManager.getAll() }.thenReturn(listOf(folderEntity("Sugar"))) + whenever { folderManager.findFolderPodcastsSorted(any()) }.thenReturn(emptyList()) + + val viewModel = createViewModel() + viewModel.onQueryChange("sugar") + advanceUntilIdle() + + assertTrue(viewModel.searchState.value is TvSearchState.Results) + } + + @Test + fun `a folder search failure does not fail the whole search`() = runTest { + whenever(listRepository.getSearchDiscoverFeed()).thenReturn(discover()) + whenever { improvedSearchManager.combinedSearch(any()) }.thenReturn(listOf(podcastItem("podcast-1"))) + whenever(syncManager.isLoggedIn()).thenReturn(true) + whenever { folderManager.getAll() }.thenThrow(RuntimeException("database unavailable")) + + val viewModel = createViewModel() + viewModel.onQueryChange("sugar") + advanceUntilIdle() + + val state = viewModel.searchState.value as TvSearchState.Results + assertEquals(listOf("podcast-1"), state.podcasts.map { it.uuid }) + assertTrue(state.folders.isEmpty()) + } + + @Test + fun `hasFolderResults reflects whether the terminal search found folders`() = runTest { + whenever(listRepository.getSearchDiscoverFeed()).thenReturn(discover()) + whenever { improvedSearchManager.combinedSearch(any()) }.thenReturn(emptyList()) + whenever(syncManager.isLoggedIn()).thenReturn(true) + whenever { folderManager.getAll() }.thenReturn(listOf(folderEntity("Sugar"))) + whenever { folderManager.findFolderPodcastsSorted(any()) }.thenReturn(emptyList()) + + val viewModel = createViewModel() + viewModel.onQueryChange("sugar") + advanceUntilIdle() + assertTrue(viewModel.hasFolderResults.value) + + viewModel.onQueryChange("") + assertFalse(viewModel.hasFolderResults.value) + } + + @Test + fun `a terminal search without folders resets the folders filter`() = runTest { + whenever(listRepository.getSearchDiscoverFeed()).thenReturn(discover()) + whenever { improvedSearchManager.combinedSearch(any()) }.thenReturn(listOf(podcastItem("podcast-1"))) + whenever(syncManager.isLoggedIn()).thenReturn(true) + whenever { folderManager.getAll() }.thenReturn(emptyList()) + + val viewModel = createViewModel() + viewModel.onFilterSelected(TvSearchFilter.Folders) + viewModel.onQueryChange("sugar") + advanceUntilIdle() + + assertEquals(TvSearchFilter.TopResults, viewModel.filter.value) + assertFalse(viewModel.hasFolderResults.value) + } + + @Test + fun `a failed search clears folder results and resets the folders filter`() = runTest { + whenever(listRepository.getSearchDiscoverFeed()).thenReturn(discover()) + whenever { improvedSearchManager.combinedSearch(any()) }.thenThrow(RuntimeException("network")) + whenever(syncManager.isLoggedIn()).thenReturn(true) + + val viewModel = createViewModel() + viewModel.onFilterSelected(TvSearchFilter.Folders) + viewModel.onQueryChange("sugar") + advanceUntilIdle() + + assertTrue(viewModel.searchState.value is TvSearchState.Error) + assertEquals(TvSearchFilter.TopResults, viewModel.filter.value) + assertFalse(viewModel.hasFolderResults.value) + } + private fun createViewModel() = TvSearchViewModel( discoverFeedLoader = TvDiscoverFeedLoader( listRepository = listRepository, @@ -464,6 +595,18 @@ class TvSearchViewModelTest { episodeManager = episodeManager, playbackManager = playbackManager, searchHistoryManager = searchHistoryManager, + folderManager = folderManager, + ) + + private fun folderEntity(name: String) = Folder( + uuid = name, + name = name, + color = 0, + addedDate = Date(0), + sortPosition = 0, + podcastsSortType = PodcastsSortType.NAME_A_TO_Z, + deleted = false, + syncModified = 0, ) private fun category(id: Int, name: String) = DiscoverCategory(id = id, name = name, icon = "", source = "")