diff --git a/app/src/main/java/eu/kanade/presentation/browse/FeedScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/FeedScreen.kt new file mode 100644 index 000000000..b6193402e --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/browse/FeedScreen.kt @@ -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?, +) + +@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, + ) + } +} diff --git a/app/src/main/java/eu/kanade/presentation/browse/FeedState.kt b/app/src/main/java/eu/kanade/presentation/browse/FeedState.kt new file mode 100644 index 000000000..ca421c90c --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/browse/FeedState.kt @@ -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? +} + +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? by mutableStateOf(null) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/feed/FeedAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/feed/FeedAdapter.kt deleted file mode 100644 index 1276df67e..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/feed/FeedAdapter.kt +++ /dev/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(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 onSourceClick(source: CatalogueSource) - fun onSavedSearchClick(savedSearch: SavedSearch, source: CatalogueSource) - 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/feed/FeedCardAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/feed/FeedCardAdapter.kt deleted file mode 100644 index af5b37ca6..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/feed/FeedCardAdapter.kt +++ /dev/null @@ -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(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) - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/feed/FeedCardHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/feed/FeedCardHolder.kt deleted file mode 100644 index ee2e58040..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/feed/FeedCardHolder.kt +++ /dev/null @@ -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) - } - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/feed/FeedCardItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/feed/FeedCardItem.kt deleted file mode 100644 index 2c83a3d42..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/feed/FeedCardItem.kt +++ /dev/null @@ -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() { - - override fun getLayoutRes(): Int { - return R.layout.global_search_controller_card_item - } - - override fun createViewHolder(view: View, adapter: FlexibleAdapter>): FeedCardHolder { - return FeedCardHolder(view, adapter as FeedCardAdapter) - } - - override fun bindViewHolder( - adapter: FlexibleAdapter>, - holder: FeedCardHolder, - position: Int, - payloads: List?, - ) { - 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 - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/feed/FeedController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/feed/FeedController.kt index b6b7a0664..119010170 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/feed/FeedController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/feed/FeedController.kt @@ -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(), - FeedCardAdapter.OnMangaClickListener, - FeedAdapter.OnFeedClickListener { +class FeedController : ComposeController() { 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) { - 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) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/feed/FeedHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/feed/FeedHolder.kt deleted file mode 100644 index 6055a7f47..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/feed/FeedHolder.kt +++ /dev/null @@ -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? = 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 - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/feed/FeedItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/feed/FeedItem.kt deleted file mode 100644 index f0422ddbb..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/feed/FeedItem.kt +++ /dev/null @@ -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?, - 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 [FeedAdapter]. - * - * @return holder of view. - */ - override fun createViewHolder(view: View, adapter: FlexibleAdapter>): FeedHolder { - return FeedHolder(view, adapter as FeedAdapter) - } - - /** - * Bind item to view. - */ - override fun bindViewHolder( - adapter: FlexibleAdapter>, - holder: FeedHolder, - 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 FeedItem) { - return feed.id == other.feed.id - } - return false - } - - /** - * Return hash code of item. - * - * @return hashcode - */ - override fun hashCode(): Int { - return feed.id.toInt() - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/feed/FeedPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/feed/FeedPresenter.kt index b10b98f0e..aa08527ae 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/feed/FeedPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/feed/FeedPresenter.kt @@ -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() { +) : BasePresenter(), 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?, - ): FeedItem { - return FeedItem(feed, savedSearch, source, results) + results: List?, + ): 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) { + private fun getFeed(feedSavedSearch: List) { // 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)