Add feed to the combined sources menus
This commit is contained in:
parent
6a41d96ddf
commit
d0e9d24f6f
@ -77,13 +77,26 @@ fun getReadMangaNotInLibraryQuery() =
|
||||
"""
|
||||
|
||||
/**
|
||||
* Query to get the manga merged into a merged manga
|
||||
* Query to get the global feed saved searches
|
||||
*/
|
||||
fun getFeedSavedSearchQuery() =
|
||||
fun getGlobalFeedSavedSearchQuery() =
|
||||
"""
|
||||
SELECT ${SavedSearchTable.TABLE}.*
|
||||
FROM (
|
||||
SELECT ${FeedSavedSearchTable.COL_SAVED_SEARCH_ID} FROM ${FeedSavedSearchTable.TABLE}
|
||||
SELECT ${FeedSavedSearchTable.COL_SAVED_SEARCH_ID} FROM ${FeedSavedSearchTable.TABLE} WHERE ${FeedSavedSearchTable.COL_GLOBAL} = 1
|
||||
) AS M
|
||||
JOIN ${SavedSearchTable.TABLE}
|
||||
ON ${SavedSearchTable.TABLE}.${SavedSearchTable.COL_ID} = M.${FeedSavedSearchTable.COL_SAVED_SEARCH_ID}
|
||||
"""
|
||||
|
||||
/**
|
||||
* Query to get the source feed saved searches
|
||||
*/
|
||||
fun getSourceFeedSavedSearchQuery() =
|
||||
"""
|
||||
SELECT ${SavedSearchTable.TABLE}.*
|
||||
FROM (
|
||||
SELECT ${FeedSavedSearchTable.COL_SAVED_SEARCH_ID} FROM ${FeedSavedSearchTable.TABLE} WHERE ${FeedSavedSearchTable.COL_GLOBAL} = 0 AND ${FeedSavedSearchTable.COL_SOURCE} = ?
|
||||
) AS M
|
||||
JOIN ${SavedSearchTable.TABLE}
|
||||
ON ${SavedSearchTable.TABLE}.${SavedSearchTable.COL_ID} = M.${FeedSavedSearchTable.COL_SAVED_SEARCH_ID}
|
||||
|
@ -167,12 +167,12 @@ open class FeedController :
|
||||
* @param source used to find holder containing source
|
||||
* @return the holder of the manga or null if it's not bound.
|
||||
*/
|
||||
private fun getHolder(source: CatalogueSource): FeedHolder? {
|
||||
private fun getHolder(feed: FeedSavedSearch): FeedHolder? {
|
||||
val adapter = adapter ?: return null
|
||||
|
||||
adapter.allBoundViewHolders.forEach { holder ->
|
||||
val item = adapter.getItem(holder.bindingAdapterPosition)
|
||||
if (item != null && source.id == item.feed.id) {
|
||||
if (item != null && feed.id == item.feed.id) {
|
||||
return holder as FeedHolder
|
||||
}
|
||||
}
|
||||
@ -200,8 +200,8 @@ open class FeedController :
|
||||
*
|
||||
* @param manga the initialized manga.
|
||||
*/
|
||||
fun onMangaInitialized(source: CatalogueSource, manga: Manga) {
|
||||
getHolder(source)?.setImage(manga)
|
||||
fun onMangaInitialized(feed: FeedSavedSearch, manga: Manga) {
|
||||
getHolder(feed)?.setImage(manga)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -7,7 +7,6 @@ import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
|
||||
import eu.davidea.flexibleadapter.items.IFlexible
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.source.CatalogueSource
|
||||
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchItem
|
||||
import exh.savedsearches.models.FeedSavedSearch
|
||||
import exh.savedsearches.models.SavedSearch
|
||||
|
||||
@ -62,8 +61,8 @@ class FeedItem(
|
||||
* @return items are equal?
|
||||
*/
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other is GlobalSearchItem) {
|
||||
return feed.id == other.source.id
|
||||
if (other is FeedItem) {
|
||||
return feed.id == other.feed.id
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
@ -52,7 +52,7 @@ open class FeedPresenter(
|
||||
/**
|
||||
* Subject which fetches image of given manga.
|
||||
*/
|
||||
private val fetchImageSubject = PublishSubject.create<Pair<List<Manga>, Source>>()
|
||||
private val fetchImageSubject = PublishSubject.create<Triple<List<Manga>, Source, FeedSavedSearch>>()
|
||||
|
||||
/**
|
||||
* Subscription for fetching images of manga.
|
||||
@ -62,7 +62,7 @@ open class FeedPresenter(
|
||||
override fun onCreate(savedState: Bundle?) {
|
||||
super.onCreate(savedState)
|
||||
|
||||
db.getFeedSavedSearches()
|
||||
db.getGlobalFeedSavedSearches()
|
||||
.asRxObservable()
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.doOnEach {
|
||||
@ -79,7 +79,7 @@ open class FeedPresenter(
|
||||
}
|
||||
|
||||
fun hasTooManyFeeds(): Boolean {
|
||||
return db.getFeedSavedSearches().executeAsBlocking().size > 10
|
||||
return db.getGlobalFeedSavedSearches().executeAsBlocking().size > 10
|
||||
}
|
||||
|
||||
fun getEnabledSources(): List<CatalogueSource> {
|
||||
@ -103,7 +103,8 @@ open class FeedPresenter(
|
||||
FeedSavedSearch(
|
||||
id = null,
|
||||
source = source.id,
|
||||
savedSearch = savedSearch?.id
|
||||
savedSearch = savedSearch?.id,
|
||||
global = true
|
||||
)
|
||||
).executeAsBlocking()
|
||||
}
|
||||
@ -116,9 +117,9 @@ open class FeedPresenter(
|
||||
}
|
||||
|
||||
private fun getSourcesToGetFeed(): List<Pair<FeedSavedSearch, SavedSearch?>> {
|
||||
val savedSearches = db.getSavedSearchesFeed().executeAsBlocking()
|
||||
val savedSearches = db.getGlobalSavedSearchesFeed().executeAsBlocking()
|
||||
.associateBy { it.id!! }
|
||||
return db.getFeedSavedSearches().executeAsBlocking()
|
||||
return db.getGlobalFeedSavedSearches().executeAsBlocking()
|
||||
.map { it to savedSearches[it.savedSearch] }
|
||||
}
|
||||
|
||||
@ -169,7 +170,7 @@ open class FeedPresenter(
|
||||
.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) } // Load manga covers.
|
||||
.doOnNext { fetchImage(it, source, feed) } // Load manga covers.
|
||||
.map { list -> createCatalogueSearchItem(feed, savedSearch, source, list.map { FeedCardItem(it) }) }
|
||||
} else {
|
||||
Observable.just(createCatalogueSearchItem(feed, null, null, emptyList()))
|
||||
@ -215,8 +216,8 @@ open class FeedPresenter(
|
||||
*
|
||||
* @param manga the list of manga to initialize.
|
||||
*/
|
||||
private fun fetchImage(manga: List<Manga>, source: Source) {
|
||||
fetchImageSubject.onNext(Pair(manga, source))
|
||||
private fun fetchImage(manga: List<Manga>, source: CatalogueSource, feed: FeedSavedSearch) {
|
||||
fetchImageSubject.onNext(Triple(manga, source, feed))
|
||||
}
|
||||
|
||||
/**
|
||||
@ -230,14 +231,14 @@ open class FeedPresenter(
|
||||
Observable.from(pair.first).filter { it.thumbnail_url == null && !it.initialized }
|
||||
.map { Pair(it, source) }
|
||||
.concatMap { getMangaDetailsObservable(it.first, it.second) }
|
||||
.map { Pair(source as CatalogueSource, it) }
|
||||
.map { Pair(pair.third, it) }
|
||||
}
|
||||
.onBackpressureBuffer()
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{ (source, manga) ->
|
||||
{ (feed, manga) ->
|
||||
@Suppress("DEPRECATION")
|
||||
view?.onMangaInitialized(source, manga)
|
||||
view?.onMangaInitialized(feed, manga)
|
||||
},
|
||||
{ error ->
|
||||
logcat(LogPriority.ERROR, error)
|
||||
|
@ -29,8 +29,8 @@ import eu.kanade.tachiyomi.ui.base.controller.requestPermissionsSafe
|
||||
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
|
||||
import eu.kanade.tachiyomi.ui.browse.BrowseController
|
||||
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController
|
||||
import eu.kanade.tachiyomi.ui.browse.source.feed.SourceFeedController
|
||||
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController
|
||||
import eu.kanade.tachiyomi.ui.browse.source.index.IndexController
|
||||
import eu.kanade.tachiyomi.ui.browse.source.latest.LatestUpdatesController
|
||||
import eu.kanade.tachiyomi.ui.category.sources.ChangeSourceCategoriesDialog
|
||||
import eu.kanade.tachiyomi.ui.main.MainActivity
|
||||
@ -145,7 +145,7 @@ class SourceController(bundle: Bundle? = null) :
|
||||
// Open the catalogue view.
|
||||
// SY -->
|
||||
if (source.supportsLatest && preferences.useNewSourceNavigation().get()) {
|
||||
openIndexSource(source)
|
||||
openSourceFeed(source)
|
||||
} else openSource(source, BrowseSourceController(source))
|
||||
// SY <--
|
||||
}
|
||||
@ -307,11 +307,11 @@ class SourceController(bundle: Bundle? = null) :
|
||||
|
||||
// SY -->
|
||||
/**
|
||||
* Opens a catalogue with the index controller.
|
||||
* Opens a catalogue with the source feed controller.
|
||||
*/
|
||||
private fun openIndexSource(source: CatalogueSource) {
|
||||
private fun openSourceFeed(source: CatalogueSource) {
|
||||
preferences.lastUsedSource().set(source.id)
|
||||
parentController!!.router.pushController(IndexController(source).withFadeTransaction())
|
||||
parentController!!.router.pushController(SourceFeedController(source).withFadeTransaction())
|
||||
}
|
||||
// SY <--
|
||||
|
||||
|
@ -0,0 +1,85 @@
|
||||
package eu.kanade.tachiyomi.ui.browse.source.feed
|
||||
|
||||
import android.os.Bundle
|
||||
import android.os.Parcelable
|
||||
import android.util.SparseArray
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
||||
import exh.savedsearches.models.FeedSavedSearch
|
||||
import exh.savedsearches.models.SavedSearch
|
||||
|
||||
/**
|
||||
* Adapter that holds the search cards.
|
||||
*
|
||||
* @param controller instance of [SourceFeedController].
|
||||
*/
|
||||
class SourceFeedAdapter(val controller: SourceFeedController) :
|
||||
FlexibleAdapter<SourceFeedItem>(null, controller, true) {
|
||||
|
||||
val feedClickListener: OnFeedClickListener = controller
|
||||
|
||||
/**
|
||||
* Bundle where the view state of the holders is saved.
|
||||
*/
|
||||
private var bundle = Bundle()
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int, payloads: List<Any?>) {
|
||||
super.onBindViewHolder(holder, position, payloads)
|
||||
restoreHolderState(holder)
|
||||
}
|
||||
|
||||
override fun onViewRecycled(holder: RecyclerView.ViewHolder) {
|
||||
super.onViewRecycled(holder)
|
||||
saveHolderState(holder, bundle)
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
val holdersBundle = Bundle()
|
||||
allBoundViewHolders.forEach { saveHolderState(it, holdersBundle) }
|
||||
outState.putBundle(HOLDER_BUNDLE_KEY, holdersBundle)
|
||||
super.onSaveInstanceState(outState)
|
||||
}
|
||||
|
||||
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
|
||||
super.onRestoreInstanceState(savedInstanceState)
|
||||
bundle = savedInstanceState.getBundle(HOLDER_BUNDLE_KEY)!!
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the view state of the given holder.
|
||||
*
|
||||
* @param holder The holder to save.
|
||||
* @param outState The bundle where the state is saved.
|
||||
*/
|
||||
private fun saveHolderState(holder: RecyclerView.ViewHolder, outState: Bundle) {
|
||||
val key = "holder_${holder.bindingAdapterPosition}"
|
||||
val holderState = SparseArray<Parcelable>()
|
||||
holder.itemView.saveHierarchyState(holderState)
|
||||
outState.putSparseParcelableArray(key, holderState)
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores the view state of the given holder.
|
||||
*
|
||||
* @param holder The holder to restore.
|
||||
*/
|
||||
private fun restoreHolderState(holder: RecyclerView.ViewHolder) {
|
||||
val key = "holder_${holder.bindingAdapterPosition}"
|
||||
val holderState = bundle.getSparseParcelableArray<Parcelable>(key)
|
||||
if (holderState != null) {
|
||||
holder.itemView.restoreHierarchyState(holderState)
|
||||
bundle.remove(key)
|
||||
}
|
||||
}
|
||||
|
||||
interface OnFeedClickListener {
|
||||
fun onLatestClick()
|
||||
fun onBrowseClick()
|
||||
fun onSavedSearchClick(savedSearch: SavedSearch)
|
||||
fun onRemoveClick(feedSavedSearch: FeedSavedSearch)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val HOLDER_BUNDLE_KEY = "holder_bundle"
|
||||
}
|
||||
}
|
@ -0,0 +1,27 @@
|
||||
package eu.kanade.tachiyomi.ui.browse.source.feed
|
||||
|
||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
|
||||
/**
|
||||
* Adapter that holds the manga items from search results.
|
||||
*
|
||||
* @param controller instance of [SourceFeedController].
|
||||
*/
|
||||
class SourceFeedCardAdapter(controller: SourceFeedController) :
|
||||
FlexibleAdapter<SourceFeedCardItem>(null, controller, true) {
|
||||
|
||||
/**
|
||||
* Listen for browse item clicks.
|
||||
*/
|
||||
val mangaClickListener: OnMangaClickListener = controller
|
||||
|
||||
/**
|
||||
* Listener which should be called when user clicks browse.
|
||||
* Note: Should only be handled by [SourceFeedController]
|
||||
*/
|
||||
interface OnMangaClickListener {
|
||||
fun onMangaClick(manga: Manga)
|
||||
fun onMangaLongClick(manga: Manga)
|
||||
}
|
||||
}
|
@ -0,0 +1,58 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,40 @@
|
||||
package eu.kanade.tachiyomi.ui.browse.source.feed
|
||||
|
||||
import android.view.View
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
||||
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
|
||||
import eu.davidea.flexibleadapter.items.IFlexible
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
|
||||
class SourceFeedCardItem(val manga: Manga) : AbstractFlexibleItem<SourceFeedCardHolder>() {
|
||||
|
||||
override fun getLayoutRes(): Int {
|
||||
return R.layout.global_search_controller_card_item
|
||||
}
|
||||
|
||||
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): SourceFeedCardHolder {
|
||||
return SourceFeedCardHolder(view, adapter as SourceFeedCardAdapter)
|
||||
}
|
||||
|
||||
override fun bindViewHolder(
|
||||
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
|
||||
holder: SourceFeedCardHolder,
|
||||
position: Int,
|
||||
payloads: List<Any?>?
|
||||
) {
|
||||
holder.bind(manga)
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other is SourceFeedCardItem) {
|
||||
return manga.id == other.manga.id
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return manga.id?.toInt() ?: 0
|
||||
}
|
||||
}
|
@ -0,0 +1,345 @@
|
||||
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.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.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.databinding.LatestControllerBinding
|
||||
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.withFadeTransaction
|
||||
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController
|
||||
import eu.kanade.tachiyomi.ui.browse.source.browse.SourceFilterSheet
|
||||
import eu.kanade.tachiyomi.ui.browse.source.latest.LatestUpdatesController
|
||||
import eu.kanade.tachiyomi.ui.manga.MangaController
|
||||
import eu.kanade.tachiyomi.util.system.toast
|
||||
import exh.savedsearches.models.FeedSavedSearch
|
||||
import exh.savedsearches.models.SavedSearch
|
||||
import exh.util.nullIfBlank
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import xyz.nulldev.ts.api.http.serializer.FilterSerializer
|
||||
|
||||
/**
|
||||
* This controller shows and manages the different search result in global search.
|
||||
* This controller should only handle UI actions, IO actions should be done by [SourceFeedPresenter]
|
||||
* [SourceFeedCardAdapter.OnMangaClickListener] called when manga is clicked in global search
|
||||
*/
|
||||
open class SourceFeedController :
|
||||
SearchableNucleusController<LatestControllerBinding, SourceFeedPresenter>,
|
||||
FabController,
|
||||
SourceFeedCardAdapter.OnMangaClickListener,
|
||||
SourceFeedAdapter.OnFeedClickListener {
|
||||
|
||||
constructor(source: CatalogueSource?) : super(
|
||||
bundleOf(
|
||||
SOURCE_EXTRA to (source?.id ?: 0)
|
||||
)
|
||||
) {
|
||||
this.source = source
|
||||
}
|
||||
|
||||
constructor(sourceId: Long) : this(
|
||||
Injekt.get<SourceManager>().get(sourceId) as? CatalogueSource
|
||||
)
|
||||
|
||||
@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
|
||||
|
||||
/**
|
||||
* Sheet containing filter items.
|
||||
*/
|
||||
private var filterSheet: SourceFilterSheet? = null
|
||||
|
||||
init {
|
||||
setHasOptionsMenu(true)
|
||||
}
|
||||
|
||||
override fun getTitle(): String? {
|
||||
return source!!.name
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the [SourceFeedPresenter] used in controller.
|
||||
*
|
||||
* @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.
|
||||
parentController?.router?.pushController(MangaController(manga, 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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds items to the options menu.
|
||||
*
|
||||
* @param menu menu containing options.
|
||||
* @param inflater used to load the menu xml.
|
||||
*/
|
||||
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||
createOptionsMenu(menu, inflater, R.menu.global_search, R.id.action_search)
|
||||
}
|
||||
|
||||
override fun onSearchViewQueryTextSubmit(query: String?) {
|
||||
onBrowseClick(query.nullIfBlank())
|
||||
}
|
||||
|
||||
override fun onSearchViewQueryTextChange(newText: String?) {
|
||||
if (router.backstack.lastOrNull()?.controller == this) {
|
||||
presenter.query = newText ?: ""
|
||||
}
|
||||
}
|
||||
|
||||
override fun createBinding(inflater: LayoutInflater): LatestControllerBinding = LatestControllerBinding.inflate(inflater)
|
||||
|
||||
/**
|
||||
* Called when the view is created
|
||||
*
|
||||
* @param view view of controller
|
||||
*/
|
||||
override fun onViewCreated(view: View) {
|
||||
super.onViewCreated(view)
|
||||
|
||||
// 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()
|
||||
|
||||
fun initFilterSheet() {
|
||||
if (presenter.sourceFilters.isEmpty()) {
|
||||
actionFab?.text = activity!!.getString(R.string.saved_searches)
|
||||
}
|
||||
|
||||
filterSheet = SourceFilterSheet(
|
||||
activity!!,
|
||||
// SY -->
|
||||
this,
|
||||
presenter.source,
|
||||
presenter.loadSearches(),
|
||||
// SY <--
|
||||
onFilterClicked = {
|
||||
val allDefault = presenter.sourceFilters == presenter.source.getFilterList()
|
||||
filterSheet?.dismiss()
|
||||
if (allDefault) {
|
||||
onBrowseClick(
|
||||
presenter.query.nullIfBlank()
|
||||
)
|
||||
} else {
|
||||
onBrowseClick(
|
||||
presenter.query.nullIfBlank(),
|
||||
filters = Json.encodeToString(filterSerializer.serialize(presenter.sourceFilters))
|
||||
)
|
||||
}
|
||||
},
|
||||
onResetClicked = {},
|
||||
onSaveClicked = {},
|
||||
onSavedSearchClicked = cb@{ idOfSearch ->
|
||||
val search = presenter.loadSearch(idOfSearch)
|
||||
|
||||
if (search == null) {
|
||||
filterSheet?.context?.let {
|
||||
MaterialAlertDialogBuilder(it)
|
||||
.setTitle(R.string.save_search_failed_to_load)
|
||||
.setMessage(R.string.save_search_failed_to_load_message)
|
||||
.show()
|
||||
}
|
||||
return@cb
|
||||
}
|
||||
|
||||
if (search.filterList == null) {
|
||||
activity?.toast(R.string.save_search_invalid)
|
||||
return@cb
|
||||
}
|
||||
|
||||
presenter.sourceFilters = FilterList(search.filterList)
|
||||
filterSheet?.setFilters(presenter.filterItems)
|
||||
val allDefault = presenter.sourceFilters == presenter.source.getFilterList()
|
||||
filterSheet?.dismiss()
|
||||
|
||||
if (!allDefault) {
|
||||
onBrowseClick(
|
||||
search = presenter.query.nullIfBlank(),
|
||||
savedSearch = search.id
|
||||
)
|
||||
}
|
||||
},
|
||||
onSavedSearchDeleteClicked = { idOfSearch, name ->
|
||||
MaterialAlertDialogBuilder(activity!!)
|
||||
.setTitle(R.string.feed)
|
||||
.setMessage(activity!!.getString(R.string.feed_add, name))
|
||||
.setPositiveButton(R.string.action_add) { _, _ ->
|
||||
presenter.createFeed(idOfSearch)
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
)
|
||||
filterSheet?.setFilters(presenter.filterItems)
|
||||
|
||||
// TODO: [ExtendedFloatingActionButton] hide/show methods don't work properly
|
||||
filterSheet?.setOnShowListener { actionFab?.isVisible = false }
|
||||
filterSheet?.setOnDismissListener { actionFab?.isVisible = true }
|
||||
|
||||
actionFab?.setOnClickListener { filterSheet?.show() }
|
||||
|
||||
actionFab?.isVisible = true
|
||||
}
|
||||
|
||||
override fun configureFab(fab: ExtendedFloatingActionButton) {
|
||||
actionFab = fab
|
||||
|
||||
// Controlled by initFilterSheet()
|
||||
fab.isVisible = false
|
||||
|
||||
fab.setText(R.string.action_filter)
|
||||
fab.setIconResource(R.drawable.ic_filter_list_24dp)
|
||||
}
|
||||
|
||||
override fun cleanupFab(fab: ExtendedFloatingActionButton) {
|
||||
fab.setOnClickListener(null)
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Add search result to adapter.
|
||||
*
|
||||
* @param feedManga the source items containing the latest manga.
|
||||
*/
|
||||
fun setItems(feedManga: List<SourceFeedItem>) {
|
||||
adapter?.updateDataSet(feedManga)
|
||||
|
||||
if (feedManga.isEmpty()) {
|
||||
binding.emptyView.show(R.string.feed_tab_empty)
|
||||
} else {
|
||||
binding.emptyView.hide()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from the presenter when a manga is initialized.
|
||||
*
|
||||
* @param manga the initialized manga.
|
||||
*/
|
||||
fun onMangaInitialized(sourceFeed: SourceFeed, manga: Manga) {
|
||||
getHolder(sourceFeed)?.setImage(manga)
|
||||
}
|
||||
|
||||
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() {
|
||||
router.replaceTopController(LatestUpdatesController(presenter.source).withFadeTransaction())
|
||||
}
|
||||
|
||||
override fun onBrowseClick() {
|
||||
router.replaceTopController(BrowseSourceController(presenter.source).withFadeTransaction())
|
||||
}
|
||||
|
||||
override fun onSavedSearchClick(savedSearch: SavedSearch) {
|
||||
router.replaceTopController(BrowseSourceController(presenter.source, savedSearch = savedSearch.id).withFadeTransaction())
|
||||
}
|
||||
|
||||
override fun onRemoveClick(feedSavedSearch: FeedSavedSearch) {
|
||||
MaterialAlertDialogBuilder(activity!!)
|
||||
.setTitle(R.string.feed)
|
||||
.setMessage(R.string.feed_delete)
|
||||
.setPositiveButton(R.string.action_delete) { _, _ ->
|
||||
presenter.deleteFeed(feedSavedSearch)
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val SOURCE_EXTRA = "source"
|
||||
}
|
||||
}
|
@ -0,0 +1,125 @@
|
||||
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.LatestControllerCardBinding
|
||||
|
||||
/**
|
||||
* 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 = LatestControllerCardBinding.bind(view)
|
||||
|
||||
/**
|
||||
* Adapter containing manga from search results.
|
||||
*/
|
||||
private val mangaAdapter = SourceFeedCardAdapter(adapter.controller)
|
||||
|
||||
private var lastBoundResults: List<SourceFeedCardItem>? = null
|
||||
|
||||
init {
|
||||
// Set layout horizontal.
|
||||
binding.recycler.layoutManager = LinearLayoutManager(view.context, LinearLayoutManager.HORIZONTAL, false)
|
||||
binding.recycler.adapter = mangaAdapter
|
||||
|
||||
binding.titleWrapper.setOnClickListener {
|
||||
adapter.getItem(bindingAdapterPosition)?.let {
|
||||
when (it.sourceFeed) {
|
||||
SourceFeed.Browse -> adapter.feedClickListener.onBrowseClick()
|
||||
SourceFeed.Latest -> adapter.feedClickListener.onLatestClick()
|
||||
is SourceFeed.SourceSavedSearch -> adapter.feedClickListener.onSavedSearchClick(it.sourceFeed.savedSearch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
binding.titleWrapper.setOnLongClickListener {
|
||||
adapter.getItem(bindingAdapterPosition)?.let {
|
||||
when (it.sourceFeed) {
|
||||
SourceFeed.Browse -> adapter.feedClickListener.onBrowseClick()
|
||||
SourceFeed.Latest -> adapter.feedClickListener.onLatestClick()
|
||||
is SourceFeed.SourceSavedSearch -> adapter.feedClickListener.onRemoveClick(it.sourceFeed.feed)
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the loading of source search result.
|
||||
*
|
||||
* @param item item of card.
|
||||
*/
|
||||
@SuppressLint("SetTextI18n")
|
||||
fun bind(item: SourceFeedItem) {
|
||||
val results = item.results
|
||||
|
||||
when (item.sourceFeed) {
|
||||
SourceFeed.Browse -> binding.title.setText(R.string.browse)
|
||||
SourceFeed.Latest -> binding.title.setText(R.string.latest)
|
||||
is SourceFeed.SourceSavedSearch -> binding.title.text = item.sourceFeed.savedSearch.name
|
||||
}
|
||||
|
||||
when {
|
||||
results == null -> {
|
||||
binding.progress.isVisible = true
|
||||
showResultsHolder()
|
||||
}
|
||||
results.isEmpty() -> {
|
||||
binding.progress.isVisible = false
|
||||
showNoResults()
|
||||
}
|
||||
else -> {
|
||||
binding.progress.isVisible = false
|
||||
showResultsHolder()
|
||||
}
|
||||
}
|
||||
if (results !== lastBoundResults) {
|
||||
mangaAdapter.updateDataSet(results)
|
||||
lastBoundResults = results
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from the presenter when a manga is initialized.
|
||||
*
|
||||
* @param manga the initialized manga.
|
||||
*/
|
||||
fun setImage(manga: Manga) {
|
||||
getHolder(manga)?.setImage(manga)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the view holder for the given manga.
|
||||
*
|
||||
* @param manga the manga to find.
|
||||
* @return the holder of the manga or null if it's not bound.
|
||||
*/
|
||||
private fun getHolder(manga: Manga): SourceFeedCardHolder? {
|
||||
mangaAdapter.allBoundViewHolders.forEach { holder ->
|
||||
val item = mangaAdapter.getItem(holder.bindingAdapterPosition)
|
||||
if (item != null && item.manga.id!! == manga.id!!) {
|
||||
return holder as SourceFeedCardHolder
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun showResultsHolder() {
|
||||
binding.noResultsFound.isVisible = false
|
||||
}
|
||||
|
||||
private fun showNoResults() {
|
||||
binding.noResultsFound.isVisible = true
|
||||
}
|
||||
}
|
@ -0,0 +1,73 @@
|
||||
package eu.kanade.tachiyomi.ui.browse.source.feed
|
||||
|
||||
import android.view.View
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
||||
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
|
||||
import eu.davidea.flexibleadapter.items.IFlexible
|
||||
import eu.kanade.tachiyomi.R
|
||||
|
||||
/**
|
||||
* Item that contains search result information.
|
||||
*
|
||||
* @param feed the source for the search results.
|
||||
* @param results the search results.
|
||||
* @param highlighted whether this search item should be highlighted/marked in the catalogue search view.
|
||||
*/
|
||||
class SourceFeedItem(
|
||||
val sourceFeed: SourceFeed,
|
||||
val results: List<SourceFeedCardItem>?,
|
||||
val highlighted: Boolean = false
|
||||
) : AbstractFlexibleItem<SourceFeedHolder>() {
|
||||
|
||||
/**
|
||||
* Set view.
|
||||
*
|
||||
* @return id of view
|
||||
*/
|
||||
override fun getLayoutRes(): Int {
|
||||
return R.layout.global_search_controller_card
|
||||
}
|
||||
|
||||
/**
|
||||
* Create view holder (see [SourceFeedAdapter].
|
||||
*
|
||||
* @return holder of view.
|
||||
*/
|
||||
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): SourceFeedHolder {
|
||||
return SourceFeedHolder(view, adapter as SourceFeedAdapter)
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind item to view.
|
||||
*/
|
||||
override fun bindViewHolder(
|
||||
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
|
||||
holder: SourceFeedHolder,
|
||||
position: Int,
|
||||
payloads: List<Any?>?
|
||||
) {
|
||||
holder.bind(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to check if two items are equal.
|
||||
*
|
||||
* @return items are equal?
|
||||
*/
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other is SourceFeedItem) {
|
||||
return sourceFeed == other.sourceFeed
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Return hash code of item.
|
||||
*
|
||||
* @return hashcode
|
||||
*/
|
||||
override fun hashCode(): Int {
|
||||
return sourceFeed.hashCode()
|
||||
}
|
||||
}
|
@ -0,0 +1,357 @@
|
||||
package eu.kanade.tachiyomi.ui.browse.source.feed
|
||||
|
||||
import android.os.Bundle
|
||||
import eu.davidea.flexibleadapter.items.IFlexible
|
||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.database.models.toMangaInfo
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.source.CatalogueSource
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.model.toSManga
|
||||
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
|
||||
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter.Companion.toItems
|
||||
import eu.kanade.tachiyomi.util.lang.launchIO
|
||||
import eu.kanade.tachiyomi.util.lang.runAsObservable
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
import exh.log.xLogE
|
||||
import exh.savedsearches.EXHSavedSearch
|
||||
import exh.savedsearches.models.FeedSavedSearch
|
||||
import exh.savedsearches.models.SavedSearch
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import logcat.LogPriority
|
||||
import rx.Observable
|
||||
import rx.Subscription
|
||||
import rx.android.schedulers.AndroidSchedulers
|
||||
import rx.schedulers.Schedulers
|
||||
import rx.subjects.PublishSubject
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import xyz.nulldev.ts.api.http.serializer.FilterSerializer
|
||||
import java.lang.RuntimeException
|
||||
|
||||
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.
|
||||
*
|
||||
* @param source the source.
|
||||
* @param db manages the database calls.
|
||||
* @param preferences manages the preference calls.
|
||||
*/
|
||||
open class SourceFeedPresenter(
|
||||
val source: CatalogueSource,
|
||||
val db: DatabaseHelper = Injekt.get(),
|
||||
val preferences: PreferencesHelper = Injekt.get()
|
||||
) : BasePresenter<SourceFeedController>() {
|
||||
|
||||
/**
|
||||
* Fetches the different sources by user settings.
|
||||
*/
|
||||
private var fetchSourcesSubscription: Subscription? = null
|
||||
|
||||
/**
|
||||
* Subject which fetches image of given manga.
|
||||
*/
|
||||
private val fetchImageSubject = PublishSubject.create<Triple<List<Manga>, Source, SourceFeed>>()
|
||||
|
||||
/**
|
||||
* Subscription for fetching images of manga.
|
||||
*/
|
||||
private var fetchImageSubscription: Subscription? = null
|
||||
|
||||
/**
|
||||
* Modifiable list of filters.
|
||||
*/
|
||||
var sourceFilters = FilterList()
|
||||
set(value) {
|
||||
field = value
|
||||
filterItems = value.toItems()
|
||||
}
|
||||
|
||||
var filterItems: List<IFlexible<*>> = emptyList()
|
||||
|
||||
init {
|
||||
query = ""
|
||||
}
|
||||
|
||||
override fun onCreate(savedState: Bundle?) {
|
||||
super.onCreate(savedState)
|
||||
|
||||
sourceFilters = source.getFilterList()
|
||||
|
||||
db.getSourceFeedSavedSearches(source.id)
|
||||
.asRxObservable()
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.doOnEach {
|
||||
getFeed()
|
||||
}
|
||||
.subscribe()
|
||||
.let(::add)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
fetchSourcesSubscription?.unsubscribe()
|
||||
fetchImageSubscription?.unsubscribe()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
fun hasTooManyFeeds(): Boolean {
|
||||
return db.getSourceFeedSavedSearches(source.id).executeAsBlocking().size > 10
|
||||
}
|
||||
|
||||
fun getSourceSavedSearches(): List<SavedSearch> {
|
||||
return db.getSavedSearches(source.id).executeAsBlocking()
|
||||
}
|
||||
|
||||
fun createFeed(savedSearchId: Long) {
|
||||
launchIO {
|
||||
db.insertFeedSavedSearch(
|
||||
FeedSavedSearch(
|
||||
id = null,
|
||||
source = source.id,
|
||||
savedSearch = savedSearchId,
|
||||
global = false
|
||||
)
|
||||
).executeAsBlocking()
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteFeed(feed: FeedSavedSearch) {
|
||||
launchIO {
|
||||
db.deleteFeedSavedSearch(feed).executeAsBlocking()
|
||||
}
|
||||
}
|
||||
|
||||
private fun getSourcesToGetFeed(): List<SourceFeed> {
|
||||
val savedSearches = db.getSourceSavedSearchesFeed(source.id).executeAsBlocking()
|
||||
.associateBy { it.id!! }
|
||||
|
||||
return listOf(SourceFeed.Latest, SourceFeed.Browse) + db.getSourceFeedSavedSearches(source.id).executeAsBlocking()
|
||||
.map { SourceFeed.SourceSavedSearch(it, savedSearches[it.savedSearch]!!) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a catalogue search item
|
||||
*/
|
||||
protected open fun createCatalogueSearchItem(
|
||||
sourceFeed: SourceFeed,
|
||||
results: List<SourceFeedCardItem>?
|
||||
): SourceFeedItem {
|
||||
return SourceFeedItem(sourceFeed, results)
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiates get manga per feed.
|
||||
*/
|
||||
fun getFeed() {
|
||||
// Create image fetch subscription
|
||||
initializeFetchImageSubscription()
|
||||
|
||||
// Create items with the initial state
|
||||
val initialItems = getSourcesToGetFeed().map {
|
||||
createCatalogueSearchItem(
|
||||
it,
|
||||
null
|
||||
)
|
||||
}
|
||||
var items = initialItems
|
||||
|
||||
fetchSourcesSubscription?.unsubscribe()
|
||||
fetchSourcesSubscription = Observable.from(getSourcesToGetFeed())
|
||||
.flatMap(
|
||||
{ sourceFeed ->
|
||||
Observable.defer {
|
||||
when (sourceFeed) {
|
||||
SourceFeed.Browse -> source.fetchPopularManga(1)
|
||||
SourceFeed.Latest -> source.fetchLatestUpdates(1)
|
||||
is SourceFeed.SourceSavedSearch -> source.fetchSearchManga(
|
||||
page = 1,
|
||||
query = sourceFeed.savedSearch.query.orEmpty(),
|
||||
filters = getFilterList(sourceFeed.savedSearch, 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, sourceFeed) } // Load manga covers.
|
||||
.map { list -> createCatalogueSearchItem(sourceFeed, list.map { SourceFeedCardItem(it) }) }
|
||||
},
|
||||
5
|
||||
)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
// Update matching source with the obtained results
|
||||
.map { result ->
|
||||
items.map { item -> if (item.sourceFeed == result.sourceFeed) result else item }
|
||||
}
|
||||
// Update current state
|
||||
.doOnNext { items = it }
|
||||
// Deliver initial state
|
||||
.startWith(initialItems)
|
||||
.subscribeLatestCache(
|
||||
{ view, manga ->
|
||||
view.setItems(manga)
|
||||
},
|
||||
{ _, error ->
|
||||
logcat(LogPriority.ERROR, error)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private val filterSerializer = FilterSerializer()
|
||||
|
||||
private fun getFilterList(savedSearch: SavedSearch, source: CatalogueSource): FilterList {
|
||||
val filters = savedSearch.filtersJson ?: return FilterList()
|
||||
return runCatching {
|
||||
val originalFilters = source.getFilterList()
|
||||
filterSerializer.deserialize(
|
||||
filters = originalFilters,
|
||||
json = Json.decodeFromString(filters)
|
||||
)
|
||||
originalFilters
|
||||
}.getOrElse { FilterList() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize a list of manga.
|
||||
*
|
||||
* @param manga the list of manga to initialize.
|
||||
*/
|
||||
private fun fetchImage(manga: List<Manga>, source: Source, sourceFeed: SourceFeed) {
|
||||
fetchImageSubject.onNext(Triple(manga, source, sourceFeed))
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to the initializer of manga details and updates the view if needed.
|
||||
*/
|
||||
private fun initializeFetchImageSubscription() {
|
||||
fetchImageSubscription?.unsubscribe()
|
||||
fetchImageSubscription = fetchImageSubject.observeOn(Schedulers.io())
|
||||
.flatMap { pair ->
|
||||
val source = pair.second
|
||||
Observable.from(pair.first).filter { it.thumbnail_url == null && !it.initialized }
|
||||
.map { Pair(it, source) }
|
||||
.concatMap { getMangaDetailsObservable(it.first, it.second) }
|
||||
.map { Pair(pair.third, it) }
|
||||
}
|
||||
.onBackpressureBuffer()
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{ (sourceFeed, manga) ->
|
||||
@Suppress("DEPRECATION")
|
||||
view?.onMangaInitialized(sourceFeed, manga)
|
||||
},
|
||||
{ error ->
|
||||
logcat(LogPriority.ERROR, error)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable of manga that initializes the given manga.
|
||||
*
|
||||
* @param manga the manga to initialize.
|
||||
* @return an observable of the manga to initialize
|
||||
*/
|
||||
private fun getMangaDetailsObservable(manga: Manga, source: Source): Observable<Manga> {
|
||||
return runAsObservable {
|
||||
val networkManga = source.getMangaDetails(manga.toMangaInfo())
|
||||
manga.copyFrom(networkManga.toSManga())
|
||||
manga.initialized = true
|
||||
db.insertManga(manga).executeAsBlocking()
|
||||
manga
|
||||
}
|
||||
.onErrorResumeNext { Observable.just(manga) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a manga from the database for the given manga from network. It creates a new entry
|
||||
* if the manga is not yet in the database.
|
||||
*
|
||||
* @param sManga the manga from the source.
|
||||
* @return a manga from the database.
|
||||
*/
|
||||
protected open fun networkToLocalManga(sManga: SManga, sourceId: Long): Manga {
|
||||
var localManga = db.getManga(sManga.url, sourceId).executeAsBlocking()
|
||||
if (localManga == null) {
|
||||
val newManga = Manga.create(sManga.url, sManga.title, sourceId)
|
||||
newManga.copyFrom(sManga)
|
||||
val result = db.insertManga(newManga).executeAsBlocking()
|
||||
newManga.id = result.insertedId()
|
||||
localManga = newManga
|
||||
}
|
||||
return localManga
|
||||
}
|
||||
|
||||
fun loadSearch(searchId: Long): EXHSavedSearch? {
|
||||
val search = db.getSavedSearch(searchId).executeAsBlocking() ?: return null
|
||||
return EXHSavedSearch(
|
||||
id = search.id!!,
|
||||
name = search.name,
|
||||
query = search.query.orEmpty(),
|
||||
filterList = runCatching {
|
||||
val originalFilters = source.getFilterList()
|
||||
filterSerializer.deserialize(
|
||||
filters = originalFilters,
|
||||
json = search.filtersJson
|
||||
?.let { Json.decodeFromString<JsonArray>(it) }
|
||||
?: return@runCatching null
|
||||
)
|
||||
originalFilters
|
||||
}.getOrNull()
|
||||
)
|
||||
}
|
||||
|
||||
fun loadSearches(): List<EXHSavedSearch> {
|
||||
return db.getSavedSearches(source.id).executeAsBlocking().map {
|
||||
val filtersJson = it.filtersJson ?: return@map EXHSavedSearch(
|
||||
id = it.id!!,
|
||||
name = it.name,
|
||||
query = it.query.orEmpty(),
|
||||
filterList = null
|
||||
)
|
||||
val filters = try {
|
||||
Json.decodeFromString<JsonArray>(filtersJson)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
} ?: return@map EXHSavedSearch(
|
||||
id = it.id!!,
|
||||
name = it.name,
|
||||
query = it.query.orEmpty(),
|
||||
filterList = null
|
||||
)
|
||||
|
||||
try {
|
||||
val originalFilters = source.getFilterList()
|
||||
filterSerializer.deserialize(originalFilters, filters)
|
||||
EXHSavedSearch(
|
||||
id = it.id!!,
|
||||
name = it.name,
|
||||
query = it.query.orEmpty(),
|
||||
filterList = originalFilters
|
||||
)
|
||||
} catch (t: RuntimeException) {
|
||||
// Load failed
|
||||
xLogE("Failed to load saved search!", t)
|
||||
EXHSavedSearch(
|
||||
id = it.id!!,
|
||||
name = it.name,
|
||||
query = it.query.orEmpty(),
|
||||
filterList = null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -66,8 +66,8 @@ import eu.kanade.tachiyomi.ui.browse.migration.advanced.design.PreMigrationContr
|
||||
import eu.kanade.tachiyomi.ui.browse.source.SourceController
|
||||
import eu.kanade.tachiyomi.ui.browse.source.SourceController.Companion.SMART_SEARCH_SOURCE_TAG
|
||||
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController
|
||||
import eu.kanade.tachiyomi.ui.browse.source.feed.SourceFeedController
|
||||
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController
|
||||
import eu.kanade.tachiyomi.ui.browse.source.index.IndexController
|
||||
import eu.kanade.tachiyomi.ui.browse.source.latest.LatestUpdatesController
|
||||
import eu.kanade.tachiyomi.ui.library.ChangeMangaCategoriesDialog
|
||||
import eu.kanade.tachiyomi.ui.library.ChangeMangaCoverDialog
|
||||
@ -921,7 +921,7 @@ class MangaController :
|
||||
previousController.searchWithQuery(query)
|
||||
}
|
||||
// SY -->
|
||||
is IndexController -> {
|
||||
is SourceFeedController -> {
|
||||
router.handleBack()
|
||||
previousController.onBrowseClick(query)
|
||||
}
|
||||
|
@ -423,7 +423,8 @@ object EXHMigrations {
|
||||
FeedSavedSearch(
|
||||
id = null,
|
||||
source = it.toLong(),
|
||||
savedSearch = null
|
||||
savedSearch = null,
|
||||
global = true
|
||||
)
|
||||
}?.ifEmpty { null }
|
||||
if (feed != null) {
|
||||
|
@ -11,6 +11,7 @@ import com.pushtorefresh.storio.sqlite.queries.DeleteQuery
|
||||
import com.pushtorefresh.storio.sqlite.queries.InsertQuery
|
||||
import com.pushtorefresh.storio.sqlite.queries.UpdateQuery
|
||||
import exh.savedsearches.models.FeedSavedSearch
|
||||
import exh.savedsearches.tables.FeedSavedSearchTable.COL_GLOBAL
|
||||
import exh.savedsearches.tables.FeedSavedSearchTable.COL_ID
|
||||
import exh.savedsearches.tables.FeedSavedSearchTable.COL_SAVED_SEARCH_ID
|
||||
import exh.savedsearches.tables.FeedSavedSearchTable.COL_SOURCE
|
||||
@ -37,7 +38,8 @@ class FeedSavedSearchPutResolver : DefaultPutResolver<FeedSavedSearch>() {
|
||||
override fun mapToContentValues(obj: FeedSavedSearch) = contentValuesOf(
|
||||
COL_ID to obj.id,
|
||||
COL_SOURCE to obj.source,
|
||||
COL_SAVED_SEARCH_ID to obj.savedSearch
|
||||
COL_SAVED_SEARCH_ID to obj.savedSearch,
|
||||
COL_GLOBAL to obj.global
|
||||
)
|
||||
}
|
||||
|
||||
@ -46,7 +48,8 @@ class FeedSavedSearchGetResolver : DefaultGetResolver<FeedSavedSearch>() {
|
||||
override fun mapFromCursor(cursor: Cursor): FeedSavedSearch = FeedSavedSearch(
|
||||
id = cursor.getLong(cursor.getColumnIndexOrThrow(COL_ID)),
|
||||
source = cursor.getLong(cursor.getColumnIndexOrThrow(COL_SOURCE)),
|
||||
savedSearch = cursor.getLongOrNull(cursor.getColumnIndexOrThrow(COL_SAVED_SEARCH_ID))
|
||||
savedSearch = cursor.getLongOrNull(cursor.getColumnIndexOrThrow(COL_SAVED_SEARCH_ID)),
|
||||
global = cursor.getInt(cursor.getColumnIndexOrThrow(COL_GLOBAL)) == 1
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -8,5 +8,8 @@ data class FeedSavedSearch(
|
||||
var source: Long,
|
||||
|
||||
// If -1 then get latest, if set get the saved search
|
||||
var savedSearch: Long?
|
||||
var savedSearch: Long?,
|
||||
|
||||
// If the feed is a global or source specific feed
|
||||
var global: Boolean
|
||||
)
|
||||
|
@ -4,40 +4,32 @@ import com.pushtorefresh.storio.sqlite.queries.DeleteQuery
|
||||
import com.pushtorefresh.storio.sqlite.queries.Query
|
||||
import com.pushtorefresh.storio.sqlite.queries.RawQuery
|
||||
import eu.kanade.tachiyomi.data.database.DbProvider
|
||||
import eu.kanade.tachiyomi.data.database.queries.getFeedSavedSearchQuery
|
||||
import eu.kanade.tachiyomi.data.database.queries.getGlobalFeedSavedSearchQuery
|
||||
import eu.kanade.tachiyomi.data.database.queries.getSourceFeedSavedSearchQuery
|
||||
import exh.savedsearches.models.FeedSavedSearch
|
||||
import exh.savedsearches.models.SavedSearch
|
||||
import exh.savedsearches.tables.FeedSavedSearchTable
|
||||
|
||||
interface FeedSavedSearchQueries : DbProvider {
|
||||
fun getFeedSavedSearches() = db.get()
|
||||
fun getGlobalFeedSavedSearches() = db.get()
|
||||
.listOfObjects(FeedSavedSearch::class.java)
|
||||
.withQuery(
|
||||
Query.builder()
|
||||
.table(FeedSavedSearchTable.TABLE)
|
||||
.where("${FeedSavedSearchTable.COL_GLOBAL} = 1")
|
||||
.orderBy(FeedSavedSearchTable.COL_ID)
|
||||
.build()
|
||||
)
|
||||
.prepare()
|
||||
|
||||
fun getFeedSavedSearch(id: Long) = db.get()
|
||||
.`object`(FeedSavedSearch::class.java)
|
||||
.withQuery(
|
||||
Query.builder()
|
||||
.table(FeedSavedSearchTable.TABLE)
|
||||
.where("${FeedSavedSearchTable.COL_ID} = ?")
|
||||
.whereArgs(id)
|
||||
.build()
|
||||
)
|
||||
.prepare()
|
||||
|
||||
fun getFeedSavedSearches(ids: List<Long>) = db.get()
|
||||
fun getSourceFeedSavedSearches(sourceId: Long) = db.get()
|
||||
.listOfObjects(FeedSavedSearch::class.java)
|
||||
.withQuery(
|
||||
Query.builder()
|
||||
.table(FeedSavedSearchTable.TABLE)
|
||||
.where("${FeedSavedSearchTable.COL_ID} IN (?)")
|
||||
.whereArgs(ids.joinToString())
|
||||
.where("${FeedSavedSearchTable.COL_SOURCE} = ? AND ${FeedSavedSearchTable.COL_GLOBAL} = 0")
|
||||
.whereArgs(sourceId)
|
||||
.orderBy(FeedSavedSearchTable.COL_ID)
|
||||
.build()
|
||||
)
|
||||
.prepare()
|
||||
@ -64,11 +56,21 @@ interface FeedSavedSearchQueries : DbProvider {
|
||||
)
|
||||
.prepare()
|
||||
|
||||
fun getSavedSearchesFeed() = db.get()
|
||||
fun getGlobalSavedSearchesFeed() = db.get()
|
||||
.listOfObjects(SavedSearch::class.java)
|
||||
.withQuery(
|
||||
RawQuery.builder()
|
||||
.query(getFeedSavedSearchQuery())
|
||||
.query(getGlobalFeedSavedSearchQuery())
|
||||
.build()
|
||||
)
|
||||
.prepare()
|
||||
|
||||
fun getSourceSavedSearchesFeed(sourceId: Long) = db.get()
|
||||
.listOfObjects(SavedSearch::class.java)
|
||||
.withQuery(
|
||||
RawQuery.builder()
|
||||
.query(getSourceFeedSavedSearchQuery())
|
||||
.args(sourceId)
|
||||
.build()
|
||||
)
|
||||
.prepare()
|
||||
|
@ -10,12 +10,15 @@ object FeedSavedSearchTable {
|
||||
|
||||
const val COL_SAVED_SEARCH_ID = "saved_search"
|
||||
|
||||
const val COL_GLOBAL = "global"
|
||||
|
||||
val createTableQuery: String
|
||||
get() =
|
||||
"""CREATE TABLE $TABLE(
|
||||
$COL_ID INTEGER NOT NULL PRIMARY KEY,
|
||||
$COL_SOURCE INTEGER NOT NULL,
|
||||
$COL_SAVED_SEARCH_ID INTEGER,
|
||||
$COL_GLOBAL BOOLEAN NOT NULL,
|
||||
FOREIGN KEY($COL_SAVED_SEARCH_ID) REFERENCES ${SavedSearchTable.TABLE} (${SavedSearchTable.COL_ID})
|
||||
ON DELETE CASCADE
|
||||
)"""
|
||||
|
@ -366,6 +366,7 @@
|
||||
<string name="feed_delete">Delete feed item?</string>
|
||||
<string name="too_many_in_feed">Too many sources in your feed, cannot add more then 10</string>
|
||||
<string name="feed_tab_empty">You don\'t have any sources in your feed, go to the sources tab and long press a source to watch it</string>
|
||||
<string name="feed_add">Add %1$s to feed?</string>
|
||||
|
||||
<!-- Sort by tags -->
|
||||
<string name="pref_tag_sorting">Tag sorting tags</string>
|
||||
|
Loading…
x
Reference in New Issue
Block a user