Convert Source Feed to Jetpack Compose

This commit is contained in:
Jobobby04 2022-08-31 16:21:26 -04:00
parent 493a1ab4a6
commit a760198981
10 changed files with 311 additions and 564 deletions

View File

@ -0,0 +1,219 @@
package eu.kanade.presentation.browse
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import eu.kanade.domain.manga.model.Manga
import eu.kanade.presentation.components.LoadingScreen
import eu.kanade.presentation.components.ScrollbarLazyColumn
import eu.kanade.presentation.util.bottomNavPaddingValues
import eu.kanade.presentation.util.plus
import eu.kanade.presentation.util.topPaddingValues
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.browse.source.feed.SourceFeedPresenter
import exh.savedsearches.models.FeedSavedSearch
import exh.savedsearches.models.SavedSearch
sealed class SourceFeedUI {
abstract val id: Long
abstract val title: String
@Composable
@ReadOnlyComposable
get
abstract val results: List<Manga>?
abstract fun withResults(results: List<Manga>?): SourceFeedUI
data class Latest(override val results: List<Manga>?) : SourceFeedUI() {
override val id: Long = -1
override val title: String
@Composable
@ReadOnlyComposable
get() = stringResource(R.string.latest)
override fun withResults(results: List<Manga>?): SourceFeedUI {
return copy(results = results)
}
}
data class Browse(override val results: List<Manga>?) : SourceFeedUI() {
override val id: Long = -2
override val title: String
@Composable
@ReadOnlyComposable
get() = stringResource(R.string.browse)
override fun withResults(results: List<Manga>?): SourceFeedUI {
return copy(results = results)
}
}
data class SourceSavedSearch(
val feed: FeedSavedSearch,
val savedSearch: SavedSearch,
override val results: List<Manga>?,
) : SourceFeedUI() {
override val id: Long
get() = feed.id
override val title: String
@Composable
@ReadOnlyComposable
get() = savedSearch.name
override fun withResults(results: List<Manga>?): SourceFeedUI {
return copy(results = results)
}
}
}
@Composable
fun SourceFeedScreen(
nestedScrollInterop: NestedScrollConnection,
presenter: SourceFeedPresenter,
onClickBrowse: () -> Unit,
onClickLatest: () -> Unit,
onClickSavedSearch: (SavedSearch) -> Unit,
onClickDelete: (FeedSavedSearch) -> Unit,
onClickManga: (Manga) -> Unit,
) {
when {
presenter.isLoading -> LoadingScreen()
else -> {
SourceFeedList(
nestedScrollConnection = nestedScrollInterop,
state = presenter,
onClickBrowse = onClickBrowse,
onClickLatest = onClickLatest,
onClickSavedSearch = onClickSavedSearch,
onClickDelete = onClickDelete,
onClickManga = onClickManga,
)
}
}
}
@Composable
fun SourceFeedList(
nestedScrollConnection: NestedScrollConnection,
state: SourceFeedState,
onClickBrowse: () -> Unit,
onClickLatest: () -> Unit,
onClickSavedSearch: (SavedSearch) -> Unit,
onClickDelete: (FeedSavedSearch) -> Unit,
onClickManga: (Manga) -> Unit,
) {
ScrollbarLazyColumn(
modifier = Modifier.nestedScroll(nestedScrollConnection),
contentPadding = bottomNavPaddingValues + WindowInsets.navigationBars.asPaddingValues() + topPaddingValues,
) {
items(
state.items.orEmpty(),
key = { it.id },
) { item ->
SourceFeedItem(
modifier = Modifier.animateItemPlacement(),
item = item,
onClickTitle = when (item) {
is SourceFeedUI.Browse -> onClickBrowse
is SourceFeedUI.Latest -> onClickLatest
is SourceFeedUI.SourceSavedSearch -> {
{ onClickSavedSearch(item.savedSearch) }
}
},
onClickDelete = onClickDelete,
onClickManga = onClickManga,
)
}
}
}
@Composable
fun SourceFeedItem(
modifier: Modifier,
item: SourceFeedUI,
onClickTitle: () -> Unit,
onClickDelete: (FeedSavedSearch) -> Unit,
onClickManga: (Manga) -> Unit,
) {
Column(
modifier then Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Row(
Modifier
.fillMaxWidth()
.let {
if (item is SourceFeedUI.SourceSavedSearch) {
it.combinedClickable(
onLongClick = {
onClickDelete(item.feed)
},
onClick = onClickTitle,
)
} else {
it.clickable(onClick = onClickTitle)
}
},
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.padding(start = 16.dp)) {
Text(
text = item.title,
style = MaterialTheme.typography.bodyMedium,
)
}
Icon(
painter = painterResource(R.drawable.ic_arrow_forward_24dp),
contentDescription = stringResource(R.string.label_more),
modifier = Modifier.padding(16.dp),
)
}
val results = item.results
when {
results == null -> {
CircularProgressIndicator()
}
results.isEmpty() -> {
Text(stringResource(R.string.no_results_found), modifier = Modifier.padding(bottom = 16.dp))
}
else -> {
LazyRow(
Modifier.fillMaxWidth(),
contentPadding = PaddingValues(horizontal = 12.dp),
) {
items(results) {
FeedCardItem(
manga = it,
onClickManga = onClickManga,
)
}
}
}
}
}
}

