Convert Feed to Jetpack Compose
This commit is contained in:
parent
5ee6029395
commit
493a1ab4a6
241
app/src/main/java/eu/kanade/presentation/browse/FeedScreen.kt
Normal file
241
app/src/main/java/eu/kanade/presentation/browse/FeedScreen.kt
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
23
app/src/main/java/eu/kanade/presentation/browse/FeedState.kt
Normal file
23
app/src/main/java/eu/kanade/presentation/browse/FeedState.kt
Normal 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)
|
||||
}
|
@ -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"
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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)
|
||||
|
@ -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
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
@ -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)
|
||||
|
Loading…
x
Reference in New Issue
Block a user