Skip to content

Commit

Permalink
Bookmarks - Add search on podcast page (#1211)
Browse files Browse the repository at this point in the history
Co-authored-by: Philip Simpson <[email protected]>
  • Loading branch information
ashiagr and geekygecko authored Jul 28, 2023
1 parent 316be02 commit f12f256
Show file tree
Hide file tree
Showing 12 changed files with 387 additions and 71 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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()
Expand All @@ -142,8 +146,8 @@ class BookmarkDaoTest {
bookmarkDao.insert(bookmark3)

val result = bookmarkDao.findByEpisodeOrderTimeFlow(
episodeUuid = episodeUuid,
podcastUuid = podcastUuid,
episodeUuid = defaultEpisodeUuid,
podcastUuid = defaultPodcastUuid,
deleted = false,
).first()

Expand All @@ -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
)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Bookmark>() {

override fun getSearchResultsObservable(podcastUuid: String): Observable<SearchResult> =
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
}
}
Original file line number Diff line number Diff line change
@@ -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<BaseEpisode>() {
private val searchDebounce = settings.getEpisodeSearchDebounceMs()

override fun getSearchResultsObservable(podcastUuid: String): Observable<SearchResult> =
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)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package au.com.shiftyjelly.pocketcasts.podcasts.helper.search

import com.jakewharton.rxrelay2.BehaviorRelay
import io.reactivex.Observable

abstract class SearchHandler<T> {
private var searchTerm = ""
protected val searchQueryRelay = BehaviorRelay.create<String>()
.apply { accept("") }

protected val noSearchResult = SearchResult("", null)

abstract fun getSearchResultsObservable(podcastUuid: String): Observable<SearchResult>

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<String>?,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -73,6 +74,7 @@ private val differ: DiffUtil.ItemCallback<Any> = 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
}
Expand All @@ -91,6 +93,8 @@ private val differ: DiffUtil.ItemCallback<Any> = 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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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)
}
}

Expand Down Expand Up @@ -388,12 +401,14 @@ class PodcastAdapter(

fun setBookmarks(
bookmarks: List<Bookmark>,
searchTerm: String,
) {
val content = mutableListOf<Any>().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)
Expand All @@ -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
}
}
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down Expand Up @@ -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) {
Expand Down
Loading

0 comments on commit f12f256

Please sign in to comment.