View File

@ -0,0 +1,21 @@
package eu.kanade.presentation.browse
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
@Stable
interface SourceFeedState {
val isLoading: Boolean
val items: List<SourceFeedUI>?
}
fun SourceFeedState(): SourceFeedState {
return SourceFeedStateImpl()
}
class SourceFeedStateImpl : SourceFeedState {
override var isLoading: Boolean by mutableStateOf(true)
override var items: List<SourceFeedUI>? by mutableStateOf(null)
}

View File

@ -1,85 +0,0 @@
package eu.kanade.tachiyomi.ui.browse.source.feed
import android.os.Bundle
import android.os.Parcelable
import android.util.SparseArray
import androidx.recyclerview.widget.RecyclerView
import eu.davidea.flexibleadapter.FlexibleAdapter
import exh.savedsearches.models.FeedSavedSearch
import exh.savedsearches.models.SavedSearch
/**
* Adapter that holds the search cards.
*
* @param controller instance of [SourceFeedController].
*/
class SourceFeedAdapter(val controller: SourceFeedController) :
FlexibleAdapter<SourceFeedItem>(null, controller, true) {
val feedClickListener: OnFeedClickListener = controller
/**
* Bundle where the view state of the holders is saved.
*/
private var bundle = Bundle()
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int, payloads: List<Any?>) {
super.onBindViewHolder(holder, position, payloads)
restoreHolderState(holder)
}
override fun onViewRecycled(holder: RecyclerView.ViewHolder) {
super.onViewRecycled(holder)
saveHolderState(holder, bundle)
}
override fun onSaveInstanceState(outState: Bundle) {
val holdersBundle = Bundle()
allBoundViewHolders.forEach { saveHolderState(it, holdersBundle) }
outState.putBundle(HOLDER_BUNDLE_KEY, holdersBundle)
super.onSaveInstanceState(outState)
}
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState)
bundle = savedInstanceState.getBundle(HOLDER_BUNDLE_KEY)!!
}
/**
* Saves the view state of the given holder.
*
* @param holder The holder to save.
* @param outState The bundle where the state is saved.
*/
private fun saveHolderState(holder: RecyclerView.ViewHolder, outState: Bundle) {
val key = "holder_${holder.bindingAdapterPosition}"
val holderState = SparseArray<Parcelable>()
holder.itemView.saveHierarchyState(holderState)
outState.putSparseParcelableArray(key, holderState)
}
/**
* Restores the view state of the given holder.
*
* @param holder The holder to restore.
*/
private fun restoreHolderState(holder: RecyclerView.ViewHolder) {
val key = "holder_${holder.bindingAdapterPosition}"
val holderState = bundle.getSparseParcelableArray<Parcelable>(key)
if (holderState != null) {
holder.itemView.restoreHierarchyState(holderState)
bundle.remove(key)
}
}
interface OnFeedClickListener {
fun onLatestClick()
fun onBrowseClick()
fun onSavedSearchClick(savedSearch: SavedSearch)
fun onRemoveClick(feedSavedSearch: FeedSavedSearch)
}
private companion object {
const val HOLDER_BUNDLE_KEY = "holder_bundle"
}
}

View File

