Convert Feed to Jetpack Compose

This commit is contained in:
Jobobby04 2022-08-31 15:20:29 -04:00
parent 5ee6029395
commit 493a1ab4a6
10 changed files with 354 additions and 571 deletions

View File

@ -0,0 +1,241 @@
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.Box
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.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ContentAlpha
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
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 androidx.compose.ui.unit.sp
import eu.kanade.domain.manga.model.Manga
import eu.kanade.presentation.components.Badge
import eu.kanade.presentation.components.BadgeGroup
import eu.kanade.presentation.components.EmptyScreen
import eu.kanade.presentation.components.LoadingScreen
import eu.kanade.presentation.components.MangaCover
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.source.CatalogueSource
import eu.kanade.tachiyomi.ui.browse.feed.FeedPresenter
import exh.savedsearches.models.FeedSavedSearch
import exh.savedsearches.models.SavedSearch
import eu.kanade.domain.manga.model.MangaCover as MangaCoverData
data class FeedItemUI(
val feed: FeedSavedSearch,
val savedSearch: SavedSearch?,
val source: CatalogueSource?,
val title: String,
val subtitle: String,
val results: List<Manga>?,
)
@Composable
fun FeedScreen(
nestedScrollInterop: NestedScrollConnection,
presenter: FeedPresenter,
onClickSavedSearch: (SavedSearch, CatalogueSource) -> Unit,
onClickSource: (CatalogueSource) -> Unit,
onClickDelete: (FeedSavedSearch) -> Unit,
onClickManga: (Manga) -> Unit,
) {
when {
presenter.isLoading -> LoadingScreen()
presenter.isEmpty -> EmptyScreen(R.string.feed_tab_empty)
else -> {
FeedList(
nestedScrollConnection = nestedScrollInterop,
state = presenter,
onClickSavedSearch = onClickSavedSearch,
onClickSource = onClickSource,
onClickDelete = onClickDelete,
onClickManga = onClickManga,
)
}
}
}
@Composable
fun FeedList(
nestedScrollConnection: NestedScrollConnection,
state: FeedState,
onClickSavedSearch: (SavedSearch, CatalogueSource) -> Unit,
onClickSource: (CatalogueSource) -> Unit,
onClickDelete: (FeedSavedSearch) -> Unit,
onClickManga: (Manga) -> Unit,
) {
ScrollbarLazyColumn(
modifier = Modifier.nestedScroll(nestedScrollConnection),
contentPadding = bottomNavPaddingValues + WindowInsets.navigationBars.asPaddingValues() + topPaddingValues,
) {
items(
state.items.orEmpty(),
key = { it.feed.id },
) { item ->
FeedItem(
modifier = Modifier.animateItemPlacement(),
item = item,
onClickSavedSearch = onClickSavedSearch,
onClickSource = onClickSource,
onClickDelete = onClickDelete,
onClickManga = onClickManga,
)
}
}
}
@Composable
fun FeedItem(
modifier: Modifier,
item: FeedItemUI,
onClickSavedSearch: (SavedSearch, CatalogueSource) -> Unit,
onClickSource: (CatalogueSource) -> Unit,
onClickDelete: (FeedSavedSearch) -> Unit,
onClickManga: (Manga) -> Unit,
) {
Column(
modifier then Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Row(
Modifier
.fillMaxWidth()
.combinedClickable(
onLongClick = {
onClickDelete(item.feed)
},
onClick = {
if (item.savedSearch != null && item.source != null) {
onClickSavedSearch(item.savedSearch, item.source)
} else if (item.source != null) {
onClickSource(item.source)
}
},
),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.padding(start = 16.dp)) {
Text(
text = item.title,
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = item.subtitle,
style = MaterialTheme.typography.bodyMedium,
fontSize = 12.sp,
color = LocalContentColor.current.copy(alpha = ContentAlpha.high),
)
}
Icon(
painter = painterResource(R.drawable.ic_arrow_forward_24dp),
contentDescription = stringResource(R.string.label_more),
modifier = Modifier.padding(16.dp),
)
}
when {
item.results == null -> {
CircularProgressIndicator()
}
item.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(item.results) {
FeedCardItem(
manga = it,
onClickManga = onClickManga,
)
}
}
}
}
}
}
@Composable
fun FeedCardItem(
modifier: Modifier = Modifier,
manga: Manga,
onClickManga: (Manga) -> Unit,
) {
Column(
modifier
.padding(vertical = 4.dp)
.width(112.dp)
.clip(RoundedCornerShape(4.dp))
.clickable(onClick = { onClickManga(manga) })
.padding(4.dp),
) {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(MangaCover.Book.ratio),
) {
MangaCover.Book(
modifier = Modifier.fillMaxWidth()
.alpha(
if (manga.favorite) 0.3f else 1.0f,
),
data = MangaCoverData(
manga.id,
manga.source,
manga.favorite,
manga.thumbnailUrl,
manga.coverLastModified,
),
)
BadgeGroup(
modifier = Modifier
.padding(4.dp)
.align(Alignment.TopStart),
) {
if (manga.favorite) {
Badge(text = stringResource(R.string.in_library))
}
}
}
Text(
modifier = Modifier.padding(4.dp),
text = manga.title,
fontSize = 12.sp,
maxLines = 2,
style = MaterialTheme.typography.titleSmall,
)
}
}

