diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt index 7c3869397..c049ccadb 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt @@ -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" } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt index 94ac50aaa..a9c32ba69 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt @@ -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) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceController.kt index bbf23fbde..4b81b9808 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceController.kt @@ -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. * diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceHolder.kt index 58a9dc875..0706a97aa 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceHolder.kt @@ -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 { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceItem.kt index 6e04374ea..cbe1d2105 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceItem.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceItem.kt @@ -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(header) { @@ -35,7 +36,7 @@ data class SourceItem( * Creates a new view holder for this item. */ override fun createViewHolder(view: View, adapter: FlexibleAdapter>): SourceHolder { - return SourceHolder(view, adapter as SourceAdapter /* SY --> */, showButtons /* SY <-- */) + return SourceHolder(view, adapter as SourceAdapter /* SY --> */, showLatest, showPins /* SY <-- */) } /** diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcePresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcePresenter.kt index 69419b78e..1d910b543 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcePresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcePresenter.kt @@ -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) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceController.kt index 974061ad6..67d0111b4 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceController.kt @@ -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 <-- } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourcePresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourcePresenter.kt index c943299df..45c434e02 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourcePresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourcePresenter.kt @@ -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> { - 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> { + 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) { val otherSerialized = prefs.eh_savedSearches().get().filter { !it.startsWith("${source.id}:") diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/index/IndexAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/index/IndexAdapter.kt new file mode 100644 index 000000000..db1d86e1f --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/index/IndexAdapter.kt @@ -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() { + + 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? = null + + private val latestAdapter = IndexCardAdapter(controller) + private var latestLastBoundResults: List? = 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?) { + 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?) { + 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 +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/index/IndexCardAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/index/IndexCardAdapter.kt new file mode 100644 index 000000000..4f3964378 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/index/IndexCardAdapter.kt @@ -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(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) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/index/IndexCardHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/index/IndexCardHolder.kt new file mode 100644 index 000000000..8826453d2 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/index/IndexCardHolder.kt @@ -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)) + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/index/IndexCardItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/index/IndexCardItem.kt new file mode 100644 index 000000000..e56dafa0b --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/index/IndexCardItem.kt @@ -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() { + + override fun getLayoutRes(): Int { + return when (Injekt.get().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>): IndexCardHolder { + return IndexCardHolder(view, adapter as IndexCardAdapter) + } + + override fun bindViewHolder( + adapter: FlexibleAdapter>, + holder: IndexCardHolder, + position: Int, + payloads: List? + ) { + 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 + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/index/IndexController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/index/IndexController.kt new file mode 100644 index 000000000..413c02b7e --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/index/IndexController.kt @@ -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, + 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().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() + .onEach { onBrowseClick(presenter.query.nullIfBlank()) } + .launchIn(scope) + + searchView.queryTextEvents() + .filter { router.backstack.lastOrNull()?.controller() == this@IndexController } + .filterIsInstance() + .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?) { + adapter?.holder?.bindLatest(results) + } + + fun setBrowseManga(results: List?) { + 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" + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/index/IndexPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/index/IndexPresenter.kt new file mode 100644 index 000000000..226f5178c --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/index/IndexPresenter.kt @@ -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() { + + /** + * 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>>() + + /** + * Modifiable list of filters. + */ + var sourceFilters = FilterList() + set(value) { + field = value + filterItems = value.toItems() + } + + var filterItems: List> = 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, 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> { + 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 { + 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() + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBrowseController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBrowseController.kt index 68dcca1c8..e9ce98beb 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBrowseController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBrowseController.kt @@ -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 { diff --git a/app/src/main/res/layout/index_adapter.xml b/app/src/main/res/layout/index_adapter.xml new file mode 100644 index 000000000..bea19d9f6 --- /dev/null +++ b/app/src/main/res/layout/index_adapter.xml @@ -0,0 +1,152 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings_sy.xml b/app/src/main/res/values/strings_sy.xml index 37fe6ae0e..4e9c3153f 100644 --- a/app/src/main/res/values/strings_sy.xml +++ b/app/src/main/res/values/strings_sy.xml @@ -157,6 +157,13 @@ Launch global updates only for ungrouped, category updates for others Launch category updates all the time + + Display language code next to name + Latest tab position + 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 + Replace latest button + Replace latest button with a custom browse view that includes both latest and browse + Download threads Higher values can speed up image downloading significantly, but can also trigger bans. Recommended value is 2 or 3. Current value is: %s @@ -234,9 +241,6 @@ Unwatch Too many watched sources, cannot add more then 5 You don\'t have any watched sources, go to the sources tab and long press a source to watch it - Display language code next to name - Latest tab position - 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 Redundant