@ -1,27 +0,0 @@
package eu.kanade.tachiyomi.ui.browse.source.feed
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.kanade.tachiyomi.data.database.models.Manga
/**
* Adapter that holds the manga items from search results.
*
* @param controller instance of [SourceFeedController].
*/
class SourceFeedCardAdapter(controller: SourceFeedController) :
FlexibleAdapter<SourceFeedCardItem>(null, controller, true) {
/**
* Listen for browse item clicks.
*/
val mangaClickListener: OnMangaClickListener = controller
/**
* Listener which should be called when user clicks browse.
* Note: Should only be handled by [SourceFeedController]
*/
interface OnMangaClickListener {
fun onMangaClick(manga: Manga)
fun onMangaLongClick(manga: Manga)
}
}

View File

@ -1,58 +0,0 @@
package eu.kanade.tachiyomi.ui.browse.source.feed
import android.view.View
import androidx.core.view.isVisible
import coil.dispose
import eu.davidea.viewholders.FlexibleViewHolder
import eu.kanade.tachiyomi.data.coil.MangaCoverFetcher
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.databinding.GlobalSearchControllerCardItemBinding
import eu.kanade.tachiyomi.util.view.loadAutoPause
class SourceFeedCardHolder(view: View, adapter: SourceFeedCardAdapter) :
FlexibleViewHolder(view, adapter) {
private val binding = GlobalSearchControllerCardItemBinding.bind(view)
init {
// Call onMangaClickListener when item is pressed.
itemView.setOnClickListener {
val item = adapter.getItem(bindingAdapterPosition)
if (item != null) {
adapter.mangaClickListener.onMangaClick(item.manga)
}
}
itemView.setOnLongClickListener {
val item = adapter.getItem(bindingAdapterPosition)
if (item != null) {
adapter.mangaClickListener.onMangaLongClick(item.manga)
}
true
}
}
fun bind(manga: Manga) {
binding.card.clipToOutline = true
// Set manga title
binding.title.text = manga.title
// Set alpha of thumbnail.
binding.cover.alpha = if (manga.favorite) 0.3f else 1.0f
// For rounded corners
binding.badges.clipToOutline = true
// Set favorite badge
binding.favoriteText.isVisible = manga.favorite
setImage(manga)
}
fun setImage(manga: Manga) {
binding.cover.dispose()
binding.cover.loadAutoPause(manga) {
setParameter(MangaCoverFetcher.USE_CUSTOM_COVER, false)
}
}
}

View File

@ -1,40 +0,0 @@
package eu.kanade.tachiyomi.ui.browse.source.feed
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Manga
class SourceFeedCardItem(val manga: Manga) : AbstractFlexibleItem<SourceFeedCardHolder>() {
override fun getLayoutRes(): Int {
return R.layout.global_search_controller_card_item
}
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): SourceFeedCardHolder {
return SourceFeedCardHolder(view, adapter as SourceFeedCardAdapter)
}
override fun bindViewHolder(
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
holder: SourceFeedCardHolder,
position: Int,
payloads: List<Any?>?,
) {
holder.bind(manga)
}
override fun equals(other: Any?): Boolean {
if (other is SourceFeedCardItem) {
return manga.id == other.manga.id
}
return false
}
override fun hashCode(): Int {
return manga.id?.toInt() ?: 0
}
}

View File

