diff --git a/app/src/androidTest/java/au/com/shiftyjelly/pocketcasts/models/db/BookmarkDaoTest.kt b/app/src/androidTest/java/au/com/shiftyjelly/pocketcasts/models/db/BookmarkDaoTest.kt index 128b1cf0360..a74a30eba80 100644 --- a/app/src/androidTest/java/au/com/shiftyjelly/pocketcasts/models/db/BookmarkDaoTest.kt +++ b/app/src/androidTest/java/au/com/shiftyjelly/pocketcasts/models/db/BookmarkDaoTest.kt @@ -5,7 +5,9 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.LargeTest import androidx.test.platform.app.InstrumentationRegistry import au.com.shiftyjelly.pocketcasts.models.db.dao.BookmarkDao +import au.com.shiftyjelly.pocketcasts.models.db.dao.EpisodeDao import au.com.shiftyjelly.pocketcasts.models.entity.Bookmark +import au.com.shiftyjelly.pocketcasts.models.entity.PodcastEpisode import au.com.shiftyjelly.pocketcasts.models.type.SyncStatus import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest @@ -22,6 +24,7 @@ import java.util.UUID @LargeTest class BookmarkDaoTest { + private lateinit var episodeDao: EpisodeDao private lateinit var bookmarkDao: BookmarkDao private lateinit var testDatabase: AppDatabase @@ -30,6 +33,7 @@ class BookmarkDaoTest { val context = InstrumentationRegistry.getInstrumentation().targetContext testDatabase = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java).build() bookmarkDao = testDatabase.bookmarkDao() + episodeDao = testDatabase.episodeDao() } @After @@ -94,8 +98,8 @@ class BookmarkDaoTest { bookmarkDao.insert(bookmark3) val result = bookmarkDao.findByEpisodeOrderCreatedAtFlow( - episodeUuid = episodeUuid, - podcastUuid = podcastUuid, + episodeUuid = defaultEpisodeUuid, + podcastUuid = defaultPodcastUuid, deleted = false, isAsc = true ).first() @@ -118,8 +122,8 @@ class BookmarkDaoTest { bookmarkDao.insert(bookmark3) val result = bookmarkDao.findByEpisodeOrderCreatedAtFlow( - episodeUuid = episodeUuid, - podcastUuid = podcastUuid, + episodeUuid = defaultEpisodeUuid, + podcastUuid = defaultPodcastUuid, deleted = false, isAsc = false ).first() @@ -142,8 +146,8 @@ class BookmarkDaoTest { bookmarkDao.insert(bookmark3) val result = bookmarkDao.findByEpisodeOrderTimeFlow( - episodeUuid = episodeUuid, - podcastUuid = podcastUuid, + episodeUuid = defaultEpisodeUuid, + podcastUuid = defaultPodcastUuid, deleted = false, ).first() @@ -154,24 +158,79 @@ class BookmarkDaoTest { } } + @Test + fun testSearchInPodcastByBookmarkTitle() = + runTest { + val podcastUuid = UUID.randomUUID().toString() + val searchTitle = "title" + val bookmark1 = FakeBookmarksGenerator.create(podcastUuid = podcastUuid, title = searchTitle, time = 100) + val bookmark2 = FakeBookmarksGenerator.create(podcastUuid = podcastUuid, time = 200) + bookmarkDao.insert(bookmark1) + bookmarkDao.insert(bookmark2) + + val episode = PodcastEpisode(uuid = defaultEpisodeUuid, podcastUuid = "1", isArchived = false, publishedDate = Date()) + episodeDao.insert(episode) + + val result = bookmarkDao.searchInPodcastByTitle( + title = searchTitle, + podcastUuid = podcastUuid, + deleted = false, + ) + + with(result) { + assert(size == 1) + assert(get(0).title == searchTitle) + } + } + + @Test + fun testSearchInPodcastByEpisodeTitle() = + runTest { + val bookmarkUuid = UUID.randomUUID().toString() + val podcastUuid = UUID.randomUUID().toString() + val episodeUuid = UUID.randomUUID().toString() + val searchTitle = "title" + val bookmark1 = FakeBookmarksGenerator.create(uuid = bookmarkUuid, episodeUuid = episodeUuid, podcastUuid = podcastUuid, time = 100) + val bookmark2 = FakeBookmarksGenerator.create(podcastUuid = podcastUuid, time = 200) + bookmarkDao.insert(bookmark1) + bookmarkDao.insert(bookmark2) + + val episode = PodcastEpisode(uuid = episodeUuid, podcastUuid = podcastUuid, title = searchTitle, isArchived = false, publishedDate = Date()) + episodeDao.insert(episode) + + val result = bookmarkDao.searchInPodcastByTitle( + title = searchTitle, + podcastUuid = podcastUuid, + deleted = false, + ) + + with(result) { + assert(size == 1) + assert(get(0).uuid == bookmarkUuid) + } + } + companion object { - private val episodeUuid = UUID.randomUUID().toString() - private val podcastUuid = UUID.randomUUID().toString() + private val defaultEpisodeUuid = UUID.randomUUID().toString() + private val defaultPodcastUuid = UUID.randomUUID().toString() object FakeBookmarksGenerator { fun create( uuid: String? = null, + title: String = "", + podcastUuid: String? = null, + episodeUuid: String? = null, time: Int = 10, createdAt: Date = Date(), ) = Bookmark( uuid = uuid ?: UUID.randomUUID().toString(), - episodeUuid = episodeUuid, - podcastUuid = podcastUuid, + episodeUuid = episodeUuid ?: defaultEpisodeUuid, + podcastUuid = podcastUuid ?: defaultPodcastUuid, timeSecs = time, createdAt = createdAt, deleted = false, syncStatus = SyncStatus.NOT_SYNCED, - title = "" + title = title ) } } diff --git a/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/helper/search/BookmarkSearchHandler.kt b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/helper/search/BookmarkSearchHandler.kt new file mode 100644 index 00000000000..ddb7b0e7e5c --- /dev/null +++ b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/helper/search/BookmarkSearchHandler.kt @@ -0,0 +1,29 @@ +package au.com.shiftyjelly.pocketcasts.podcasts.helper.search + +import au.com.shiftyjelly.pocketcasts.models.entity.Bookmark +import au.com.shiftyjelly.pocketcasts.repositories.bookmark.BookmarkManager +import io.reactivex.Observable +import kotlinx.coroutines.rx2.rxSingle +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class BookmarkSearchHandler @Inject constructor( + private val bookmarkManager: BookmarkManager, +) : SearchHandler() { + + override fun getSearchResultsObservable(podcastUuid: String): Observable = + searchQueryRelay.switchMapSingle { searchTerm -> + if (searchTerm.length > 1) { + rxSingle { bookmarkManager.searchInPodcastByTitle(podcastUuid, searchTerm) } + .map { SearchResult(searchTerm, it) } + .onErrorReturnItem(noSearchResult) + } else { + rxSingle { noSearchResult } + } + }.distinctUntilChanged() + + override fun trackSearchIfNeeded(oldValue: String, newValue: String) { + // TODO: Bookmark search tracking + } +} diff --git a/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/helper/search/EpisodeSearchHandler.kt b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/helper/search/EpisodeSearchHandler.kt new file mode 100644 index 00000000000..02a12c7f3f2 --- /dev/null +++ b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/helper/search/EpisodeSearchHandler.kt @@ -0,0 +1,46 @@ +package au.com.shiftyjelly.pocketcasts.podcasts.helper.search + +import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsEvent +import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsTrackerWrapper +import au.com.shiftyjelly.pocketcasts.models.entity.BaseEpisode +import au.com.shiftyjelly.pocketcasts.preferences.Settings +import au.com.shiftyjelly.pocketcasts.servers.podcast.PodcastCacheServerManagerImpl +import io.reactivex.Observable +import io.reactivex.Single +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class EpisodeSearchHandler @Inject constructor( + settings: Settings, + private val cacheServerManager: PodcastCacheServerManagerImpl, + private val analyticsTracker: AnalyticsTrackerWrapper, +) : SearchHandler() { + private val searchDebounce = settings.getEpisodeSearchDebounceMs() + + override fun getSearchResultsObservable(podcastUuid: String): Observable = + searchQueryRelay.debounce { // Only debounce when search has a value otherwise it slows down loading the pages + if (it.isEmpty()) { + Observable.empty() + } else { + Observable.timer(searchDebounce, TimeUnit.MILLISECONDS) + } + }.switchMapSingle { searchTerm -> + if (searchTerm.length > 2) { + cacheServerManager.searchEpisodes(podcastUuid, searchTerm) + .map { SearchResult(searchTerm, it) } + .onErrorReturnItem(noSearchResult) + } else { + Single.just(noSearchResult) + } + }.distinctUntilChanged() + + override fun trackSearchIfNeeded(oldValue: String, newValue: String) { + if (oldValue.isEmpty() && newValue.isNotEmpty()) { + analyticsTracker.track(AnalyticsEvent.PODCAST_SCREEN_SEARCH_PERFORMED) + } else if (oldValue.isNotEmpty() && newValue.isEmpty()) { + analyticsTracker.track(AnalyticsEvent.PODCAST_SCREEN_SEARCH_CLEARED) + } + } +} diff --git a/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/helper/search/SearchHandler.kt b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/helper/search/SearchHandler.kt new file mode 100644 index 00000000000..c4d7b4d0254 --- /dev/null +++ b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/helper/search/SearchHandler.kt @@ -0,0 +1,28 @@ +package au.com.shiftyjelly.pocketcasts.podcasts.helper.search + +import com.jakewharton.rxrelay2.BehaviorRelay +import io.reactivex.Observable + +abstract class SearchHandler { + private var searchTerm = "" + protected val searchQueryRelay = BehaviorRelay.create() + .apply { accept("") } + + protected val noSearchResult = SearchResult("", null) + + abstract fun getSearchResultsObservable(podcastUuid: String): Observable + + fun searchQueryUpdated(newValue: String) { + val oldValue = searchQueryRelay.value ?: "" + searchTerm = newValue + searchQueryRelay.accept(newValue) + trackSearchIfNeeded(oldValue, newValue) + } + + abstract fun trackSearchIfNeeded(oldValue: String, newValue: String) + + data class SearchResult( + val searchTerm: String, + val searchUuids: List?, + ) +} diff --git a/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/podcast/PodcastAdapter.kt b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/podcast/PodcastAdapter.kt index 8d55cdb2061..8b97605e849 100644 --- a/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/podcast/PodcastAdapter.kt +++ b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/podcast/PodcastAdapter.kt @@ -42,6 +42,7 @@ import au.com.shiftyjelly.pocketcasts.podcasts.databinding.AdapterEpisodeHeaderB import au.com.shiftyjelly.pocketcasts.podcasts.databinding.AdapterPodcastHeaderBinding import au.com.shiftyjelly.pocketcasts.podcasts.view.components.PlayButton import au.com.shiftyjelly.pocketcasts.podcasts.view.components.StarRatingView +import au.com.shiftyjelly.pocketcasts.podcasts.view.podcast.adapter.BookmarkHeaderViewHolder import au.com.shiftyjelly.pocketcasts.podcasts.view.podcast.adapter.BookmarkViewHolder import au.com.shiftyjelly.pocketcasts.podcasts.view.podcast.adapter.TabsViewHolder import au.com.shiftyjelly.pocketcasts.podcasts.viewmodel.PodcastRatingsViewModel @@ -73,6 +74,7 @@ private val differ: DiffUtil.ItemCallback = object : DiffUtil.ItemCallback< oldItem is PodcastAdapter.EpisodeHeader && newItem is PodcastAdapter.EpisodeHeader -> true oldItem is PodcastEpisode && newItem is PodcastEpisode -> oldItem.uuid == newItem.uuid oldItem is Bookmark && newItem is Bookmark -> oldItem.uuid == newItem.uuid + oldItem is PodcastAdapter.BookmarkHeader && newItem is PodcastAdapter.BookmarkHeader -> true oldItem is PodcastAdapter.DividerRow && newItem is PodcastAdapter.DividerRow -> oldItem.groupIndex == newItem.groupIndex else -> oldItem == newItem } @@ -91,6 +93,8 @@ private val differ: DiffUtil.ItemCallback = object : DiffUtil.ItemCallback< oldItem.showingArchived == newItem.showingArchived && oldItem.episodeLimit == newItem.episodeLimit ) + } else if (oldItem is PodcastAdapter.BookmarkHeader && newItem is PodcastAdapter.BookmarkHeader) { + return oldItem.bookmarksCount == newItem.bookmarksCount } return oldItem == newItem } @@ -132,10 +136,17 @@ class PodcastAdapter( val selectedTab: PodcastTab, val onTabClicked: (PodcastTab) -> Unit, ) + data class BookmarkHeader( + val bookmarksCount: Int, + val searchTerm: String, + val onSearchFocus: () -> Unit, + val onSearchQueryChanged: (String) -> Unit, + ) companion object { private const val VIEW_TYPE_TABS = 100 private const val VIEW_TYPE_BOOKMARKS = 101 + private const val VIEW_TYPE_BOOKMARK_HEADER = 102 val VIEW_TYPE_EPISODE_HEADER = R.layout.adapter_episode_header val VIEW_TYPE_PODCAST_HEADER = R.layout.adapter_podcast_header val VIEW_TYPE_EPISODE_LIMIT_ROW = R.layout.adapter_episode_limit @@ -169,6 +180,7 @@ class PodcastAdapter( VIEW_TYPE_NO_EPISODE -> NoEpisodesViewHolder(inflater.inflate(R.layout.adapter_no_episodes, parent, false)) VIEW_TYPE_DIVIDER -> DividerViewHolder(inflater.inflate(R.layout.adapter_divider_row, parent, false)) VIEW_TYPE_BOOKMARKS -> BookmarkViewHolder(ComposeView(parent.context), theme) + VIEW_TYPE_BOOKMARK_HEADER -> BookmarkHeaderViewHolder(ComposeView(parent.context), theme) else -> EpisodeViewHolder( binding = AdapterEpisodeBinding.inflate(inflater, parent, false), viewMode = EpisodeViewHolder.ViewMode.NoArtwork, @@ -190,6 +202,7 @@ class PodcastAdapter( is NoEpisodesViewHolder -> bindNoEpisodesMessage(holder, position) is DividerViewHolder -> bindDividerRow(holder, position) is BookmarkViewHolder -> holder.bind(getItem(position) as Bookmark, onBookmarkPlayClicked) + is BookmarkHeaderViewHolder -> holder.bind(getItem(position) as BookmarkHeader) } } @@ -388,12 +401,14 @@ class PodcastAdapter( fun setBookmarks( bookmarks: List, + searchTerm: String, ) { val content = mutableListOf().apply { add(Podcast()) if (FeatureFlag.isEnabled(Feature.BOOKMARKS_ENABLED)) { add(TabsHeader(PodcastTab.BOOKMARKS, onTabClicked)) } + add(BookmarkHeader(bookmarks.size, searchTerm, onSearchFocus, onSearchQueryChanged)) addAll(bookmarks) } submitList(content) @@ -413,6 +428,7 @@ class PodcastAdapter( is DividerRow -> R.layout.adapter_divider_row is TabsHeader -> VIEW_TYPE_TABS is Bookmark -> VIEW_TYPE_BOOKMARKS + is BookmarkHeader -> VIEW_TYPE_BOOKMARK_HEADER else -> R.layout.adapter_episode } } @@ -425,6 +441,7 @@ class PodcastAdapter( is EpisodeLimitRow -> Long.MAX_VALUE - 2 is NoEpisodeMessage -> Long.MAX_VALUE - 3 is TabsHeader -> Long.MAX_VALUE - 4 + is BookmarkHeader -> Long.MAX_VALUE - 5 is DividerRow -> item.groupIndex.toLong() is PodcastEpisode -> item.adapterId is Bookmark -> item.adapterId diff --git a/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/podcast/PodcastFragment.kt b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/podcast/PodcastFragment.kt index 13cc36dd6f2..91b5b98e943 100644 --- a/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/podcast/PodcastFragment.kt +++ b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/podcast/PodcastFragment.kt @@ -434,7 +434,7 @@ class PodcastFragment : BaseFragment(), Toolbar.OnMenuItemClickListener, Corouti } } - private val onTabClicked: (tab: PodcastViewModel.PodcastTab) -> Unit = { tab -> + private val onTabClicked: (tab: PodcastTab) -> Unit = { tab -> viewModel.onTabClicked(tab) } @@ -739,6 +739,7 @@ class PodcastFragment : BaseFragment(), Toolbar.OnMenuItemClickListener, Corouti ) PodcastTab.BOOKMARKS -> adapter?.setBookmarks( bookmarks = state.bookmarks, + searchTerm = state.searchBookmarkTerm, ) } if (state.searchTerm.isNotEmpty() && state.searchTerm != lastSearchTerm) { diff --git a/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/podcast/adapter/BookmarkHeaderViewHolder.kt b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/podcast/adapter/BookmarkHeaderViewHolder.kt new file mode 100644 index 00000000000..fdf774dfe7a --- /dev/null +++ b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/podcast/adapter/BookmarkHeaderViewHolder.kt @@ -0,0 +1,118 @@ +package au.com.shiftyjelly.pocketcasts.podcasts.view.podcast.adapter + +import android.widget.EditText +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Divider +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.recyclerview.widget.RecyclerView +import au.com.shiftyjelly.pocketcasts.compose.AppTheme +import au.com.shiftyjelly.pocketcasts.compose.components.TextP60 +import au.com.shiftyjelly.pocketcasts.compose.theme +import au.com.shiftyjelly.pocketcasts.podcasts.R +import au.com.shiftyjelly.pocketcasts.podcasts.view.podcast.EpisodeSearchView +import au.com.shiftyjelly.pocketcasts.podcasts.view.podcast.PodcastAdapter.BookmarkHeader +import au.com.shiftyjelly.pocketcasts.ui.theme.Theme +import au.com.shiftyjelly.pocketcasts.images.R as IR +import au.com.shiftyjelly.pocketcasts.localization.R as LR +class BookmarkHeaderViewHolder( + private val composeView: ComposeView, + private val theme: Theme, +) : RecyclerView.ViewHolder(composeView) { + fun bind(bookmarkHeader: BookmarkHeader) { + composeView.setContent { + AppTheme(theme.activeTheme) { + Column( + modifier = Modifier + .background(MaterialTheme.theme.colors.primaryUi02) + ) { + SearchHeader(bookmarkHeader) + Divider(color = MaterialTheme.theme.colors.primaryUi05) + if (bookmarkHeader.bookmarksCount > 0) { + BookmarksCountView(bookmarkHeader) + } + } + } + } + } + @Composable + private fun SearchHeader(bookmarkHeader: BookmarkHeader) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Box( + modifier = Modifier + .weight(1f) + .padding(start = 16.dp) + .padding(vertical = 16.dp) + ) { + SearchView( + bookmarkHeader = bookmarkHeader, + modifier = Modifier.fillMaxWidth() + ) + } + IconButton( + onClick = { }, + modifier = Modifier + ) { + Icon( + painter = painterResource(IR.drawable.ic_more_vert_black_24dp), + contentDescription = stringResource(LR.string.more_options), + tint = MaterialTheme.theme.colors.primaryIcon02, + ) + } + } + } + @Composable + fun SearchView( + bookmarkHeader: BookmarkHeader, + modifier: Modifier = Modifier, + ) { + val hintText = stringResource(id = LR.string.bookmarks_search) + AndroidView( + modifier = modifier, + factory = { context -> + EpisodeSearchView(context).apply { + val searchText = findViewById(R.id.searchText) + searchText.hint = hintText + this.onFocus = { bookmarkHeader.onSearchFocus() } + onSearch = { query -> + bookmarkHeader.onSearchQueryChanged(query) + } + text = bookmarkHeader.searchTerm + } + }, + ) + } + @Composable + private fun BookmarksCountView(bookmarkHeader: BookmarkHeader) { + TextP60( + text = if (bookmarkHeader.bookmarksCount > 1) { + stringResource(LR.string.bookmarks_plural, bookmarkHeader.bookmarksCount) + } else { + stringResource(LR.string.bookmarks_singular) + }, + color = MaterialTheme.theme.colors.primaryText02, + modifier = Modifier + .padding(start = 16.dp) + .padding(vertical = 16.dp) + ) + } +} diff --git a/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/viewmodel/PodcastViewModel.kt b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/viewmodel/PodcastViewModel.kt index 88cf798bcc1..c214265831f 100644 --- a/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/viewmodel/PodcastViewModel.kt +++ b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/viewmodel/PodcastViewModel.kt @@ -19,6 +19,9 @@ import au.com.shiftyjelly.pocketcasts.models.entity.Podcast import au.com.shiftyjelly.pocketcasts.models.entity.PodcastEpisode import au.com.shiftyjelly.pocketcasts.models.to.PodcastGrouping import au.com.shiftyjelly.pocketcasts.models.type.EpisodesSortType +import au.com.shiftyjelly.pocketcasts.podcasts.helper.search.BookmarkSearchHandler +import au.com.shiftyjelly.pocketcasts.podcasts.helper.search.EpisodeSearchHandler +import au.com.shiftyjelly.pocketcasts.podcasts.helper.search.SearchHandler import au.com.shiftyjelly.pocketcasts.preferences.Settings import au.com.shiftyjelly.pocketcasts.repositories.bookmark.BookmarkManager import au.com.shiftyjelly.pocketcasts.repositories.chromecast.CastManager @@ -28,10 +31,8 @@ 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.user.UserManager -import au.com.shiftyjelly.pocketcasts.servers.podcast.PodcastCacheServerManagerImpl import au.com.shiftyjelly.pocketcasts.ui.theme.Theme import au.com.shiftyjelly.pocketcasts.utils.log.LogBuffer -import com.jakewharton.rxrelay2.BehaviorRelay import dagger.hilt.android.lifecycle.HiltViewModel import io.reactivex.BackpressureStrategy import io.reactivex.Flowable @@ -48,7 +49,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.reactive.asFlow import kotlinx.coroutines.rx2.asFlowable import timber.log.Timber -import java.util.concurrent.TimeUnit import javax.inject.Inject import kotlin.coroutines.CoroutineContext import kotlin.math.min @@ -61,20 +61,19 @@ class PodcastViewModel private val podcastManager: PodcastManager, private val folderManager: FolderManager, private val episodeManager: EpisodeManager, - private val cacheServerManager: PodcastCacheServerManagerImpl, private val theme: Theme, - private val settings: Settings, private val castManager: CastManager, private val downloadManager: DownloadManager, private val userManager: UserManager, private val analyticsTracker: AnalyticsTrackerWrapper, private val episodeAnalytics: EpisodeAnalytics, private val bookmarkManager: BookmarkManager, + private val episodeSearchHandler: EpisodeSearchHandler, + private val bookmarkSearchHandler: BookmarkSearchHandler, ) : ViewModel(), CoroutineScope { private val disposables = CompositeDisposable() val podcast = MutableLiveData() - var searchTerm = "" lateinit var podcastUuid: String private val _uiState: MutableLiveData = MutableLiveData(UiState.Loading) @@ -86,8 +85,6 @@ class PodcastViewModel val tintColor = MutableLiveData() val observableHeaderExpanded = MutableLiveData() - private val searchQueryRelay = BehaviorRelay.create() - .apply { accept("") } val castConnected = castManager.isConnectedObservable .toFlowable(BackpressureStrategy.LATEST) @@ -98,21 +95,10 @@ class PodcastViewModel fun loadPodcast(uuid: String, resources: Resources) { viewModelScope.launch { + this@PodcastViewModel.podcastUuid = uuid - val noSearchResult = Pair?>("", null) - val searchResults = searchQueryRelay.debounce { // Only debounce when search has a value otherwise it slows down loading the pages - if (it.isEmpty()) { - Observable.empty() - } else { - Observable.timer(settings.getEpisodeSearchDebounceMs(), TimeUnit.MILLISECONDS) - } - }.switchMapSingle { searchTerm -> - if (searchTerm.length > 2) { - cacheServerManager.searchEpisodes(uuid, searchTerm).map { Pair?>(searchTerm, it) }.onErrorReturnItem(noSearchResult) - } else { - Single.just(noSearchResult) - } - }.distinctUntilChanged() + val episodeSearchResults = episodeSearchHandler.getSearchResultsObservable(uuid) + val bookmarkSearchResults = bookmarkSearchHandler.getSearchResultsObservable(uuid) val podcastStateFlowable = podcastManager.findPodcastByUuidRx(uuid) .subscribeOn(Schedulers.io()) @@ -149,8 +135,17 @@ class PodcastViewModel podcast.postValue(newPodcast) } .switchMap { - Observables.combineLatest(Observable.just(it), searchResults) { podcast, searchQuery -> - CombinedEpisodeData(podcast, podcast.showArchived, searchQuery.first, searchQuery.second) + Observables.combineLatest( + Observable.just(it), + episodeSearchResults, + bookmarkSearchResults + ) { podcast, episodeSearchResults, bookmarkSearchResults -> + CombinedEpisodeAndBookmarkData( + podcast = podcast, + showingArchived = podcast.showArchived, + episodeSearchResult = episodeSearchResults, + bookmarkSearchResult = bookmarkSearchResults, + ) }.toFlowable(BackpressureStrategy.LATEST) } .loadEpisodesAndBookmarks(episodeManager, bookmarkManager) @@ -164,7 +159,7 @@ class PodcastViewModel } .onErrorReturn { LogBuffer.e(LogBuffer.TAG_BACKGROUND_TASKS, it, "Could not load podcast page") - UiState.Error(it.message ?: "Unknown error", searchTerm) + UiState.Error(it.message ?: "Unknown error") } .observeOn(AndroidSchedulers.mainThread()) @@ -247,10 +242,10 @@ class PodcastViewModel } fun searchQueryUpdated(newValue: String) { - val oldValue = searchQueryRelay.value ?: "" - searchTerm = newValue - searchQueryRelay.accept(newValue) - trackSearchIfNeeded(oldValue, newValue) + when (getCurrentTab()) { + PodcastTab.EPISODES -> episodeSearchHandler.searchQueryUpdated(newValue) + PodcastTab.BOOKMARKS -> bookmarkSearchHandler.searchQueryUpdated(newValue) + } } fun updateEpisodesSortType(episodesSortType: EpisodesSortType) { @@ -394,25 +389,17 @@ class PodcastViewModel val episodeCount: Int, val archivedCount: Int, val searchTerm: String, + val searchBookmarkTerm: String, val episodeLimit: Int?, val episodeLimitIndex: Int?, val showTab: PodcastTab = PodcastTab.EPISODES, ) : UiState() data class Error( val errorMessage: String, - val searchTerm: String ) : UiState() object Loading : UiState() } - private fun trackSearchIfNeeded(oldValue: String, newValue: String) { - if (oldValue.isEmpty() && newValue.isNotEmpty()) { - analyticsTracker.track(AnalyticsEvent.PODCAST_SCREEN_SEARCH_PERFORMED) - } else if (oldValue.isNotEmpty() && newValue.isEmpty()) { - analyticsTracker.track(AnalyticsEvent.PODCAST_SCREEN_SEARCH_CLEARED) - } - } - private object AnalyticsProp { private const val ENABLED_KEY = "enabled" private const val SHOW_ARCHIVED = "show_archived" @@ -433,18 +420,18 @@ private fun Maybe.filterKeepSubscribed(): Maybe { private class EpisodeLimitPlaceholder -private data class CombinedEpisodeData( +private data class CombinedEpisodeAndBookmarkData( val podcast: Podcast, val showingArchived: Boolean, - val searchTerm: String, - val searchUuids: List?, + val episodeSearchResult: SearchHandler.SearchResult, + val bookmarkSearchResult: SearchHandler.SearchResult, ) -private fun Flowable.loadEpisodesAndBookmarks( +private fun Flowable.loadEpisodesAndBookmarks( episodeManager: EpisodeManager, bookmarkManager: BookmarkManager, ): Flowable { - return this.switchMap { (podcast, showArchived, searchTerm, searchUuids) -> + return this.switchMap { (podcast, showArchived, episodeSearchResults, bookmarkSearchResults) -> LogBuffer.i( LogBuffer.TAG_BACKGROUND_TASKS, "Observing podcast ${podcast.uuid} episode changes" @@ -458,20 +445,19 @@ private fun Flowable.loadEpisodesAndBookmarks( } else { it } - } - .flatMap { episodeList -> - if (searchUuids == null) { - Flowable.just(Pair(episodeList, episodeList)) - } else { - val searchEpisodes = episodeList.filter { searchUuids.contains(it.uuid) } - Flowable.just(Pair(searchEpisodes, episodeList)) - } - }, + }.withSearchResult( + { episodeSearchResults.searchUuids?.contains(it.uuid) ?: false }, + searchResults = episodeSearchResults, + ), bookmarkManager.findPodcastBookmarksFlow(podcast.uuid).asFlowable() - ) { (searchList, episodeList), bookmarks -> + .withSearchResult( + { bookmarkSearchResults.searchUuids?.contains(it.uuid) ?: false }, + searchResults = bookmarkSearchResults, + ) + ) { (searchList, episodeList), (bookmarks, _) -> val episodeCount = episodeList.size val archivedCount = episodeList.count { it.isArchived } - val showArchivedWithSearch = searchUuids != null || showArchived + val showArchivedWithSearch = episodeSearchResults.searchUuids != null || showArchived val filteredList = if (showArchivedWithSearch) searchList else searchList.filter { !it.isArchived } val episodeLimit = podcast.autoArchiveEpisodeLimit @@ -505,18 +491,31 @@ private fun Flowable.loadEpisodesAndBookmarks( showingArchived = showArchivedWithSearch, episodeCount = episodeCount, archivedCount = archivedCount, - searchTerm = searchTerm, + searchTerm = episodeSearchResults.searchTerm, + searchBookmarkTerm = bookmarkSearchResults.searchTerm, episodeLimit = podcast.autoArchiveEpisodeLimit, episodeLimitIndex = episodeLimitIndex, ) state } - .doOnError { Timber.e("Error loading episodes: ${it.message}") } - .onErrorReturnItem(PodcastViewModel.UiState.Error("There was an error loading the episodes", searchTerm)) + .doOnError { Timber.e("Error loading episodes or bookmarks: ${it.message}") } + .onErrorReturnItem(PodcastViewModel.UiState.Error("There was an error loading the episodes or bookmarks")) .subscribeOn(Schedulers.io()) } } +private fun Flowable>.withSearchResult( + filterCondition: (T) -> Boolean, + searchResults: SearchHandler.SearchResult +) = + this.flatMap { list -> + if (searchResults.searchUuids == null) { + Flowable.just(Pair(list, list)) + } else { + Flowable.just(Pair(list.filter { filterCondition(it) }, list)) + } + } + private fun Maybe.downloadMissingPodcast(uuid: String, podcastManager: PodcastManager): Single { return this.switchIfEmpty( Single.defer { diff --git a/modules/services/localization/src/main/res/values/strings.xml b/modules/services/localization/src/main/res/values/strings.xml index 5e162689ea0..b907d67bf50 100644 --- a/modules/services/localization/src/main/res/values/strings.xml +++ b/modules/services/localization/src/main/res/values/strings.xml @@ -1687,8 +1687,8 @@ You can save timestamps of episodes from the actions menu in the player or by configuring an action with your headphones. No bookmarks yet Headphone settings - %s bookmarks - %s bookmark + %d bookmarks + 1 bookmark Delete bookmarks Delete bookmark %1$d bookmarks deleted @@ -1696,6 +1696,7 @@ %d bookmarks will be deleted. This cannot be undone. This bookmark will be deleted. This cannot be undone. Select bookmarks + Search bookmark @string/sort_by @string/sort_newest_to_oldest @string/sort_oldest_to_newest diff --git a/modules/services/model/src/main/java/au/com/shiftyjelly/pocketcasts/models/db/dao/BookmarkDao.kt b/modules/services/model/src/main/java/au/com/shiftyjelly/pocketcasts/models/db/dao/BookmarkDao.kt index facb1edc816..9901228609c 100644 --- a/modules/services/model/src/main/java/au/com/shiftyjelly/pocketcasts/models/db/dao/BookmarkDao.kt +++ b/modules/services/model/src/main/java/au/com/shiftyjelly/pocketcasts/models/db/dao/BookmarkDao.kt @@ -63,6 +63,20 @@ abstract class BookmarkDao { deleted: Boolean = false, ): Flow> + @Query( + """SELECT bookmarks.* + FROM bookmarks + LEFT JOIN podcast_episodes ON bookmarks.episode_uuid = podcast_episodes.uuid + WHERE bookmarks.podcast_uuid = :podcastUuid + AND (UPPER(bookmarks.title) LIKE UPPER(:title) OR UPPER(podcast_episodes.title) LIKE UPPER(:title)) + AND deleted = :deleted""" + ) + abstract suspend fun searchInPodcastByTitle( + podcastUuid: String, + title: String, + deleted: Boolean = false, + ): List + @Query("UPDATE bookmarks SET deleted = :deleted, deleted_modified = :deletedModified, sync_status = :syncStatus WHERE uuid = :uuid") abstract suspend fun updateDeleted(uuid: String, deleted: Boolean, deletedModified: Long, syncStatus: SyncStatus) diff --git a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/bookmark/BookmarkManager.kt b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/bookmark/BookmarkManager.kt index 298e875f15a..38bd60b695e 100644 --- a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/bookmark/BookmarkManager.kt +++ b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/bookmark/BookmarkManager.kt @@ -19,4 +19,5 @@ interface BookmarkManager { suspend fun deleteSynced(bookmarkUuid: String) suspend fun upsertSynced(bookmark: Bookmark): Bookmark fun findBookmarksToSync(): List + suspend fun searchInPodcastByTitle(podcastUuid: String, title: String): List } diff --git a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/bookmark/BookmarkManagerImpl.kt b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/bookmark/BookmarkManagerImpl.kt index c7e4edca30f..c4e4515249a 100644 --- a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/bookmark/BookmarkManagerImpl.kt +++ b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/bookmark/BookmarkManagerImpl.kt @@ -111,6 +111,9 @@ class BookmarkManagerImpl @Inject constructor( flowOf(helper.map { it.toBookmark() }) } + override suspend fun searchInPodcastByTitle(podcastUuid: String, title: String) = + bookmarkDao.searchInPodcastByTitle(podcastUuid, "%$title%").map { it.uuid } + /** * Mark the bookmark as deleted so it can be synced to other devices. */