View File

@ -0,0 +1,23 @@
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 FeedState {
val isLoading: Boolean
val isEmpty: Boolean
val items: List<FeedItemUI>?
}
fun FeedState(): FeedState {
return FeedStateImpl()
}
class FeedStateImpl : FeedState {
override var isLoading: Boolean by mutableStateOf(true)
override var isEmpty: Boolean by mutableStateOf(false)
override var items: List<FeedItemUI>? by mutableStateOf(null)
}

View File

@ -1,85 +0,0 @@
package eu.kanade.tachiyomi.ui.browse.feed
import android.os.Bundle
import android.os.Parcelable
import android.util.SparseArray
import androidx.recyclerview.widget.RecyclerView
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.kanade.tachiyomi.source.CatalogueSource
import exh.savedsearches.models.FeedSavedSearch
import exh.savedsearches.models.SavedSearch
/**
* Adapter that holds the search cards.
*
* @param controller instance of [FeedController].
*/
class FeedAdapter(val controller: FeedController) :
FlexibleAdapter<FeedItem>(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 onSourceClick(source: CatalogueSource)
fun onSavedSearchClick(savedSearch: SavedSearch, source: CatalogueSource)
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.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 [FeedController].
*/
class FeedCardAdapter(controller: FeedController) :
FlexibleAdapter<FeedCardItem>(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 [FeedController]
*/
interface OnMangaClickListener {
fun onMangaClick(manga: Manga)
fun onMangaLongClick(manga: Manga)
}
}

View File

@ -1,58 +0,0 @@
package eu.kanade.tachiyomi.ui.browse.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 FeedCardHolder(view: View, adapter: FeedCardAdapter) :
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.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 FeedCardItem(val manga: Manga) : AbstractFlexibleItem<FeedCardHolder>() {
override fun getLayoutRes(): Int {
return R.layout.global_search_controller_card_item
}
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): FeedCardHolder {
return FeedCardHolder(view, adapter as FeedCardAdapter)
}
override fun bindViewHolder(
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
holder: FeedCardHolder,
position: Int,
payloads: List<Any?>?,
) {
holder.bind(manga)
}
override fun equals(other: Any?): Boolean {
if (other is FeedCardItem) {
return manga.id == other.manga.id
}
return false
}
override fun hashCode(): Int {
return manga.id?.toInt() ?: 0
}
}

View File

