Add custom browese view, disabled by default and can be enabled in the settings

Signed-off-by: Jobobby04 <jobobby04@users.noreply.github.com>
This commit is contained in:
Jobobby04 2020-08-18 22:05:56 -04:00
parent 9e0e2db25d
commit 2e4def13e3
17 changed files with 1091 additions and 54 deletions

View File

@ -284,4 +284,6 @@ object PreferenceKeys {
const val continuousVerticalTappingByPage = "continuous_vertical_tapping_by_page"
const val groupLibraryUpdateType = "group_library_update_type"
const val useNewSourceNavigation = "use_new_source_navigation"
}

View File

@ -389,4 +389,6 @@ class PreferencesHelper(val context: Context) {
fun continuousVerticalTappingByPage() = flowPrefs.getBoolean(Keys.continuousVerticalTappingByPage, false)
fun groupLibraryUpdateType() = flowPrefs.getEnum(Keys.groupLibraryUpdateType, Values.GroupLibraryMode.GLOBAL)
fun useNewSourceNavigation() = flowPrefs.getBoolean(Keys.useNewSourceNavigation, false)
}

View File

@ -34,6 +34,7 @@ import eu.kanade.tachiyomi.ui.browse.BrowseController
import eu.kanade.tachiyomi.ui.browse.SourceDividerItemDecoration
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController
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.util.system.toast
@ -152,7 +153,9 @@ class SourceController(bundle: Bundle? = null) :
when (mode) {
Mode.CATALOGUE -> {
// Open the catalogue view.
openSource(source, BrowseSourceController(source))
// SY -->
if (source.supportsLatest && preferences.useNewSourceNavigation().get()) openIndexSource(source) else openSource(source, BrowseSourceController(source))
// SY <--
}
Mode.SMART_SEARCH -> router.pushController(
SmartSearchController(
@ -319,6 +322,16 @@ class SourceController(bundle: Bundle? = null) :
parentController!!.router.pushController(controller.withFadeTransaction())
}
// SY -->
/**
* Opens a catalogue with the index controller.
*/
private fun openIndexSource(source: CatalogueSource) {
preferences.lastUsedSource().set(source.id)
parentController!!.router.pushController(IndexController(source).withFadeTransaction())
}
// SY <--
/**
* Adds items to the options menu.
*

View File

@ -17,7 +17,7 @@ import kotlinx.android.synthetic.main.source_main_controller_card_item.pin
import kotlinx.android.synthetic.main.source_main_controller_card_item.source_latest
import kotlinx.android.synthetic.main.source_main_controller_card_item.title
class SourceHolder(private val view: View, override val adapter: SourceAdapter /* SY --> */, private val showButtons: Boolean /* SY <-- */) :
class SourceHolder(private val view: View, override val adapter: SourceAdapter /* SY --> */, private val showLatest: Boolean, private val showPins: Boolean /* SY <-- */) :
BaseFlexibleViewHolder(view, adapter),
SourceListItem,
SlicedHolder {
@ -39,7 +39,7 @@ class SourceHolder(private val view: View, override val adapter: SourceAdapter /
}
// SY -->
if (!showButtons) {
if (!showLatest) {
source_latest.isVisible = false
}
// SY <--
@ -61,9 +61,9 @@ class SourceHolder(private val view: View, override val adapter: SourceAdapter /
}
}
source_latest.isVisible = source.supportsLatest/* SY --> */ && showButtons /* SY <-- */
source_latest.isVisible = source.supportsLatest/* SY --> */ && showLatest /* SY <-- */
pin.isVisible = showButtons
pin.isVisible = showPins
if (item.isPinned) {
pin.setVectorCompat(R.drawable.ic_push_pin_filled_24dp, view.context.getResourceColor(R.attr.colorAccent))
} else {

View File

@ -19,7 +19,8 @@ data class SourceItem(
val header: LangItem? = null,
val isPinned: Boolean = false,
// SY -->
val showButtons: Boolean
val showLatest: Boolean,
val showPins: Boolean
// SY <--
) :
AbstractSectionableItem<SourceHolder, LangItem>(header) {
@ -35,7 +36,7 @@ data class SourceItem(
* Creates a new view holder for this item.
*/
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): SourceHolder {
return SourceHolder(view, adapter as SourceAdapter /* SY --> */, showButtons /* SY <-- */)
return SourceHolder(view, adapter as SourceAdapter /* SY --> */, showLatest, showPins /* SY <-- */)
}
/**

View File

@ -91,9 +91,13 @@ class SourcePresenter(
var sourceItems = byLang.flatMap {
val langItem = LangItem(it.key)
it.value.map { source ->
// SY -->
val showPins = controllerMode == SourceController.Mode.CATALOGUE
val showLatest = showPins && !preferences.useNewSourceNavigation().get()
// SY <--
val isPinned = source.id.toString() in pinnedSourceIds
if (isPinned) {
pinnedSources.add(SourceItem(source, LangItem(PINNED_KEY), isPinned, controllerMode == SourceController.Mode.CATALOGUE))
pinnedSources.add(SourceItem(source, LangItem(PINNED_KEY), isPinned /* SY --> */, showLatest, showPins /* SY <-- */))
}
// SY -->
@ -108,7 +112,8 @@ class SourcePresenter(
source,
LangItem("custom|" + SourceAndCategory.second),
isPinned,
controllerMode == SourceController.Mode.CATALOGUE
showLatest,
showPins
)
)
}
@ -117,7 +122,7 @@ class SourcePresenter(
}
// SY <--
SourceItem(source, langItem, isPinned, controllerMode == SourceController.Mode.CATALOGUE)
SourceItem(source, langItem, isPinned /* SY --> */, showLatest, showPins /* SY <-- */)
}
}
@ -151,7 +156,11 @@ class SourcePresenter(
private fun updateLastUsedSource(sourceId: Long) {
val source = (sourceManager.get(sourceId) as? CatalogueSource)?.let {
val isPinned = it.id.toString() in preferences.pinnedSources().get()
SourceItem(it, null, isPinned, controllerMode == SourceController.Mode.CATALOGUE)
// SY -->
val showPins = controllerMode == SourceController.Mode.CATALOGUE
val showLatest = showPins && !preferences.useNewSourceNavigation().get()
// SY <--
SourceItem(it, null, isPinned /* SY --> */, showLatest, showPins /* SY <-- */)
}
source?.let { view?.setLastUsedSource(it) }
}

View File

@ -82,7 +82,8 @@ open class BrowseSourceController(bundle: Bundle) :
source: CatalogueSource,
searchQuery: String? = null,
// SY -->
smartSearchConfig: SourceController.SmartSearchConfig? = null
smartSearchConfig: SourceController.SmartSearchConfig? = null,
filterList: String? = null
// SY <--
) : this(
Bundle().apply {
@ -96,6 +97,10 @@ open class BrowseSourceController(bundle: Bundle) :
if (smartSearchConfig != null) {
putParcelable(SMART_SEARCH_CONFIG_KEY, smartSearchConfig)
}
if (filterList != null) {
putString(FILTERS_CONFIG_KEY, filterList)
}
// SY <--
}
)
@ -160,7 +165,8 @@ open class BrowseSourceController(bundle: Bundle) :
return BrowseSourcePresenter(
args.getLong(SOURCE_ID_KEY),
args.getString(SEARCH_QUERY_KEY),
recommendsMangaId = if (mode == Mode.RECOMMENDS) recommendsConfig?.mangaId else null
recommendsMangaId = if (mode == Mode.RECOMMENDS) recommendsConfig?.mangaId else null,
filters = args.getString(FILTERS_CONFIG_KEY)
)
// SY <--
}
@ -851,6 +857,7 @@ open class BrowseSourceController(bundle: Bundle) :
// SY -->
const val SMART_SEARCH_CONFIG_KEY = "smartSearchConfig"
const val FILTERS_CONFIG_KEY = "filters"
const val RECOMMENDS_CONFIG = "RECOMMENDS_CONFIG"
// SY <--
}

