Add genre filter for catalogue (#428)

* Add genre filter for catalogue

* Implement genre filter for batoto

* hardcode filters for sources

* swtich filter id to string

* reset filters when switching sources

* Add filter support to mangafox

* Catalogue changes

* Indefinite snackbar on error, use plain subscriptions in catalogue presenter
This commit is contained in:
Robin Appelman
2016-08-28 22:59:00 +02:00
committed by inorichi
parent 4171e87b4b
commit 2fb3b50535
21 changed files with 484 additions and 251 deletions

View File

@@ -2,13 +2,13 @@ package eu.kanade.tachiyomi.ui.catalogue
import android.content.res.Configuration
import android.os.Bundle
import android.support.design.widget.Snackbar
import android.support.v7.widget.GridLayoutManager
import android.support.v7.widget.LinearLayoutManager
import android.support.v7.widget.SearchView
import android.support.v7.widget.Toolbar
import android.view.*
import android.view.animation.AnimationUtils
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.ProgressBar
import android.widget.Spinner
@@ -25,6 +25,7 @@ import eu.kanade.tachiyomi.util.snack
import eu.kanade.tachiyomi.util.toast
import eu.kanade.tachiyomi.widget.DividerItemDecoration
import eu.kanade.tachiyomi.widget.EndlessScrollListener
import eu.kanade.tachiyomi.widget.IgnoreFirstSpinnerListener
import kotlinx.android.synthetic.main.fragment_catalogue.*
import kotlinx.android.synthetic.main.toolbar.*
import nucleus.factory.RequiresPresenter
@@ -64,7 +65,7 @@ class CatalogueFragment : BaseRxFragment<CataloguePresenter>(), FlexibleViewHold
/**
* Query of the search box.
*/
private val query: String?
private val query: String
get() = presenter.query
/**
@@ -92,11 +93,6 @@ class CatalogueFragment : BaseRxFragment<CataloguePresenter>(), FlexibleViewHold
*/
private var numColumnsSubscription: Subscription? = null
/**
* Display mode of the catalogue (list or grid mode).
*/
private var displayMode: MenuItem? = null
/**
* Search item.
*/
@@ -144,7 +140,8 @@ class CatalogueFragment : BaseRxFragment<CataloguePresenter>(), FlexibleViewHold
catalogue_list.adapter = adapter
catalogue_list.layoutManager = llm
catalogue_list.addOnScrollListener(listScrollListener)
catalogue_list.addItemDecoration(DividerItemDecoration(context.theme.getResourceDrawable(R.attr.divider_drawable)))
catalogue_list.addItemDecoration(
DividerItemDecoration(context.theme.getResourceDrawable(R.attr.divider_drawable)))
if (presenter.isListMode) {
switcher.showNext()
@@ -166,28 +163,25 @@ class CatalogueFragment : BaseRxFragment<CataloguePresenter>(), FlexibleViewHold
android.R.layout.simple_spinner_item, presenter.sources)
spinnerAdapter.setDropDownViewResource(R.layout.spinner_item)
val onItemSelected = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) {
val source = spinnerAdapter.getItem(position)
if (!presenter.isValidSource(source)) {
spinner.setSelection(selectedIndex)
context.toast(R.string.source_requires_login)
} else if (source != presenter.source) {
selectedIndex = position
showProgressBar()
glm.scrollToPositionWithOffset(0, 0)
llm.scrollToPositionWithOffset(0, 0)
presenter.setActiveSource(source)
}
}
override fun onNothingSelected(parent: AdapterView<*>) {
val onItemSelected = IgnoreFirstSpinnerListener { position ->
val source = spinnerAdapter.getItem(position)
if (!presenter.isValidSource(source)) {
spinner.setSelection(selectedIndex)
context.toast(R.string.source_requires_login)
} else if (source != presenter.source) {
selectedIndex = position
showProgressBar()
glm.scrollToPositionWithOffset(0, 0)
llm.scrollToPositionWithOffset(0, 0)
presenter.setActiveSource(source)
activity.invalidateOptionsMenu()
}
}
selectedIndex = presenter.sources.indexOf(presenter.source)
spinner = Spinner(themedContext).apply {
adapter = spinnerAdapter
selectedIndex = presenter.sources.indexOf(presenter.source)
setSelection(selectedIndex)
onItemSelectedListener = onItemSelected
}
@@ -205,7 +199,7 @@ class CatalogueFragment : BaseRxFragment<CataloguePresenter>(), FlexibleViewHold
searchItem = menu.findItem(R.id.action_search).apply {
val searchView = actionView as SearchView
if (!query.isNullOrEmpty()) {
if (!query.isBlank()) {
expandActionView()
searchView.setQuery(query, true)
searchView.clearFocus()
@@ -223,20 +217,31 @@ class CatalogueFragment : BaseRxFragment<CataloguePresenter>(), FlexibleViewHold
})
}
// Setup filters button
menu.findItem(R.id.action_set_filter).apply {
if (presenter.source.filters.isEmpty()) {
isEnabled = false
icon.alpha = 128
} else {
isEnabled = true
icon.alpha = 255
}
}
// Show next display mode
displayMode = menu.findItem(R.id.action_display_mode).apply {
menu.findItem(R.id.action_display_mode).apply {
val icon = if (presenter.isListMode)
R.drawable.ic_view_module_white_24dp
else
R.drawable.ic_view_list_white_24dp
setIcon(icon)
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_display_mode -> swapDisplayMode()
R.id.action_set_filter -> showFiltersDialog()
else -> return super.onOptionsItemSelected(item)
}
return true
@@ -312,7 +317,7 @@ class CatalogueFragment : BaseRxFragment<CataloguePresenter>(), FlexibleViewHold
*/
fun onAddPage(page: Int, mangas: List<Manga>) {
hideProgressBar()
if (page == 0) {
if (page == 1) {
adapter.clear()
gridScrollListener.resetScroll()
listScrollListener.resetScroll()
@@ -329,10 +334,10 @@ class CatalogueFragment : BaseRxFragment<CataloguePresenter>(), FlexibleViewHold
hideProgressBar()
Timber.e(error, error.message)
catalogue_view.snack(error.message ?: "") {
catalogue_view.snack(error.message ?: "", Snackbar.LENGTH_INDEFINITE) {
setAction(R.string.action_retry) {
showProgressBar()
presenter.retryPage()
presenter.requestNext()
}
}
}
@@ -352,11 +357,7 @@ class CatalogueFragment : BaseRxFragment<CataloguePresenter>(), FlexibleViewHold
fun swapDisplayMode() {
presenter.swapDisplayMode()
val isListMode = presenter.isListMode
val icon = if (isListMode)
R.drawable.ic_view_module_white_24dp
else
R.drawable.ic_view_list_white_24dp
displayMode?.setIcon(icon)
activity.invalidateOptionsMenu()
switcher.showNext()
if (!isListMode) {
// Initialize mangas if going to grid view
@@ -444,4 +445,27 @@ class CatalogueFragment : BaseRxFragment<CataloguePresenter>(), FlexibleViewHold
}.show()
}
/**
* Show the filter dialog for the source.
*/
private fun showFiltersDialog() {
val allFilters = presenter.source.filters
val selectedFilters = presenter.filters
.map { filter -> allFilters.indexOf(filter) }
.toTypedArray()
MaterialDialog.Builder(context)
.title(R.string.action_set_filter)
.items(allFilters.map { it.name })
.itemsCallbackMultiChoice(selectedFilters) { dialog, positions, text ->
val newFilters = positions.map { allFilters[it] }
showProgressBar()
presenter.setSourceFilter(newFilters)
true
}
.positiveText(android.R.string.ok)
.negativeText(android.R.string.cancel)
.show()
}
}

View File

@@ -0,0 +1,41 @@
package eu.kanade.tachiyomi.ui.catalogue
import eu.kanade.tachiyomi.data.source.model.MangasPage
import eu.kanade.tachiyomi.data.source.online.OnlineSource
import eu.kanade.tachiyomi.data.source.online.OnlineSource.Filter
import rx.Observable
import rx.subjects.PublishSubject
class CataloguePager(val source: OnlineSource, val query: String, val filters: List<Filter>) {
private var lastPage: MangasPage? = null
private val results = PublishSubject.create<MangasPage>()
fun results(): Observable<MangasPage> {
return results.asObservable()
}
fun requestNext(transformer: (Observable<MangasPage>) -> Observable<MangasPage>): Observable<MangasPage> {
val lastPage = lastPage
val page = if (lastPage == null)
MangasPage(1)
else
MangasPage(lastPage.page + 1).apply { url = lastPage.nextPageUrl!! }
val observable = if (query.isBlank() && filters.isEmpty())
source.fetchPopularManga(page)
else
source.fetchSearchManga(page, query, filters)
return transformer(observable)
.doOnNext { results.onNext(it) }
.doOnNext { this@CataloguePager.lastPage = it }
}
fun hasNextPage(): Boolean {
return lastPage == null || lastPage?.nextPageUrl != null
}
}

View File

@@ -12,9 +12,10 @@ import eu.kanade.tachiyomi.data.source.SourceManager
import eu.kanade.tachiyomi.data.source.model.MangasPage
import eu.kanade.tachiyomi.data.source.online.LoginSource
import eu.kanade.tachiyomi.data.source.online.OnlineSource
import eu.kanade.tachiyomi.data.source.online.OnlineSource.Filter
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.util.RxPager
import rx.Observable
import rx.Subscription
import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
import rx.subjects.PublishSubject
@@ -64,14 +65,14 @@ class CataloguePresenter : BasePresenter<CatalogueFragment>() {
private set
/**
* Pager containing a list of manga results.
* Active filters.
*/
private var pager = RxPager<Manga>()
var filters: List<Filter> = emptyList()
/**
* Last fetched page from network.
* Pager containing a list of manga results.
*/
private var lastMangasPage: MangasPage? = null
private lateinit var pager: CataloguePager
/**
* Subject that initializes a list of manga.
@@ -84,27 +85,20 @@ class CataloguePresenter : BasePresenter<CatalogueFragment>() {
var isListMode: Boolean = false
private set
companion object {
/**
* Id of the restartable that delivers a list of manga.
*/
const val PAGER = 1
/**
* Subscription for the pager.
*/
private var pagerSubscription: Subscription? = null
/**
* Id of the restartable that requests a page of manga from network.
*/
const val REQUEST_PAGE = 2
/**
* Subscription for one request from the pager.
*/
private var pageSubscription: Subscription? = null
/**
* Id of the restartable that initializes the details of manga.
*/
const val GET_MANGA_DETAILS = 3
/**
* Key to save and restore [query] from a [Bundle].
*/
const val QUERY_KEY = "query_key"
}
/**
* Subscription to initialize manga details.
*/
private var initializerSubscription: Subscription? = null
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
@@ -112,52 +106,68 @@ class CataloguePresenter : BasePresenter<CatalogueFragment>() {
source = getLastUsedSource()
if (savedState != null) {
query = savedState.getString(QUERY_KEY, "")
query = savedState.getString(CataloguePresenter::query.name, "")
}
startableLatestCache(GET_MANGA_DETAILS,
{ mangaDetailSubject.observeOn(Schedulers.io())
.flatMap { Observable.from(it) }
.filter { !it.initialized }
.concatMap { getMangaDetailsObservable(it) }
.onBackpressureBuffer()
.observeOn(AndroidSchedulers.mainThread()) },
{ view, manga -> view.onMangaInitialized(manga) },
{ view, error -> Timber.e(error.message) })
add(prefs.catalogueAsList().asObservable()
.subscribe { setDisplayMode(it) })
startableReplay(PAGER,
{ pager.results() },
{ view, pair -> view.onAddPage(pair.first, pair.second) })
startableFirst(REQUEST_PAGE,
{ pager.request { page -> getMangasPageObservable(page + 1) } },
{ view, next -> },
{ view, error -> view.onAddPageError(error) })
start(PAGER)
start(REQUEST_PAGE)
restartPager()
}
override fun onSave(state: Bundle) {
state.putString(QUERY_KEY, query)
state.putString(CataloguePresenter::query.name, query)
super.onSave(state)
}
/**
* Sets the display mode.
* Restarts the pager for the active source with the provided query and filters.
*
* @param asList whether the current mode is in list or not.
* @param query the query.
* @param filters the list of active filters (for search mode).
*/
private fun setDisplayMode(asList: Boolean) {
isListMode = asList
if (asList) {
stop(GET_MANGA_DETAILS)
} else {
start(GET_MANGA_DETAILS)
fun restartPager(query: String = this.query, filters: List<Filter> = this.filters) {
this.query = query
this.filters = filters
if (!isListMode) {
subscribeToMangaInitializer()
}
// Create a new pager.
pager = CataloguePager(source, query, filters)
// Prepare the pager.
pagerSubscription?.let { remove(it) }
pagerSubscription = pager.results()
.subscribeReplay({ view, page ->
view.onAddPage(page.page, page.mangas)
}, { view, error ->
Timber.e(error, error.message)
})
// Request first page.
requestNext()
}
/**
* Requests the next page for the active pager.
*/
fun requestNext() {
if (!hasNextPage()) return
pageSubscription?.let { remove(it) }
pageSubscription = pager.requestNext { getPageTransformer(it) }
.subscribeFirst({ view, page ->
// Nothing to do when onNext is emitted.
}, CatalogueFragment::onAddPageError)
}
/**
* Returns true if the last fetched page has a next page.
*/
fun hasNextPage(): Boolean {
return pager.hasNextPage()
}
/**
@@ -168,73 +178,64 @@ class CataloguePresenter : BasePresenter<CatalogueFragment>() {
fun setActiveSource(source: OnlineSource) {
prefs.lastUsedCatalogueSource().set(source.id)
this.source = source
restartPager()
restartPager(query = "", filters = emptyList())
}
/**
* Restarts the request for the active source.
* Sets the display mode.
*
* @param query the query, or null if searching popular manga.
* @param asList whether the current mode is in list or not.
*/
fun restartPager(query: String = "") {
this.query = query
stop(REQUEST_PAGE)
lastMangasPage = null
if (!isListMode) {
start(GET_MANGA_DETAILS)
}
start(PAGER)
start(REQUEST_PAGE)
}
/**
* Requests the next page for the active pager.
*/
fun requestNext() {
if (hasNextPage()) {
start(REQUEST_PAGE)
private fun setDisplayMode(asList: Boolean) {
isListMode = asList
if (asList) {
initializerSubscription?.let { remove(it) }
} else {
subscribeToMangaInitializer()
}
}
/**
* Returns true if the last fetched page has a next page.
* Subscribes to the initializer of manga details and updates the view if needed.
*/
fun hasNextPage(): Boolean {
return lastMangasPage?.nextPageUrl != null
}
/**
* Retries the current request that failed.
*/
fun retryPage() {
start(REQUEST_PAGE)
}
/**
* Returns the observable of the network request for a page.
*
* @param page the page number to request.
* @return an observable of the network request.
*/
private fun getMangasPageObservable(page: Int): Observable<List<Manga>> {
val nextMangasPage = MangasPage(page)
if (page != 1) {
nextMangasPage.url = lastMangasPage!!.nextPageUrl!!
}
val observable = if (query.isEmpty())
source.fetchPopularManga(nextMangasPage)
else
source.fetchSearchManga(nextMangasPage, query)
return observable.subscribeOn(Schedulers.io())
.doOnNext { lastMangasPage = it }
.flatMap { Observable.from(it.mangas) }
.map { networkToLocalManga(it) }
.toList()
.doOnNext { initializeMangas(it) }
private fun subscribeToMangaInitializer() {
initializerSubscription?.let { remove(it) }
initializerSubscription = mangaDetailSubject.observeOn(Schedulers.io())
.flatMap { Observable.from(it) }
.filter { !it.initialized }
.concatMap { getMangaDetailsObservable(it) }
.onBackpressureBuffer()
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ manga ->
@Suppress("DEPRECATION")
view?.onMangaInitialized(manga)
}, { error ->
Timber.e(error, error.message)
})
.apply { add(this) }
}
/**
* Returns the function to apply to the observable of the list of manga from the source.
*
* @param observable the observable from the source.
* @return the function to apply.
*/
fun getPageTransformer(observable: Observable<MangasPage>): Observable<MangasPage> {
return observable.subscribeOn(Schedulers.io())
.doOnNext { it.mangas.replace { networkToLocalManga(it) } }
.doOnNext { initializeMangas(it.mangas) }
.observeOn(AndroidSchedulers.mainThread())
}
/**
* Replaces an object in the list with another.
*/
fun <T> MutableList<T>.replace(block: (T) -> T) {
forEachIndexed { i, obj ->
set(i, block(obj))
}
}
/**
@@ -354,4 +355,13 @@ class CataloguePresenter : BasePresenter<CatalogueFragment>() {
prefs.catalogueAsList().set(!isListMode)
}
/**
* Set the active filters for the current source.
*
* @param selectedFilters a list of active filters.
*/
fun setSourceFilter(selectedFilters: List<Filter>) {
restartPager(filters = selectedFilters)
}
}