@ -1,19 +1,15 @@
package eu.kanade.tachiyomi.ui.browse.feed
import android.os.Bundle
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.compose.runtime.Composable
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dev.chrisbanes.insetter.applyInsetter
import eu.kanade.presentation.browse.FeedScreen
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.ui.base.controller.NucleusController
import eu.kanade.tachiyomi.ui.base.controller.ComposeController
import eu.kanade.tachiyomi.ui.base.controller.pushController
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController
import eu.kanade.tachiyomi.ui.browse.source.latest.LatestUpdatesController
@ -28,20 +24,12 @@ import exh.savedsearches.models.SavedSearch
* This controller should only handle UI actions, IO actions should be done by [FeedPresenter]
* [FeedCardAdapter.OnMangaClickListener] called when manga is clicked in global search
*/
open class FeedController :
NucleusController<GlobalSearchControllerBinding, FeedPresenter>(),
FeedCardAdapter.OnMangaClickListener,
FeedAdapter.OnFeedClickListener {
class FeedController : ComposeController<FeedPresenter>() {
init {
setHasOptionsMenu(true)
}
/**
* Adapter containing search results grouped by lang.
*/
protected var adapter: FeedAdapter? = null
override fun getTitle(): String? {
return applicationContext?.getString(R.string.feed)
}
@ -55,6 +43,18 @@ open class FeedController :
return FeedPresenter()
}
@Composable
override fun ComposeContent(nestedScrollInterop: NestedScrollConnection) {
FeedScreen(
nestedScrollInterop = nestedScrollInterop,
presenter = presenter,
onClickSavedSearch = ::onSavedSearchClick,
onClickSource = ::onSourceClick,
onClickDelete = ::onRemoveClick,
onClickManga = ::onMangaClick,
)
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.feed, menu)
}
@ -113,116 +113,25 @@ open class FeedController :
*
* @param manga clicked item containing manga information.
*/
override fun onMangaClick(manga: Manga) {
private fun onMangaClick(manga: eu.kanade.domain.manga.model.Manga) {
// Open MangaController.
parentController?.router?.pushController(MangaController(manga.id!!, true))
}
/**
* 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)
}
override fun createBinding(inflater: LayoutInflater): GlobalSearchControllerBinding = GlobalSearchControllerBinding.inflate(inflater)
/**
* Called when the view is created
*
* @param view view of controller
*/
override fun onViewCreated(view: View) {
super.onViewCreated(view)
binding.recycler.applyInsetter {
type(navigationBars = true) {
padding()
}
}
adapter = FeedAdapter(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)
}
/**
* 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(feed: FeedSavedSearch): FeedHolder? {
val adapter = adapter ?: return null
adapter.allBoundViewHolders.forEach { holder ->
val item = adapter.getItem(holder.bindingAdapterPosition)
if (item != null && feed.id == item.feed.id) {
return holder as FeedHolder
}
}
return null
}
/**
* Add search result to adapter.
*
* @param feedManga the source items containing the latest manga.
*/
fun setItems(feedManga: List<FeedItem>) {
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(feed: FeedSavedSearch, manga: Manga) {
getHolder(feed)?.setImage(manga)
parentController?.router?.pushController(MangaController(manga.id, true))
}
/**
* Opens a catalogue with the given search.
*/
override fun onSourceClick(source: CatalogueSource) {
private fun onSourceClick(source: CatalogueSource) {
presenter.preferences.lastUsedSource().set(source.id)
parentController?.router?.pushController(LatestUpdatesController(source))
}
override fun onSavedSearchClick(savedSearch: SavedSearch, source: CatalogueSource) {
private fun onSavedSearchClick(savedSearch: SavedSearch, source: CatalogueSource) {
presenter.preferences.lastUsedSource().set(savedSearch.source)
parentController?.router?.pushController(BrowseSourceController(source, savedSearch = savedSearch.id))
}
override fun onRemoveClick(feedSavedSearch: FeedSavedSearch) {
private fun onRemoveClick(feedSavedSearch: FeedSavedSearch) {
MaterialAlertDialogBuilder(activity!!)
.setTitle(R.string.feed)
.setMessage(R.string.feed_delete)

View File

@ -1,128 +0,0 @@
package eu.kanade.tachiyomi.ui.browse.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.data.database.models.Manga
import eu.kanade.tachiyomi.databinding.GlobalSearchControllerCardBinding
import eu.kanade.tachiyomi.util.system.LocaleHelper
/**
* Holder that binds the [FeedItem] containing catalogue cards.
*
* @param view view of [FeedItem]
* @param adapter instance of [FeedAdapter]
*/
class FeedHolder(view: View, val adapter: FeedAdapter) :
FlexibleViewHolder(view, adapter) {
private val binding = GlobalSearchControllerCardBinding.bind(view)
/**
* Adapter containing manga from search results.
*/
private val mangaAdapter = FeedCardAdapter(adapter.controller)
private var lastBoundResults: List<FeedCardItem>? = 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 {
if (it.savedSearch != null) {
adapter.feedClickListener.onSavedSearchClick(it.savedSearch, it.source ?: return@let)
} else {
adapter.feedClickListener.onSourceClick(it.source ?: return@let)
}
}
}
binding.titleWrapper.setOnLongClickListener {
adapter.getItem(bindingAdapterPosition)?.let {
adapter.feedClickListener.onRemoveClick(it.feed)
}
true
}
}
/**
* Show the loading of source search result.
*
* @param item item of card.
*/
@SuppressLint("SetTextI18n")
fun bind(item: FeedItem) {
val results = item.results
val titlePrefix = if (item.highlighted) "" else ""
binding.title.text = titlePrefix + if (item.savedSearch != null) {
item.savedSearch.name
} else {
item.source?.name ?: item.feed.source.toString()
}
binding.subtitle.isVisible = true
binding.subtitle.text = if (item.savedSearch != null) {
item.source?.name ?: item.feed.source.toString()
} else {
LocaleHelper.getDisplayName(item.source?.lang)
}
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): FeedCardHolder? {
mangaAdapter.allBoundViewHolders.forEach { holder ->
val item = mangaAdapter.getItem(holder.bindingAdapterPosition)
if (item != null && item.manga.id!! == manga.id!!) {
return holder as FeedCardHolder
}
}
return null
}
private fun showResultsHolder() {
binding.noResultsFound.isVisible = false
}
private fun showNoResults() {
binding.noResultsFound.isVisible = true
}
}

View File

@ -1,78 +0,0 @@
package eu.kanade.tachiyomi.ui.browse.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.source.CatalogueSource
import exh.savedsearches.models.FeedSavedSearch
import exh.savedsearches.models.SavedSearch
/**
* 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 FeedItem(
val feed: FeedSavedSearch,
val savedSearch: SavedSearch?,
val source: CatalogueSource?,
val results: List<FeedCardItem>?,
val highlighted: Boolean = false,
) : AbstractFlexibleItem<FeedHolder>() {
/**
* Set view.
*
* @return id of view
*/
override fun getLayoutRes(): Int {
return R.layout.global_search_controller_card
}
/**
* Create view holder (see [FeedAdapter].
*
* @return holder of view.
*/
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): FeedHolder {
return FeedHolder(view, adapter as FeedAdapter)
}
/**
* Bind item to view.
*/
override fun bindViewHolder(
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
holder: FeedHolder,
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 FeedItem) {
return feed.id == other.feed.id
}
return false
}
/**
* Return hash code of item.
*
* @return hashcode
*/
override fun hashCode(): Int {
return feed.id.toInt()
}
}

View File

@ -12,6 +12,9 @@ import eu.kanade.domain.source.interactor.GetFeedSavedSearchGlobal
import eu.kanade.domain.source.interactor.GetSavedSearchBySourceId
import eu.kanade.domain.source.interactor.GetSavedSearchGlobalFeed
import eu.kanade.domain.source.interactor.InsertFeedSavedSearch
import eu.kanade.presentation.browse.FeedItemUI
import eu.kanade.presentation.browse.FeedState
import eu.kanade.presentation.browse.FeedStateImpl
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.toDomainManga
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
@ -24,9 +27,11 @@ import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.runAsObservable
import eu.kanade.tachiyomi.util.system.LocaleHelper
import eu.kanade.tachiyomi.util.system.logcat
import exh.savedsearches.models.FeedSavedSearch
import exh.savedsearches.models.SavedSearch
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.runBlocking
@ -50,6 +55,7 @@ import xyz.nulldev.ts.api.http.serializer.FilterSerializer
* @param preferences manages the preference calls.
*/
open class FeedPresenter(
private val state: FeedStateImpl = FeedState() as FeedStateImpl,
val sourceManager: SourceManager = Injekt.get(),
val preferences: PreferencesHelper = Injekt.get(),
private val getManga: GetManga = Injekt.get(),
@ -61,7 +67,7 @@ open class FeedPresenter(
private val getSavedSearchBySourceId: GetSavedSearchBySourceId = Injekt.get(),
private val insertFeedSavedSearch: InsertFeedSavedSearch = Injekt.get(),
private val deleteFeedSavedSearchById: DeleteFeedSavedSearchById = Injekt.get(),
) : BasePresenter<FeedController>() {
) : BasePresenter<FeedController>(), FeedState by state {
/**
* Fetches the different sources by user settings.
@ -82,8 +88,20 @@ open class FeedPresenter(
super.onCreate(savedState)
getFeedSavedSearchGlobal.subscribe()
.distinctUntilChanged()
.onEach {
getFeed(it)
val items = getSourcesToGetFeed(it).map { (feed, savedSearch) ->
createCatalogueSearchItem(
feed = feed,
savedSearch = savedSearch,
source = sourceManager.get(feed.source) as? CatalogueSource,
results = null,
)
}
state.items = items
state.isEmpty = items.isEmpty()
state.isLoading = false
getFeed(items)
}
.launchIn(presenterScope)
}
@ -142,72 +160,67 @@ open class FeedPresenter(
/**
* Creates a catalogue search item
*/
protected open fun createCatalogueSearchItem(
private fun createCatalogueSearchItem(
feed: FeedSavedSearch,
savedSearch: SavedSearch?,
source: CatalogueSource?,
results: List<FeedCardItem>?,
): FeedItem {
return FeedItem(feed, savedSearch, source, results)
results: List<eu.kanade.domain.manga.model.Manga>?,
): FeedItemUI {
return FeedItemUI(
feed,
savedSearch,
source,
savedSearch?.name ?: (source?.name ?: feed.source.toString()),
if (savedSearch != null) {
source?.name ?: feed.source.toString()
} else {
LocaleHelper.getDisplayName(source?.lang)
},
results,
)
}
/**
* Initiates get manga per feed.
*/
private suspend fun getFeed(feedSavedSearch: List<FeedSavedSearch>) {
private fun getFeed(feedSavedSearch: List<FeedItemUI>) {
// Create image fetch subscription
initializeFetchImageSubscription()
// Create items with the initial state
val initialItems = getSourcesToGetFeed(feedSavedSearch).map { (feed, savedSearch) ->
createCatalogueSearchItem(
feed,
savedSearch,
sourceManager.get(feed.source) as? CatalogueSource,
null,
)
}
var items = initialItems
fetchSourcesSubscription?.unsubscribe()
fetchSourcesSubscription = Observable.from(getSourcesToGetFeed(feedSavedSearch))
fetchSourcesSubscription = Observable.from(feedSavedSearch)
.flatMap(
{ (feed, savedSearch) ->
val source = sourceManager.get(feed.source) as? CatalogueSource
if (source != null) {
{ itemUI ->
if (itemUI.source != null) {
Observable.defer {
if (savedSearch == null) {
source.fetchLatestUpdates(1)
if (itemUI.savedSearch == null) {
itemUI.source.fetchLatestUpdates(1)
} else {
source.fetchSearchManga(1, savedSearch.query.orEmpty(), getFilterList(savedSearch, source))
itemUI.source.fetchSearchManga(1, itemUI.savedSearch.query.orEmpty(), getFilterList(itemUI.savedSearch, itemUI.source))
}
}
.subscribeOn(Schedulers.io())
.onErrorReturn { MangasPage(emptyList(), false) } // Ignore timeouts or other exceptions
.map { it.mangas } // Get manga from search result.
.map { list -> list.map { networkToLocalManga(it, source.id) } } // Convert to local manga.
.doOnNext { fetchImage(it, source, feed) } // Load manga covers.
.map { list -> createCatalogueSearchItem(feed, savedSearch, source, list.map { FeedCardItem(it) }) }
.map { list -> list.map { networkToLocalManga(it, itemUI.source.id) } } // Convert to local manga.
.doOnNext { fetchImage(it, itemUI.source, itemUI.feed) } // Load manga covers.
.map { list -> itemUI.copy(results = list.mapNotNull { it.toDomainManga() }) }
} else {
Observable.just(createCatalogueSearchItem(feed, null, null, emptyList()))
Observable.just(itemUI.copy(results = emptyList()))
}
},
5,
)
.observeOn(AndroidSchedulers.mainThread())
// Update matching source with the obtained results
.map { result ->
items.map { item -> if (item.feed == result.feed) result else item }
.doOnNext { result ->
synchronized(state) {
state.items = state.items?.map { if (it.feed.id == result.feed.id) result else it }
}
}
// Update current state
.doOnNext { items = it }
// Deliver initial state
.startWith(initialItems)
.subscribeLatestCache(
{ view, manga ->
view.setItems(manga)
},
{ _, error ->
.subscribe(
{},
{ error ->
logcat(LogPriority.ERROR, error)
},
)
@ -253,8 +266,21 @@ open class FeedPresenter(
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ (feed, manga) ->
@Suppress("DEPRECATION")
view?.onMangaInitialized(feed, manga)
synchronized(state) {
state.items = items?.map { itemUI ->
if (feed.id == itemUI.feed.id) {
itemUI.copy(
results = itemUI.results?.map {
if (it.id == manga.id) {
manga.toDomainManga()!!
} else {
it
}
},
)
} else itemUI
}
}
},
{ error ->
logcat(LogPriority.ERROR, error)