@ -1,24 +1,23 @@
package eu.kanade.tachiyomi.ui.browse.source.feed
import android.os.Bundle
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.View
import androidx.compose.runtime.Composable
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.core.os.bundleOf
import androidx.core.view.isVisible
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
import dev.chrisbanes.insetter.applyInsetter
import eu.kanade.domain.manga.model.Manga
import eu.kanade.presentation.browse.SourceFeedScreen
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.databinding.GlobalSearchControllerBinding
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.ui.base.controller.FabController
import eu.kanade.tachiyomi.ui.base.controller.SearchableNucleusController
import eu.kanade.tachiyomi.ui.base.controller.SearchableComposeController
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController
import eu.kanade.tachiyomi.ui.browse.source.browse.SourceFilterSheet
@ -42,10 +41,8 @@ import xyz.nulldev.ts.api.http.serializer.FilterSerializer
* [SourceFeedCardAdapter.OnMangaClickListener] called when manga is clicked in global search
*/
open class SourceFeedController :
SearchableNucleusController<GlobalSearchControllerBinding, SourceFeedPresenter>,
FabController,
SourceFeedCardAdapter.OnMangaClickListener,
SourceFeedAdapter.OnFeedClickListener {
SearchableComposeController<SourceFeedPresenter>,
FabController {
constructor(source: CatalogueSource?) : super(
bundleOf(
@ -62,11 +59,6 @@ open class SourceFeedController :
@Suppress("unused")
constructor(bundle: Bundle) : this(bundle.getLong(SOURCE_EXTRA))
/**
* Adapter containing search results grouped by lang.
*/
protected var adapter: SourceFeedAdapter? = null
var source: CatalogueSource? = null
private var actionFab: ExtendedFloatingActionButton? = null
@ -90,27 +82,7 @@ open class SourceFeedController :
* @return instance of [SourceFeedPresenter]
*/
override fun createPresenter(): SourceFeedPresenter {
return SourceFeedPresenter(source!!)
}
/**
* Called when manga in global search is clicked, opens manga.
*
* @param manga clicked item containing manga information.
*/
override fun onMangaClick(manga: Manga) {
// Open MangaController.
router.pushController(MangaController(manga.id!!, true).withFadeTransaction())
}
/**
* Called when manga in global search is long clicked.
*
* @param manga clicked item containing manga information.
*/
override fun onMangaLongClick(manga: Manga) {
// Delegate to single click by default.
onMangaClick(manga)
return SourceFeedPresenter(source = source!!)
}
/**
@ -133,8 +105,6 @@ open class SourceFeedController :
}
}
override fun createBinding(inflater: LayoutInflater): GlobalSearchControllerBinding = GlobalSearchControllerBinding.inflate(inflater)
/**
* Called when the view is created
*
@ -145,33 +115,6 @@ open class SourceFeedController :
// Prepare filter sheet
initFilterSheet()
binding.recycler.applyInsetter {
type(navigationBars = true) {
padding()
}
}
adapter = SourceFeedAdapter(this)
// Create recycler and set adapter.
binding.recycler.layoutManager = LinearLayoutManager(view.context)
binding.recycler.adapter = adapter
}
override fun onDestroyView(view: View) {
adapter = null
super.onDestroyView(view)
}
override fun onSaveViewState(view: View, outState: Bundle) {
super.onSaveViewState(view, outState)
adapter?.onSaveInstanceState(outState)
}
override fun onRestoreViewState(view: View, savedViewState: Bundle) {
super.onRestoreViewState(view, savedViewState)
adapter?.onRestoreInstanceState(savedViewState)
}
private val filterSerializer = FilterSerializer()
@ -284,66 +227,46 @@ open class SourceFeedController :
actionFab = null
}
/**
* Returns the view holder for the given manga.
*
* @param source used to find holder containing source
* @return the holder of the manga or null if it's not bound.
*/
private fun getHolder(sourceFeed: SourceFeed): SourceFeedHolder? {
val adapter = adapter ?: return null
adapter.allBoundViewHolders.forEach { holder ->
val item = adapter.getItem(holder.bindingAdapterPosition)
if (item != null && sourceFeed == item.sourceFeed) {
return holder as SourceFeedHolder
}
}
return null
@Composable
override fun ComposeContent(nestedScrollInterop: NestedScrollConnection) {
SourceFeedScreen(
nestedScrollInterop = nestedScrollInterop,
presenter = presenter,
onClickBrowse = ::onBrowseClick,
onClickLatest = ::onLatestClick,
onClickSavedSearch = ::onSavedSearchClick,
onClickDelete = ::onRemoveClick,
onClickManga = ::onMangaClick,
)
}
/**
* Add search result to adapter.
* Called when manga in global search is clicked, opens manga.
*
* @param feedManga the source items containing the latest manga.
* @param manga clicked item containing manga information.
*/
fun setItems(feedManga: List<SourceFeedItem>) {
adapter?.updateDataSet(feedManga)
if (feedManga.isEmpty()) {
binding.emptyView.show(R.string.feed_tab_empty)
} else {
binding.emptyView.hide()
}
}
/**
* Called from the presenter when a manga is initialized.
*
* @param manga the initialized manga.
*/
fun onMangaInitialized(sourceFeed: SourceFeed, manga: Manga) {
getHolder(sourceFeed)?.setImage(manga)
private fun onMangaClick(manga: Manga) {
// Open MangaController.
router.pushController(MangaController(manga.id, true).withFadeTransaction())
}
fun onBrowseClick(search: String? = null, savedSearch: Long? = null, filters: String? = null) {
router.replaceTopController(BrowseSourceController(presenter.source, search, savedSearch = savedSearch, filterList = filters).withFadeTransaction())
}
override fun onLatestClick() {
private fun onLatestClick() {
router.replaceTopController(LatestUpdatesController(presenter.source).withFadeTransaction())
}
override fun onBrowseClick() {
private fun onBrowseClick() {
router.replaceTopController(BrowseSourceController(presenter.source).withFadeTransaction())
}
override fun onSavedSearchClick(savedSearch: SavedSearch) {
private fun onSavedSearchClick(savedSearch: SavedSearch) {
router.replaceTopController(BrowseSourceController(presenter.source, savedSearch = savedSearch.id).withFadeTransaction())
}
override fun onRemoveClick(feedSavedSearch: FeedSavedSearch) {
private fun onRemoveClick(feedSavedSearch: FeedSavedSearch) {
MaterialAlertDialogBuilder(activity!!)
.setTitle(R.string.feed)
.setMessage(R.string.feed_delete)

View File

@ -1,125 +0,0 @@
package eu.kanade.tachiyomi.ui.browse.source.feed
import android.annotation.SuppressLint
import android.view.View
import androidx.core.view.isVisible
import androidx.recyclerview.widget.LinearLayoutManager
import eu.davidea.viewholders.FlexibleViewHolder
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.databinding.GlobalSearchControllerCardBinding
/**
* Holder that binds the [SourceFeedItem] containing catalogue cards.
*
* @param view view of [SourceFeedItem]
* @param adapter instance of [SourceFeedAdapter]
*/
class SourceFeedHolder(view: View, val adapter: SourceFeedAdapter) :
FlexibleViewHolder(view, adapter) {
private val binding = GlobalSearchControllerCardBinding.bind(view)
/**
* Adapter containing manga from search results.
*/
private val mangaAdapter = SourceFeedCardAdapter(adapter.controller)
private var lastBoundResults: List<SourceFeedCardItem>? = null
init {
// Set layout horizontal.
binding.recycler.layoutManager = LinearLayoutManager(view.context, LinearLayoutManager.HORIZONTAL, false)
binding.recycler.adapter = mangaAdapter
binding.titleWrapper.setOnClickListener {
adapter.getItem(bindingAdapterPosition)?.let {
when (it.sourceFeed) {
SourceFeed.Browse -> adapter.feedClickListener.onBrowseClick()
SourceFeed.Latest -> adapter.feedClickListener.onLatestClick()
is SourceFeed.SourceSavedSearch -> adapter.feedClickListener.onSavedSearchClick(it.sourceFeed.savedSearch)
}
}
}
binding.titleWrapper.setOnLongClickListener {
adapter.getItem(bindingAdapterPosition)?.let {
when (it.sourceFeed) {
SourceFeed.Browse -> adapter.feedClickListener.onBrowseClick()
SourceFeed.Latest -> adapter.feedClickListener.onLatestClick()
is SourceFeed.SourceSavedSearch -> adapter.feedClickListener.onRemoveClick(it.sourceFeed.feed)
}
}
true
}
}
/**
* Show the loading of source search result.
*
* @param item item of card.
*/
@SuppressLint("SetTextI18n")
fun bind(item: SourceFeedItem) {
val results = item.results
when (item.sourceFeed) {
SourceFeed.Browse -> binding.title.setText(R.string.browse)
SourceFeed.Latest -> binding.title.setText(R.string.latest)
is SourceFeed.SourceSavedSearch -> binding.title.text = item.sourceFeed.savedSearch.name
}
when {
results == null -> {
binding.progress.isVisible = true
showResultsHolder()
}
results.isEmpty() -> {
binding.progress.isVisible = false
showNoResults()
}
else -> {
binding.progress.isVisible = false
showResultsHolder()
}
}
if (results !== lastBoundResults) {
mangaAdapter.updateDataSet(results)
lastBoundResults = results
}
}
/**
* Called from the presenter when a manga is initialized.
*
* @param manga the initialized manga.
*/
fun setImage(manga: Manga) {
getHolder(manga)?.setImage(manga)
}
/**
* Returns the view holder for the given manga.
*
* @param manga the manga to find.
* @return the holder of the manga or null if it's not bound.
*/
private fun getHolder(manga: Manga): SourceFeedCardHolder? {
mangaAdapter.allBoundViewHolders.forEach { holder ->
val item = mangaAdapter.getItem(holder.bindingAdapterPosition)
if (item != null && item.manga.id!! == manga.id!!) {
return holder as SourceFeedCardHolder
}
}
return null
}
private fun showResultsHolder() {
binding.noResultsFound.isVisible = false
}
private fun showNoResults() {
binding.noResultsFound.isVisible = true
}
}

View File

@ -1,73 +0,0 @@
package eu.kanade.tachiyomi.ui.browse.source.feed
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.R
/**
* Item that contains search result information.
*
* @param feed the source for the search results.
* @param results the search results.
* @param highlighted whether this search item should be highlighted/marked in the catalogue search view.
*/
class SourceFeedItem(
val sourceFeed: SourceFeed,
val results: List<SourceFeedCardItem>?,
val highlighted: Boolean = false,
) : AbstractFlexibleItem<SourceFeedHolder>() {
/**
* Set view.
*
* @return id of view
*/
override fun getLayoutRes(): Int {
return R.layout.global_search_controller_card
}
/**
* Create view holder (see [SourceFeedAdapter].
*
* @return holder of view.
*/
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): SourceFeedHolder {
return SourceFeedHolder(view, adapter as SourceFeedAdapter)
}
/**
* Bind item to view.
*/
override fun bindViewHolder(
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
holder: SourceFeedHolder,
position: Int,
payloads: List<Any?>?,
) {
holder.bind(this)
}
/**
* Used to check if two items are equal.
*
* @return items are equal?
*/
override fun equals(other: Any?): Boolean {
if (other is SourceFeedItem) {
return sourceFeed == other.sourceFeed
}
return false
}
/**
* Return hash code of item.
*
* @return hashcode
*/
override fun hashCode(): Int {
return sourceFeed.hashCode()
}
}

View File

@ -14,6 +14,9 @@ import eu.kanade.domain.source.interactor.GetFeedSavedSearchBySourceId
import eu.kanade.domain.source.interactor.GetSavedSearchBySourceId
import eu.kanade.domain.source.interactor.GetSavedSearchBySourceIdFeed
import eu.kanade.domain.source.interactor.InsertFeedSavedSearch
import eu.kanade.presentation.browse.SourceFeedState
import eu.kanade.presentation.browse.SourceFeedStateImpl
import eu.kanade.presentation.browse.SourceFeedUI
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.toDomainManga
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
@ -44,12 +47,6 @@ import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import xyz.nulldev.ts.api.http.serializer.FilterSerializer
sealed class SourceFeed {
object Latest : SourceFeed()
object Browse : SourceFeed()
data class SourceSavedSearch(val feed: FeedSavedSearch, val savedSearch: SavedSearch) : SourceFeed()
}
/**
* Presenter of [SourceFeedController]
* Function calls should be done from here. UI calls should be done from the controller.
@ -59,6 +56,7 @@ sealed class SourceFeed {
* @param preferences manages the preference calls.
*/
open class SourceFeedPresenter(
private val state: SourceFeedStateImpl = SourceFeedState() as SourceFeedStateImpl,
val source: CatalogueSource,
val preferences: PreferencesHelper = Injekt.get(),
private val getManga: GetManga = Injekt.get(),
@ -71,7 +69,7 @@ open class SourceFeedPresenter(
private val insertFeedSavedSearch: InsertFeedSavedSearch = Injekt.get(),
private val deleteFeedSavedSearchById: DeleteFeedSavedSearchById = Injekt.get(),
private val getExhSavedSearch: GetExhSavedSearch = Injekt.get(),
) : BasePresenter<SourceFeedController>() {
) : BasePresenter<SourceFeedController>(), SourceFeedState by state {
/**
* Fetches the different sources by user settings.
@ -81,7 +79,7 @@ open class SourceFeedPresenter(
/**
* Subject which fetches image of given manga.
*/
private val fetchImageSubject = PublishSubject.create<Triple<List<Manga>, Source, SourceFeed>>()
private val fetchImageSubject = PublishSubject.create<Triple<List<Manga>, Source, SourceFeedUI>>()
/**
* Subscription for fetching images of manga.
@ -110,7 +108,10 @@ open class SourceFeedPresenter(
getFeedSavedSearchBySourceId.subscribe(source.id)
.onEach {
getFeed(it)
val items = getSourcesToGetFeed(it)
state.items = items
state.isLoading = false
getFeed(items)
}
.launchIn(presenterScope)
}
@ -148,54 +149,35 @@ open class SourceFeedPresenter(
}
}
private suspend fun getSourcesToGetFeed(feedSavedSearch: List<FeedSavedSearch>): List<SourceFeed> {
private suspend fun getSourcesToGetFeed(feedSavedSearch: List<FeedSavedSearch>): List<SourceFeedUI> {
val savedSearches = getSavedSearchBySourceIdFeed.await(source.id)
.associateBy { it.id }
return listOfNotNull(
if (source.supportsLatest) {
SourceFeed.Latest
SourceFeedUI.Latest(null)
} else null,
SourceFeed.Browse,
SourceFeedUI.Browse(null),
) + feedSavedSearch
.map { SourceFeed.SourceSavedSearch(it, savedSearches[it.savedSearch]!!) }
}
/**
* Creates a catalogue search item
*/
protected open fun createCatalogueSearchItem(
sourceFeed: SourceFeed,
results: List<SourceFeedCardItem>?,
): SourceFeedItem {
return SourceFeedItem(sourceFeed, results)
.map { SourceFeedUI.SourceSavedSearch(it, savedSearches[it.savedSearch]!!, null) }
}
/**
* Initiates get manga per feed.
*/
private suspend fun getFeed(feedSavedSearch: List<FeedSavedSearch>) {
private fun getFeed(feedSavedSearch: List<SourceFeedUI>) {
// Create image fetch subscription
initializeFetchImageSubscription()
// Create items with the initial state
val initialItems = getSourcesToGetFeed(feedSavedSearch).map {
createCatalogueSearchItem(
it,
null,
)
}
var items = initialItems
fetchSourcesSubscription?.unsubscribe()
fetchSourcesSubscription = Observable.from(getSourcesToGetFeed(feedSavedSearch))
fetchSourcesSubscription = Observable.from(feedSavedSearch)
.flatMap(
{ sourceFeed ->
Observable.defer {
when (sourceFeed) {
SourceFeed.Browse -> source.fetchPopularManga(1)
SourceFeed.Latest -> source.fetchLatestUpdates(1)
is SourceFeed.SourceSavedSearch -> source.fetchSearchManga(
is SourceFeedUI.Browse -> source.fetchPopularManga(1)
is SourceFeedUI.Latest -> source.fetchLatestUpdates(1)
is SourceFeedUI.SourceSavedSearch -> source.fetchSearchManga(
page = 1,
query = sourceFeed.savedSearch.query.orEmpty(),
filters = getFilterList(sourceFeed.savedSearch, source),
@ -207,24 +189,21 @@ open class SourceFeedPresenter(
.map { it.mangas } // Get manga from search result.
.map { list -> list.map { networkToLocalManga(it, source.id) } } // Convert to local manga.
.doOnNext { fetchImage(it, source, sourceFeed) } // Load manga covers.
.map { list -> createCatalogueSearchItem(sourceFeed, list.map { SourceFeedCardItem(it) }) }
.map { list -> sourceFeed.withResults(list.mapNotNull { it.toDomainManga() }) }
},
5,
)
.observeOn(AndroidSchedulers.mainThread())
// Update matching source with the obtained results
.map { result ->
items.map { item -> if (item.sourceFeed == result.sourceFeed) result else item }
.doOnNext { result ->
synchronized(state) {
state.items = state.items?.map { item -> if (item.id == result.id) result else item }
}
}
// Update current state
.doOnNext { items = it }
// Deliver initial state
.startWith(initialItems)
.subscribeLatestCache(
{ view, manga ->
view.setItems(manga)
},
{ _, error ->
.subscribe(
{},
{ error ->
logcat(LogPriority.ERROR, error)
},
)
@ -249,7 +228,7 @@ open class SourceFeedPresenter(
*
* @param manga the list of manga to initialize.
*/
private fun fetchImage(manga: List<Manga>, source: Source, sourceFeed: SourceFeed) {
private fun fetchImage(manga: List<Manga>, source: Source, sourceFeed: SourceFeedUI) {
fetchImageSubject.onNext(Triple(manga, source, sourceFeed))
}
@ -270,8 +249,21 @@ open class SourceFeedPresenter(
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ (sourceFeed, manga) ->
@Suppress("DEPRECATION")
view?.onMangaInitialized(sourceFeed, manga)
synchronized(state) {
state.items = items?.map { itemUI ->
if (sourceFeed.id == itemUI.id) {
itemUI.withResults(
results = itemUI.results?.map {
if (it.id == manga.id) {
manga.toDomainManga()!!
} else {
it
}
},
)
} else itemUI
}
}
},
{ error ->
logcat(LogPriority.ERROR, error)