diff --git a/app/src/main/java/eu/kanade/presentation/browse/SourceFeedScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/SourceFeedScreen.kt new file mode 100644 index 000000000..d4a504ae5 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/browse/SourceFeedScreen.kt @@ -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? + + abstract fun withResults(results: List?): SourceFeedUI + + data class Latest(override val results: List?) : SourceFeedUI() { + override val id: Long = -1 + override val title: String + @Composable + @ReadOnlyComposable + get() = stringResource(R.string.latest) + + override fun withResults(results: List?): SourceFeedUI { + return copy(results = results) + } + } + data class Browse(override val results: List?) : SourceFeedUI() { + override val id: Long = -2 + override val title: String + @Composable + @ReadOnlyComposable + get() = stringResource(R.string.browse) + + override fun withResults(results: List?): SourceFeedUI { + return copy(results = results) + } + } + data class SourceSavedSearch( + val feed: FeedSavedSearch, + val savedSearch: SavedSearch, + override val results: List?, + ) : SourceFeedUI() { + override val id: Long + get() = feed.id + + override val title: String + @Composable + @ReadOnlyComposable + get() = savedSearch.name + + override fun withResults(results: List?): 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, + ) + } + } + } + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/browse/SourceFeedState.kt b/app/src/main/java/eu/kanade/presentation/browse/SourceFeedState.kt new file mode 100644 index 000000000..13de0ae68 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/browse/SourceFeedState.kt @@ -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? +} + +fun SourceFeedState(): SourceFeedState { + return SourceFeedStateImpl() +} + +class SourceFeedStateImpl : SourceFeedState { + override var isLoading: Boolean by mutableStateOf(true) + override var items: List? by mutableStateOf(null) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/feed/SourceFeedAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/feed/SourceFeedAdapter.kt deleted file mode 100644 index cdca13abb..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/feed/SourceFeedAdapter.kt +++ /dev/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(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) { - 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() - 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(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" - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/feed/SourceFeedCardAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/feed/SourceFeedCardAdapter.kt deleted file mode 100644 index d0421877b..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/feed/SourceFeedCardAdapter.kt +++ /dev/null @@ -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(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) - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/feed/SourceFeedCardHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/feed/SourceFeedCardHolder.kt deleted file mode 100644 index 1c4f94101..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/feed/SourceFeedCardHolder.kt +++ /dev/null @@ -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) - } - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/feed/SourceFeedCardItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/feed/SourceFeedCardItem.kt deleted file mode 100644 index cbcc49708..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/feed/SourceFeedCardItem.kt +++ /dev/null @@ -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() { - - override fun getLayoutRes(): Int { - return R.layout.global_search_controller_card_item - } - - override fun createViewHolder(view: View, adapter: FlexibleAdapter>): SourceFeedCardHolder { - return SourceFeedCardHolder(view, adapter as SourceFeedCardAdapter) - } - - override fun bindViewHolder( - adapter: FlexibleAdapter>, - holder: SourceFeedCardHolder, - position: Int, - payloads: List?, - ) { - 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 - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/feed/SourceFeedController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/feed/SourceFeedController.kt index 6b67f9ee6..a2a6f907b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/feed/SourceFeedController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/feed/SourceFeedController.kt @@ -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, - FabController, - SourceFeedCardAdapter.OnMangaClickListener, - SourceFeedAdapter.OnFeedClickListener { + SearchableComposeController, + 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) { - 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) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/feed/SourceFeedHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/feed/SourceFeedHolder.kt deleted file mode 100644 index ed0d8187d..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/feed/SourceFeedHolder.kt +++ /dev/null @@ -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? = 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 - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/feed/SourceFeedItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/feed/SourceFeedItem.kt deleted file mode 100644 index 937667bae..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/feed/SourceFeedItem.kt +++ /dev/null @@ -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?, - val highlighted: Boolean = false, -) : AbstractFlexibleItem() { - - /** - * 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>): SourceFeedHolder { - return SourceFeedHolder(view, adapter as SourceFeedAdapter) - } - - /** - * Bind item to view. - */ - override fun bindViewHolder( - adapter: FlexibleAdapter>, - holder: SourceFeedHolder, - position: Int, - payloads: List?, - ) { - 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() - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/feed/SourceFeedPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/feed/SourceFeedPresenter.kt index 86aade67b..8de11ebfe 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/feed/SourceFeedPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/feed/SourceFeedPresenter.kt @@ -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() { +) : BasePresenter(), 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, Source, SourceFeed>>() + private val fetchImageSubject = PublishSubject.create, 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): List { + private suspend fun getSourcesToGetFeed(feedSavedSearch: List): List { 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?, - ): SourceFeedItem { - return SourceFeedItem(sourceFeed, results) + .map { SourceFeedUI.SourceSavedSearch(it, savedSearches[it.savedSearch]!!, null) } } /** * Initiates get manga per feed. */ - private suspend fun getFeed(feedSavedSearch: List) { + private fun getFeed(feedSavedSearch: List) { // 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, source: Source, sourceFeed: SourceFeed) { + private fun fetchImage(manga: List, 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)