View File

@ -59,6 +59,7 @@ open class BrowseSourcePresenter(
private val searchQuery: String? = null,
// SY -->
private val recommendsMangaId: Long? = null,
private val filters: String? = null,
// SY <--
private val sourceManager: SourceManager = Injekt.get(),
private val db: DatabaseHelper = Injekt.get(),
@ -115,6 +116,8 @@ open class BrowseSourcePresenter(
// SY -->
private var manga: Manga? = null
private val filterSerializer = FilterSerializer()
// SY <--
/**
@ -129,6 +132,14 @@ open class BrowseSourcePresenter(
sourceFilters = source.getFilterList()
// SY -->
if (filters != null) {
val filters = JsonParser.parseString(filters).obj
filterSerializer.deserialize(sourceFilters, filters["filters"].array)
}
val allDefault = sourceFilters == source.getFilterList()
// SY <--
if (savedState != null) {
query = savedState.getString(::query.name, "")
}
@ -137,7 +148,7 @@ open class BrowseSourcePresenter(
manga = db.getManga(recommendsMangaId).executeAsBlocking()
}
restartPager()
restartPager(/* SY -->*/ filters = if (allDefault) this.appliedFilters else sourceFilters /* SY <--*/)
}
override fun onSave(state: Bundle) {
@ -323,48 +334,54 @@ open class BrowseSourcePresenter(
return SourcePager(source, query, filters)
}
private fun FilterList.toItems(): List<IFlexible<*>> {
return mapNotNull { filter ->
when (filter) {
is Filter.Header -> HeaderItem(filter)
// --> EXH
is Filter.HelpDialog -> HelpDialogItem(filter)
is Filter.AutoComplete -> AutoComplete(filter)
// <-- EXH
is Filter.Separator -> SeparatorItem(filter)
is Filter.CheckBox -> CheckboxItem(filter)
is Filter.TriState -> TriStateItem(filter)
is Filter.Text -> TextItem(filter)
is Filter.Select<*> -> SelectItem(filter)
is Filter.Group<*> -> {
val group = GroupItem(filter)
val subItems = filter.state.mapNotNull {
when (it) {
is Filter.CheckBox -> CheckboxSectionItem(it)
is Filter.TriState -> TriStateSectionItem(it)
is Filter.Text -> TextSectionItem(it)
is Filter.Select<*> -> SelectSectionItem(it)
// SY -->
is Filter.AutoComplete -> AutoCompleteSectionItem(it)
// SY <--
else -> null
} as? ISectionable<*, *>
// SY -->
companion object {
// SY <--
fun FilterList.toItems(): List<IFlexible<*>> {
return mapNotNull { filter ->
when (filter) {
is Filter.Header -> HeaderItem(filter)
// --> EXH
is Filter.HelpDialog -> HelpDialogItem(filter)
is Filter.AutoComplete -> AutoComplete(filter)
// <-- EXH
is Filter.Separator -> SeparatorItem(filter)
is Filter.CheckBox -> CheckboxItem(filter)
is Filter.TriState -> TriStateItem(filter)
is Filter.Text -> TextItem(filter)
is Filter.Select<*> -> SelectItem(filter)
is Filter.Group<*> -> {
val group = GroupItem(filter)
val subItems = filter.state.mapNotNull {
when (it) {
is Filter.CheckBox -> CheckboxSectionItem(it)
is Filter.TriState -> TriStateSectionItem(it)
is Filter.Text -> TextSectionItem(it)
is Filter.Select<*> -> SelectSectionItem(it)
// SY -->
is Filter.AutoComplete -> AutoCompleteSectionItem(it)
// SY <--
else -> null
} as? ISectionable<*, *>
}
subItems.forEach { it.header = group }
group.subItems = subItems
group
}
subItems.forEach { it.header = group }
group.subItems = subItems
group
}
is Filter.Sort -> {
val group = SortGroup(filter)
val subItems = filter.values.map {
SortItem(it, group)
is Filter.Sort -> {
val group = SortGroup(filter)
val subItems = filter.values.map {
SortItem(it, group)
}
group.subItems = subItems
group
}
group.subItems = subItems
group
}
}
}
// SY -->
}
// SY <--
/**
* Get user categories.
@ -422,7 +439,6 @@ open class BrowseSourcePresenter(
}
// EXH -->
private val filterSerializer = FilterSerializer()
fun saveSearches(searches: List<EXHSavedSearch>) {
val otherSerialized = prefs.eh_savedSearches().get().filter {
!it.startsWith("${source.id}:")

View File

@ -0,0 +1,155 @@
package eu.kanade.tachiyomi.ui.browse.source.index
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.databinding.IndexAdapterBinding
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import reactivecircus.flowbinding.android.view.clicks
/**
* Adapter that holds the search cards.
*
* @param controller instance of [IndexController].
*/
class IndexAdapter(val controller: IndexController) :
RecyclerView.Adapter<IndexAdapter.ViewHolder>() {
val clickListener: ClickListener = controller
private lateinit var binding: IndexAdapterBinding
private val scope = CoroutineScope(Job() + Dispatchers.Main)
var holder: IndexAdapter.ViewHolder? = null
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): IndexAdapter.ViewHolder {
binding = IndexAdapterBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return ViewHolder(binding.root)
}
override fun onBindViewHolder(holder: IndexAdapter.ViewHolder, position: Int) {
this.holder = holder
holder.bindBrowse(null)
holder.bindLatest(null)
}
// stores and recycles views as they are scrolled off screen
inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
private val browseAdapter = IndexCardAdapter(controller)
private var browseLastBoundResults: List<IndexCardItem>? = null
private val latestAdapter = IndexCardAdapter(controller)
private var latestLastBoundResults: List<IndexCardItem>? = null
init {
binding.browseBarWrapper.clicks()
.onEach {
clickListener.onBrowseClick()
}
.launchIn(scope)
binding.latestBarWrapper.clicks()
.onEach {
clickListener.onLatestClick()
}
.launchIn(scope)
binding.browseRecycler.layoutManager = LinearLayoutManager(itemView.context, LinearLayoutManager.HORIZONTAL, false)
binding.browseRecycler.adapter = browseAdapter
binding.latestRecycler.layoutManager = LinearLayoutManager(itemView.context, LinearLayoutManager.HORIZONTAL, false)
binding.latestRecycler.adapter = latestAdapter
}
fun bindBrowse(browseResults: List<IndexCardItem>?) {
when {
browseResults == null -> {
binding.browseProgress.isVisible = true
showBrowseResultsHolder()
}
browseResults.isEmpty() -> {
binding.browseProgress.isVisible = false
showBrowseNoResults()
}
else -> {
binding.browseProgress.isVisible = false
showBrowseResultsHolder()
}
}
if (browseResults !== browseLastBoundResults) {
browseAdapter.updateDataSet(browseResults)
browseLastBoundResults = browseResults
}
}
fun bindLatest(latestResults: List<IndexCardItem>?) {
when {
latestResults == null -> {
binding.latestProgress.isVisible = true
showLatestResultsHolder()
}
latestResults.isEmpty() -> {
binding.latestProgress.isVisible = false
showLatestNoResults()
}
else -> {
binding.latestProgress.isVisible = false
showLatestResultsHolder()
}
}
if (latestResults !== latestLastBoundResults) {
latestAdapter.updateDataSet(latestResults)
latestLastBoundResults = latestResults
}
}
private fun showBrowseResultsHolder() {
binding.browseNoResultsFound.isVisible = false
binding.browseCard.isVisible = true
}
private fun showBrowseNoResults() {
binding.browseNoResultsFound.isVisible = true
binding.browseCard.isVisible = false
}
private fun showLatestResultsHolder() {
binding.latestNoResultsFound.isVisible = false
binding.latestCard.isVisible = true
}
private fun showLatestNoResults() {
binding.latestNoResultsFound.isVisible = true
binding.latestCard.isVisible = false
}
fun setLatestImage(manga: Manga) {
latestAdapter.allBoundViewHolders.forEach {
if (it !is IndexCardHolder) return@forEach
if (latestAdapter.getItem(it.bindingAdapterPosition)?.manga?.id != manga.id) return@forEach
it.setImage(manga)
}
}
fun setBrowseImage(manga: Manga) {
browseAdapter.allBoundViewHolders.forEach {
if (it !is IndexCardHolder) return@forEach
if (browseAdapter.getItem(it.bindingAdapterPosition)?.manga?.id != manga.id) return@forEach
it.setImage(manga)
}
}
}
interface ClickListener {
fun onBrowseClick(search: String? = null, filters: String? = null)
fun onLatestClick()
}
override fun getItemCount(): Int = 1
}

View File

@ -0,0 +1,27 @@
package eu.kanade.tachiyomi.ui.browse.source.index
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 [IndexController].
*/
class IndexCardAdapter(controller: IndexController) :
FlexibleAdapter<IndexCardItem>(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 [IndexController]
*/
interface OnMangaClickListener {
fun onMangaClick(manga: Manga)
fun onMangaLongClick(manga: Manga)
}
}

View File

@ -0,0 +1,54 @@
package eu.kanade.tachiyomi.ui.browse.source.index
import android.view.View
import com.bumptech.glide.load.engine.DiskCacheStrategy
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.glide.GlideApp
import eu.kanade.tachiyomi.data.glide.toMangaThumbnail
import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder
import eu.kanade.tachiyomi.widget.StateImageViewTarget
import kotlinx.android.synthetic.main.global_search_controller_comfortable_card_item.itemImage
import kotlinx.android.synthetic.main.global_search_controller_comfortable_card_item.progress
import kotlinx.android.synthetic.main.global_search_controller_comfortable_card_item.tvTitle
class IndexCardHolder(view: View, adapter: IndexCardAdapter) :
BaseFlexibleViewHolder(view, adapter) {
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) {
tvTitle.text = manga.title
// Set alpha of thumbnail.
itemImage.alpha = if (manga.favorite) 0.3f else 1.0f
setImage(manga)
}
fun setImage(manga: Manga) {
GlideApp.with(itemView.context).clear(itemImage)
if (!manga.thumbnail_url.isNullOrEmpty()) {
GlideApp.with(itemView.context)
.load(manga.toMangaThumbnail())
.diskCacheStrategy(DiskCacheStrategy.DATA)
.centerCrop()
.skipMemoryCache(true)
.placeholder(android.R.color.transparent)
.into(StateImageViewTarget(itemImage, progress))
}
}
}

View File

@ -0,0 +1,48 @@
package eu.kanade.tachiyomi.ui.browse.source.index
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
import eu.kanade.tachiyomi.data.preference.PreferenceValues
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.ui.browse.latest.LatestCardItem
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class IndexCardItem(val manga: Manga) : AbstractFlexibleItem<IndexCardHolder>() {
override fun getLayoutRes(): Int {
return when (Injekt.get<PreferencesHelper>().sourceDisplayMode().get()) {
PreferenceValues.DisplayMode.COMPACT_GRID -> R.layout.global_search_controller_compact_card_item
else -> R.layout.global_search_controller_comfortable_card_item
}
}
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): IndexCardHolder {
return IndexCardHolder(view, adapter as IndexCardAdapter)
}
override fun bindViewHolder(
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
holder: IndexCardHolder,
position: Int,
payloads: List<Any?>?
) {
holder.bind(manga)
}
override fun equals(other: Any?): Boolean {
if (other is LatestCardItem) {
return manga.id == other.manga.id
}
return false
}
override fun hashCode(): Int {
return manga.id?.toInt() ?: 0
}
}

View File

@ -0,0 +1,302 @@
package eu.kanade.tachiyomi.ui.browse.source.index
import android.os.Bundle
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.widget.SearchView
import androidx.core.view.isVisible
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.afollestad.materialdialogs.MaterialDialog
import com.elvishew.xlog.XLog
import com.github.salomonbrys.kotson.jsonObject
import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
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.NucleusController
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 exh.util.nullIfBlank
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import reactivecircus.flowbinding.appcompat.QueryTextEvent
import reactivecircus.flowbinding.appcompat.queryTextEvents
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 [IndexPresenter]
* [IndexCardAdapter.OnMangaClickListener] called when manga is clicked in global search
*/
open class IndexController :
NucleusController<LatestControllerBinding, IndexPresenter>,
FabController,
IndexCardAdapter.OnMangaClickListener,
IndexAdapter.ClickListener {
constructor(source: CatalogueSource?) : super(
Bundle().apply {
putLong(SOURCE_EXTRA, 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))
var source: CatalogueSource? = null
/**
* Adapter containing search results grouped by lang.
*/
protected var adapter: IndexAdapter? = null
private var actionFab: ExtendedFloatingActionButton? = null
private var actionFabScrollListener: RecyclerView.OnScrollListener? = null
/**
* Sheet containing filter items.
*/
private var filterSheet: SourceFilterSheet? = null
init {
setHasOptionsMenu(true)
}
/**
* Initiate the view with [R.layout.global_search_controller].
*
* @param inflater used to load the layout xml.
* @param container containing parent views.
* @return inflated view
*/
override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View {
binding = LatestControllerBinding.inflate(inflater)
return binding.root
}
override fun getTitle(): String? {
return source!!.name
}
/**
* Create the [LatestPresenter] used in controller.
*
* @return instance of [LatestPresenter]
*/
override fun createPresenter(): IndexPresenter {
return IndexPresenter(source!!)
}
/**
* Called when manga in global search is clicked, opens manga.
*
* @param manga clicked item containing manga information.
*/
override fun onMangaClick(manga: Manga) {
// Open MangaController.
router.pushController(MangaController(manga, 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) {
// Inflate menu.
inflater.inflate(R.menu.global_search, menu)
// Initialize search menu
val searchItem = menu.findItem(R.id.action_search)
val searchView = searchItem.actionView as SearchView
searchView.maxWidth = Int.MAX_VALUE
val query = presenter.query
if (!query.isBlank()) {
searchItem.expandActionView()
searchView.setQuery(query, true)
searchView.clearFocus()
}
searchView.queryTextEvents()
.filter { router.backstack.lastOrNull()?.controller() == this@IndexController }
.filterIsInstance<QueryTextEvent.QuerySubmitted>()
.onEach { onBrowseClick(presenter.query.nullIfBlank()) }
.launchIn(scope)
searchView.queryTextEvents()
.filter { router.backstack.lastOrNull()?.controller() == this@IndexController }
.filterIsInstance<QueryTextEvent.QueryChanged>()
.onEach { presenter.query = it.queryText.toString() }
.launchIn(scope)
searchItem.fixExpand(
onExpand = { invalidateMenuOnExpand() }
)
}
/**
* Called when the view is created
*
* @param view view of controller
*/
override fun onViewCreated(view: View) {
super.onViewCreated(view)
// Prepare filter sheet
initFilterSheet()
adapter = IndexAdapter(this)
// Create recycler and set adapter.
binding.recycler.layoutManager = LinearLayoutManager(view.context)
binding.recycler.adapter = adapter
presenter.getLatest()
}
private val filterSerializer = FilterSerializer()
open fun initFilterSheet() {
if (presenter.sourceFilters.isEmpty()) {
actionFab?.text = activity!!.getString(R.string.saved_searches)
}
filterSheet = SourceFilterSheet(
activity!!,
// SY -->
presenter.loadSearches(),
// SY <--
onFilterClicked = {
val allDefault = presenter.sourceFilters == presenter.source.getFilterList()
filterSheet?.dismiss()
if (!allDefault) {
val json = jsonObject("filters" to filterSerializer.serialize(presenter.sourceFilters))
XLog.nst().json(json.toString())
onBrowseClick(presenter.query.nullIfBlank(), json.toString())
}
},
onResetClicked = {},
onSaveClicked = {},
onSavedSearchClicked = cb@{ indexToSearch ->
val savedSearches = presenter.loadSearches()
val search = savedSearches.getOrNull(indexToSearch)
if (search == null) {
filterSheet?.context?.let {
MaterialDialog(it)
.title(R.string.save_search_failed_to_load)
.message(R.string.save_search_failed_to_load_message)
.cancelable(true)
.cancelOnTouchOutside(true)
.show()
}
return@cb
}
presenter.sourceFilters = FilterList(search.filterList)
filterSheet?.setFilters(presenter.filterItems)
val allDefault = presenter.sourceFilters == presenter.source.getFilterList()
filterSheet?.dismiss()
if (!allDefault) {
val json = jsonObject("filters" to filterSerializer.serialize(presenter.sourceFilters))
onBrowseClick(presenter.query.nullIfBlank(), json.toString())
}
},
onSavedSearchDeleteClicked = { _, _ -> }
)
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) {
actionFabScrollListener?.let { binding.recycler.removeOnScrollListener(it) }
actionFab = null
}
fun setLatestManga(results: List<IndexCardItem>?) {
adapter?.holder?.bindLatest(results)
}
fun setBrowseManga(results: List<IndexCardItem>?) {
adapter?.holder?.bindBrowse(results)
}
override fun onDestroyView(view: View) {
adapter = null
super.onDestroyView(view)
}
override fun onBrowseClick(search: String?, filters: String?) {
router.replaceTopController(BrowseSourceController(presenter.source, search, filterList = filters).withFadeTransaction())
}
override fun onLatestClick() {
router.replaceTopController(LatestUpdatesController(presenter.source).withFadeTransaction())
}
/**
* Called from the presenter when a manga is initialized.
*
* @param manga the initialized manga.
*/
fun onMangaInitialized(manga: Manga, isLatest: Boolean) {
if (isLatest) adapter?.holder?.setLatestImage(manga)
else adapter?.holder?.setBrowseImage(manga)
}
companion object {
const val SOURCE_EXTRA = "source"
}
}

View File

@ -0,0 +1,239 @@
package eu.kanade.tachiyomi.ui.browse.source.index
import android.os.Bundle
import com.github.salomonbrys.kotson.array
import com.github.salomonbrys.kotson.obj
import com.github.salomonbrys.kotson.string
import com.google.gson.JsonParser
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.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.SManga
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter.Companion.toItems
import exh.EXHSavedSearch
import java.lang.RuntimeException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import rx.Observable
import rx.Subscription
import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
import rx.subjects.PublishSubject
import timber.log.Timber
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import xyz.nulldev.ts.api.http.serializer.FilterSerializer
/**
* Presenter of [IndexController]
* 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 IndexPresenter(
val source: CatalogueSource,
val db: DatabaseHelper = Injekt.get(),
val preferences: PreferencesHelper = Injekt.get()
) : BasePresenter<IndexController>() {
/**
* Fetches the different sources by user settings.
*/
private var fetchSourcesSubscription: Subscription? = null
private val scope = CoroutineScope(Job() + Dispatchers.Main)
/**
* Query from the view.
*/
var query = ""
/**
* Subject which fetches image of given manga.
*/
private val fetchImageSubject = PublishSubject.create<List<Pair<Manga, Boolean>>>()
/**
* Modifiable list of filters.
*/
var sourceFilters = FilterList()
set(value) {
field = value
filterItems = value.toItems()
}
var filterItems: List<IFlexible<*>> = emptyList()
/**
* Subscription for fetching images of manga.
*/
private var fetchImageSubscription: Subscription? = null
override fun onDestroy() {
fetchSourcesSubscription?.unsubscribe()
fetchImageSubscription?.unsubscribe()
super.onDestroy()
}
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
sourceFilters = source.getFilterList()
}
/**
* Initiates get latest per watching source.
*/
fun getLatest() {
// Create image fetch subscription
initializeFetchImageSubscription()
scope.launch(Dispatchers.IO) {
withContext(Dispatchers.Main) {
Observable.just(null).subscribeLatestCache({ view, results ->
view.setLatestManga(results)
})
}
if (source.supportsLatest) {
val results = source.fetchLatestUpdates(1)
.toBlocking()
.single()
.mangas
.take(10)
.map { networkToLocalManga(it, source.id) }
fetchImage(results, true)
withContext(Dispatchers.Main) {
Observable.just(results.map { IndexCardItem(it) }).subscribeLatestCache({ view, results ->
view.setLatestManga(results)
})
}
}
}
scope.launch(Dispatchers.IO) {
withContext(Dispatchers.Main) {
Observable.just(null).subscribeLatestCache({ view, results ->
view.setBrowseManga(results)
})
}
val results = source.fetchPopularManga(1)
.toBlocking()
.single()
.mangas
.take(10)
.map { networkToLocalManga(it, source.id) }
fetchImage(results, false)
withContext(Dispatchers.Main) {
Observable.just(results.map { IndexCardItem(it) }).subscribeLatestCache({ view, results ->
view.setBrowseManga(results)
})
}
}
}
/**
* Initialize a list of manga.
*
* @param manga the list of manga to initialize.
*/
private fun fetchImage(manga: List<Manga>, isLatest: Boolean) {
fetchImageSubject.onNext(manga.map { it to isLatest })
}
/**
* 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 ->
Observable.from(pair).filter { it.first.thumbnail_url == null && !it.first.initialized }
.concatMap { getMangaDetailsObservable(it.first, source, it.second) }
}
.onBackpressureBuffer()
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ pair ->
@Suppress("DEPRECATION")
view?.onMangaInitialized(pair.first, pair.second)
},
{ error ->
Timber.e(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, isLatest: Boolean): Observable<Pair<Manga, Boolean>> {
return source.fetchMangaDetails(manga)
.flatMap { networkManga ->
manga.copyFrom(networkManga)
manga.initialized = true
db.insertManga(manga).executeAsBlocking()
Observable.just(manga to isLatest)
}
.onErrorResumeNext { Observable.just(manga to isLatest) }
}
/**
* 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
}
private val filterSerializer = FilterSerializer()
fun loadSearches(): List<EXHSavedSearch> {
val loaded = preferences.eh_savedSearches().get()
return loaded.map {
try {
val id = it.substringBefore(':').toLong()
if (id != source.id) return@map null
val content = JsonParser.parseString(it.substringAfter(':')).obj
val originalFilters = source.getFilterList()
filterSerializer.deserialize(originalFilters, content["filters"].array)
EXHSavedSearch(
content["name"].string,
content["query"].string,
originalFilters
)
} catch (t: RuntimeException) {
// Load failed
Timber.e(t, "Failed to load saved search!")
t.printStackTrace()
null
}
}.filterNotNull()
}
}

View File

@ -34,6 +34,12 @@ class SettingsBrowseController : SettingsController() {
router.pushController(SourceCategoryController().withFadeTransaction())
}
}
switchPreference {
key = Keys.useNewSourceNavigation
titleRes = R.string.pref_source_navigation
summaryRes = R.string.pref_source_navigation_summery
defaultValue = false
}
}
preferenceCategory {

View File

@ -0,0 +1,152 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/browse_bar_wrapper"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground">
<TextView
android:id="@+id/browse"
style="@style/TextAppearance.Regular.SubHeading"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:padding="@dimen/material_component_text_fields_padding_above_and_below_label"
android:text="@string/browse"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/browse_bar_more_icon"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/browse_bar_more_icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/label_more"
android:padding="@dimen/material_component_text_fields_padding_above_and_below_label"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_arrow_forward_24dp"
app:tint="?android:attr/textColorPrimary" />
</androidx.constraintlayout.widget.ConstraintLayout>
<TextView
android:id="@+id/browse_no_results_found"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="@dimen/material_component_text_fields_padding_above_and_below_label"
android:paddingEnd="@dimen/material_component_text_fields_padding_above_and_below_label"
android:paddingBottom="@dimen/material_component_text_fields_padding_above_and_below_label"
android:text="@string/no_results_found"
android:visibility="gone" />
<androidx.cardview.widget.CardView
android:id="@+id/browse_card"
style="@style/Theme.Widget.CardView.Item"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="144dp">
<ProgressBar
android:id="@+id/browse_progress"
style="?android:attr/progressBarStyleSmall"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/browse_recycler"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipToPadding="false"
android:orientation="horizontal"
android:paddingStart="4dp"
android:paddingEnd="4dp"
tools:listitem="@layout/global_search_controller_comfortable_card_item" />
</androidx.cardview.widget.CardView>
</LinearLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/latest_bar_wrapper"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground">
<TextView
android:id="@+id/latest"
style="@style/TextAppearance.Regular.SubHeading"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:padding="@dimen/material_component_text_fields_padding_above_and_below_label"
android:text="@string/latest"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/latest_bar_more_icon"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/latest_bar_more_icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/label_more"
android:padding="@dimen/material_component_text_fields_padding_above_and_below_label"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_arrow_forward_24dp"
app:tint="?android:attr/textColorPrimary" />
</androidx.constraintlayout.widget.ConstraintLayout>
<TextView
android:id="@+id/latest_no_results_found"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="@dimen/material_component_text_fields_padding_above_and_below_label"
android:paddingEnd="@dimen/material_component_text_fields_padding_above_and_below_label"
android:paddingBottom="@dimen/material_component_text_fields_padding_above_and_below_label"
android:text="@string/no_results_found"
android:visibility="gone" />
<androidx.cardview.widget.CardView
android:id="@+id/latest_card"
style="@style/Theme.Widget.CardView.Item"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="144dp">
<ProgressBar
android:id="@+id/latest_progress"
style="?android:attr/progressBarStyleSmall"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/latest_recycler"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipToPadding="false"
android:orientation="horizontal"
android:paddingStart="4dp"
android:paddingEnd="4dp"
tools:listitem="@layout/global_search_controller_comfortable_card_item" />
</androidx.cardview.widget.CardView>
</LinearLayout>

View File

@ -157,6 +157,13 @@
<string name="library_group_updates_all_but_ungrouped">Launch global updates only for ungrouped, category updates for others</string>
<string name="library_group_updates_all">Launch category updates all the time</string>
<!-- Browse settings -->
<string name="pref_latest_tab_language_code">Display language code next to name</string>
<string name="pref_latest_position">Latest tab position</string>
<string name="pref_latest_position_summery">Do you want the latest tab to be the first tab in browse? This will make it the default tab when opening browse, not recommended if your on data or a metered network</string>
<string name="pref_source_navigation">Replace latest button</string>
<string name="pref_source_navigation_summery">Replace latest button with a custom browse view that includes both latest and browse</string>
<!-- Reader Settings -->
<string name="download_threads">Download threads</string>
<string name="download_threads_summary">Higher values can speed up image downloading significantly, but can also trigger bans. Recommended value is 2 or 3. Current value is: %s</string>
@ -234,9 +241,6 @@
<string name="unwatch">Unwatch</string>
<string name="too_many_watched">Too many watched sources, cannot add more then 5</string>
<string name="latest_tab_empty">You don\'t have any watched sources, go to the sources tab and long press a source to watch it</string>
<string name="pref_latest_tab_language_code">Display language code next to name</string>
<string name="pref_latest_position">Latest tab position</string>
<string name="pref_latest_position_summery">Do you want the latest tab to be the first tab in browse? This will make it the default tab when opening browse, not recommended if your on data or a metered network</string>
<!-- Extension section -->
<string name="ext_redundant">Redundant</string>