Convert Source Feed to Jetpack Compose
This commit is contained in:
parent
493a1ab4a6
commit
a760198981
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
@ -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"
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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)
|
||||
|
@ -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
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
@ -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)
|
||||
|
Loading…
x
Reference in New Issue
Block a user