From 5ca57226fe75a11cf987164d74f29e6e8fb46581 Mon Sep 17 00:00:00 2001 From: Jobobby04 Date: Tue, 16 Jun 2020 15:28:57 -0400 Subject: [PATCH] Add custom categories for sources, with it you could group your scanlators or hentai sources in their own categories in the Sources tab in the new categories! --- .../data/preference/PreferenceKeys.kt | 4 + .../data/preference/PreferencesHelper.kt | 4 + .../ui/browse/source/SourceController.kt | 59 +++- .../ui/browse/source/SourcePresenter.kt | 37 ++ .../sources/ChangeSourceCategoriesDialog.kt | 51 +++ .../category/sources/SourceCategoryAdapter.kt | 30 ++ .../sources/SourceCategoryController.kt | 330 ++++++++++++++++++ .../sources/SourceCategoryCreateDialog.kt | 50 +++ .../category/sources/SourceCategoryHolder.kt | 26 ++ .../ui/category/sources/SourceCategoryItem.kt | 62 ++++ .../sources/SourceCategoryPresenter.kt | 132 +++++++ .../sources/SourceCategoryRenameDialog.kt | 86 +++++ .../ui/setting/SettingsBrowseController.kt | 19 + .../tachiyomi/util/system/LocaleHelper.kt | 3 + app/src/main/res/values/strings_extra.xml | 2 + 15 files changed, 894 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/category/sources/ChangeSourceCategoriesDialog.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/category/sources/SourceCategoryAdapter.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/category/sources/SourceCategoryController.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/category/sources/SourceCategoryCreateDialog.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/category/sources/SourceCategoryHolder.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/category/sources/SourceCategoryItem.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/category/sources/SourceCategoryPresenter.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/category/sources/SourceCategoryRenameDialog.kt 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 0623111d9..cdced0579 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 @@ -262,4 +262,8 @@ object PreferenceKeys { const val latest_tab_position = "latest_tab_position" const val latest_tab_language_code = "latest_tab_language_code" + + const val sources_tab_categories = "sources_tab_categories" + + const val sources_tab_source_categories = "sources_tab_source_categories" } 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 c2f85b422..1b0ddf56e 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 @@ -362,4 +362,8 @@ class PreferencesHelper(val context: Context) { fun latestTabInFront() = flowPrefs.getBoolean(Keys.latest_tab_position, false) fun latestTabDisplayLanguageCode() = flowPrefs.getBoolean(Keys.latest_tab_language_code, false) + + fun sourcesTabCategories() = flowPrefs.getStringSet(Keys.sources_tab_categories, mutableSetOf()) + + fun sourcesTabSourcesInCategories() = flowPrefs.getStringSet(Keys.sources_tab_source_categories, mutableSetOf()) } 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 6436e210c..1107b8522 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 @@ -30,6 +30,7 @@ import eu.kanade.tachiyomi.ui.browse.BrowseController 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.latest.LatestUpdatesController +import eu.kanade.tachiyomi.ui.category.sources.ChangeSourceCategoriesDialog import eu.kanade.tachiyomi.util.system.toast import exh.ui.smartsearch.SmartSearchController import kotlinx.android.parcel.Parcelize @@ -52,7 +53,8 @@ class SourceController(bundle: Bundle? = null) : FlexibleAdapter.OnItemClickListener, FlexibleAdapter.OnItemLongClickListener, SourceAdapter.OnBrowseClickListener, - SourceAdapter.OnLatestClickListener { + SourceAdapter.OnLatestClickListener, + ChangeSourceCategoriesDialog.Listener { private val preferences: PreferencesHelper = Injekt.get() @@ -179,6 +181,13 @@ class SourceController(bundle: Bundle? = null) : ) } + items.add( + Pair( + activity.getString(R.string.label_categories), + { addToCategories(item.source) } + ) + ) + MaterialDialog(activity) .title(text = item.source.name) .listItems( @@ -222,6 +231,54 @@ class SourceController(bundle: Bundle? = null) : preferences.latestTabSources().set(current + source.id.toString()) } } + + private fun addToCategories(source: Source) { + val categories = preferences.sourcesTabCategories().get().toList().sortedBy { it.toLowerCase() } + + if (categories.isEmpty()) { + applicationContext?.toast(R.string.no_source_categories) + return + } + + val preferenceSources = preferences.sourcesTabSourcesInCategories().get().toMutableList() + val sources = preferenceSources.map { it.split("|")[0] } + + if (source.id.toString() in sources) { + val preferenceSources = preferenceSources.map { it.split("|") }.filter { it[0] == source.id.toString() }.map { Pair(it[0], it[1]) }.toMutableList() + + val preselected = preferenceSources.map { category -> + categories.indexOf(category.second) + }.toTypedArray() + + ChangeSourceCategoriesDialog(this, source, categories, preselected) + .showDialog(router) + } else { + ChangeSourceCategoriesDialog(this, source, categories, emptyArray()) + .showDialog(router) + } + } + + override fun updateCategoriesForSource(source: Source, categories: List) { + var preferenceSources = preferences.sourcesTabSourcesInCategories().get().toMutableList() + val sources = preferenceSources.map { it.split("|")[0] } + + if (source.id.toString() in sources) { + preferenceSources = preferenceSources + .map { it.split("|") } + .filter { it[0] != source.id.toString() } + .map { it[0] + "|" + it[1] }.toMutableList() + } + + categories.forEach { + preferenceSources.add(source.id.toString() + "|" + it) + } + + preferences.sourcesTabSourcesInCategories().set( + preferenceSources.sorted().toSet() + ) + presenter.updateSources() + } + /** * Called when browse is clicked in [SourceAdapter] */ 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 e9a4c9f77..dd8a01873 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 @@ -60,6 +60,19 @@ class SourcePresenter( val pinnedSources = mutableListOf() val pinnedCatalogues = preferences.pinnedCatalogues().get() + val categories = mutableListOf() + + preferences.sourcesTabCategories().get().sortedByDescending { it.toLowerCase() }.forEach { + categories.add(SourceCategory(it)) + } + + val sourcesAndCategoriesCombined = preferences.sourcesTabSourcesInCategories().get() + val sourcesAndCategories = if (sourcesAndCategoriesCombined.isNotEmpty()) sourcesAndCategoriesCombined.map { + val temp = it.split("|") + Pair(temp[0], temp[1]) + } else null + + val sourcesInCategories = sourcesAndCategories?.map { it.first } val map = TreeMap> { d1, d2 -> // Catalogues without a lang defined will be placed at the end @@ -77,10 +90,32 @@ class SourcePresenter( pinnedSources.add(SourceItem(source, LangItem(PINNED_KEY), controllerMode == SourceController.Mode.CATALOGUE)) } + if (sourcesInCategories != null && source.id.toString() in sourcesInCategories) { + sourcesAndCategories + .filter { SourcesAndCategory -> SourcesAndCategory.first == source.id.toString() } + .forEach { SourceAndCategory -> + categories.forEach { dataClass -> + if (dataClass.category.trim() == SourceAndCategory.second.trim()) { + dataClass.sources.add( + SourceItem( + source, + LangItem("custom|" + SourceAndCategory.second), + controllerMode == SourceController.Mode.CATALOGUE + ) + ) + } + } + } + } + SourceItem(source, langItem, controllerMode == SourceController.Mode.CATALOGUE) } } + categories.forEach { + sourceItems = it.sources.sortedBy { sourceItem -> sourceItem.source.name.toLowerCase() } + sourceItems + } + if (pinnedSources.isNotEmpty()) { sourceItems = pinnedSources + sourceItems } @@ -134,3 +169,5 @@ class SourcePresenter( const val LAST_USED_KEY = "last_used" } } + +data class SourceCategory(val category: String, var sources: MutableList = mutableListOf()) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/category/sources/ChangeSourceCategoriesDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/category/sources/ChangeSourceCategoriesDialog.kt new file mode 100644 index 000000000..8359c049e --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/category/sources/ChangeSourceCategoriesDialog.kt @@ -0,0 +1,51 @@ +package eu.kanade.tachiyomi.ui.category.sources + +import android.app.Dialog +import android.os.Bundle +import com.afollestad.materialdialogs.MaterialDialog +import com.afollestad.materialdialogs.list.listItemsMultiChoice +import com.bluelinelabs.conductor.Controller +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.ui.base.controller.DialogController + +class ChangeSourceCategoriesDialog(bundle: Bundle? = null) : + DialogController(bundle) where T : Controller, T : ChangeSourceCategoriesDialog.Listener { + + private var source: Source? = null + + private var categories = emptyList() + + private var preselected = emptyArray() + + constructor( + target: T, + source: Source, + categories: List, + preselected: Array + ) : this() { + this.source = source + this.categories = categories + this.preselected = preselected + targetController = target + } + + override fun onCreateDialog(savedViewState: Bundle?): Dialog { + return MaterialDialog(activity!!) + .title(R.string.action_move_category) + .listItemsMultiChoice( + items = categories, + initialSelection = preselected.toIntArray(), + allowEmptySelection = true + ) { _, selections, _ -> + val newCategories = selections.map { categories[it] } + (targetController as? Listener)?.updateCategoriesForSource(source!!, newCategories) + } + .positiveButton(android.R.string.ok) + .negativeButton(android.R.string.cancel) + } + + interface Listener { + fun updateCategoriesForSource(source: Source, categories: List) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/category/sources/SourceCategoryAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/category/sources/SourceCategoryAdapter.kt new file mode 100644 index 000000000..8be27e407 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/category/sources/SourceCategoryAdapter.kt @@ -0,0 +1,30 @@ +package eu.kanade.tachiyomi.ui.category.sources + +import eu.davidea.flexibleadapter.FlexibleAdapter + +/** + * Custom adapter for categories. + * + * @param controller The containing controller. + */ +class SourceCategoryAdapter(controller: SourceCategoryController) : + FlexibleAdapter(null, controller, true) { + + /** + * Clears the active selections from the list and the model. + */ + override fun clearSelection() { + super.clearSelection() + (0 until itemCount).forEach { getItem(it)?.isSelected = false } + } + + /** + * Toggles the selection of the given position. + * + * @param position The position to toggle. + */ + override fun toggleSelection(position: Int) { + super.toggleSelection(position) + getItem(position)?.isSelected = isSelected(position) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/category/sources/SourceCategoryController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/category/sources/SourceCategoryController.kt new file mode 100644 index 000000000..96d7badd9 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/category/sources/SourceCategoryController.kt @@ -0,0 +1,330 @@ +package eu.kanade.tachiyomi.ui.category.sources + +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.view.ActionMode +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.snackbar.Snackbar +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.davidea.flexibleadapter.SelectableAdapter +import eu.davidea.flexibleadapter.helpers.UndoHelper +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.databinding.CategoriesControllerBinding +import eu.kanade.tachiyomi.ui.base.controller.NucleusController +import eu.kanade.tachiyomi.ui.main.offsetAppbarHeight +import eu.kanade.tachiyomi.util.system.toast +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import reactivecircus.flowbinding.android.view.clicks + +/** + * Controller to manage the categories for the users' library. + */ +class SourceCategoryController : + NucleusController(), + ActionMode.Callback, + FlexibleAdapter.OnItemClickListener, + FlexibleAdapter.OnItemLongClickListener, + SourceCategoryCreateDialog.Listener, + SourceCategoryRenameDialog.Listener, + UndoHelper.OnActionListener { + + /** + * Object used to show ActionMode toolbar. + */ + private var actionMode: ActionMode? = null + + /** + * Adapter containing category items. + */ + private var adapter: SourceCategoryAdapter? = null + + /** + * Undo helper used for restoring a deleted category. + */ + private var undoHelper: UndoHelper? = null + + /** + * Creates the presenter for this controller. Not to be manually called. + */ + override fun createPresenter() = SourceCategoryPresenter() + + /** + * Returns the toolbar title to show when this controller is attached. + */ + override fun getTitle(): String? { + return resources?.getString(R.string.action_edit_categories) + } + + /** + * Returns the view of this controller. + * + * @param inflater The layout inflater to create the view from XML. + * @param container The parent view for this one. + */ + override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View { + binding = CategoriesControllerBinding.inflate(inflater) + return binding.root + } + + /** + * Called after view inflation. Used to initialize the view. + * + * @param view The view of this controller. + */ + override fun onViewCreated(view: View) { + super.onViewCreated(view) + + adapter = SourceCategoryAdapter(this@SourceCategoryController) + binding.recycler.layoutManager = LinearLayoutManager(view.context) + binding.recycler.setHasFixedSize(true) + binding.recycler.adapter = adapter + adapter?.isPermanentDelete = false + + binding.fab.clicks() + .onEach { + SourceCategoryCreateDialog(this@SourceCategoryController).showDialog(router, null) + } + .launchIn(scope) + + binding.fab.offsetAppbarHeight(activity!!) + } + + /** + * Called when the view is being destroyed. Used to release references and remove callbacks. + * + * @param view The view of this controller. + */ + override fun onDestroyView(view: View) { + // Manually call callback to delete categories if required + undoHelper?.onDeleteConfirmed(Snackbar.Callback.DISMISS_EVENT_MANUAL) + undoHelper = null + actionMode = null + adapter = null + super.onDestroyView(view) + } + + /** + * Called from the presenter when the categories are updated. + * + * @param categories The new list of categories to display. + */ + fun setCategories(categories: List) { + actionMode?.finish() + adapter?.updateDataSet(categories) + if (categories.isNotEmpty()) { + binding.emptyView.hide() + val selected = categories.filter { it.isSelected } + if (selected.isNotEmpty()) { + selected.forEach { onItemLongClick(categories.indexOf(it)) } + } + } else { + binding.emptyView.show(R.string.information_empty_category) + } + } + + /** + * Called when action mode is first created. The menu supplied will be used to generate action + * buttons for the action mode. + * + * @param mode ActionMode being created. + * @param menu Menu used to populate action buttons. + * @return true if the action mode should be created, false if entering this mode should be + * aborted. + */ + override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { + // Inflate menu. + mode.menuInflater.inflate(R.menu.category_selection, menu) + // Enable adapter multi selection. + adapter?.mode = SelectableAdapter.Mode.MULTI + return true + } + + /** + * Called to refresh an action mode's action menu whenever it is invalidated. + * + * @param mode ActionMode being prepared. + * @param menu Menu used to populate action buttons. + * @return true if the menu or action mode was updated, false otherwise. + */ + override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { + val adapter = adapter ?: return false + val count = adapter.selectedItemCount + mode.title = count.toString() + + // Show edit button only when one item is selected + val editItem = mode.menu.findItem(R.id.action_edit) + editItem.isVisible = count == 1 + return true + } + + /** + * Called to report a user click on an action button. + * + * @param mode The current ActionMode. + * @param item The item that was clicked. + * @return true if this callback handled the event, false if the standard MenuItem invocation + * should continue. + */ + override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { + val adapter = adapter ?: return false + + when (item.itemId) { + R.id.action_delete -> { + undoHelper = UndoHelper(adapter, this) + undoHelper?.start( + adapter.selectedPositions, view!!, + R.string.snack_categories_deleted, R.string.action_undo, 3000 + ) + + mode.finish() + } + R.id.action_edit -> { + // Edit selected category + if (adapter.selectedItemCount == 1) { + val position = adapter.selectedPositions.first() + val category = adapter.getItem(position)?.category + if (category != null) { + editCategory(category) + } + } + } + else -> return false + } + return true + } + + /** + * Called when an action mode is about to be exited and destroyed. + * + * @param mode The current ActionMode being destroyed. + */ + override fun onDestroyActionMode(mode: ActionMode) { + // Reset adapter to single selection + adapter?.mode = SelectableAdapter.Mode.IDLE + adapter?.clearSelection() + actionMode = null + } + + /** + * Called when an item in the list is clicked. + * + * @param position The position of the clicked item. + * @return true if this click should enable selection mode. + */ + override fun onItemClick(view: View, position: Int): Boolean { + // Check if action mode is initialized and selected item exist. + return if (actionMode != null && position != RecyclerView.NO_POSITION) { + toggleSelection(position) + true + } else { + false + } + } + + /** + * Called when an item in the list is long clicked. + * + * @param position The position of the clicked item. + */ + override fun onItemLongClick(position: Int) { + val activity = activity as? AppCompatActivity ?: return + + // Check if action mode is initialized. + if (actionMode == null) { + // Initialize action mode + actionMode = activity.startSupportActionMode(this) + } + + // Set item as selected + toggleSelection(position) + } + + /** + * Toggle the selection state of an item. + * If the item was the last one in the selection and is unselected, the ActionMode is finished. + * + * @param position The position of the item to toggle. + */ + private fun toggleSelection(position: Int) { + val adapter = adapter ?: return + + // Mark the position selected + adapter.toggleSelection(position) + + if (adapter.selectedItemCount == 0) { + actionMode?.finish() + } else { + actionMode?.invalidate() + } + } + + /** + * Called when the undo action is clicked in the snackbar. + * + * @param action The action performed. + */ + override fun onActionCanceled(action: Int, positions: MutableList?) { + adapter?.restoreDeletedItems() + undoHelper = null + } + + /** + * Called when the time to restore the items expires. + * + * @param action The action performed. + * @param event The event that triggered the action + */ + override fun onActionConfirmed(action: Int, event: Int) { + val adapter = adapter ?: return + presenter.deleteCategories(adapter.deletedItems.map { it.category }) + undoHelper = null + } + + /** + * Show a dialog to let the user change the category name. + * + * @param category The category to be edited. + */ + private fun editCategory(category: String) { + SourceCategoryRenameDialog(this, category).showDialog(router) + } + + /** + * Renames the given category with the given name. + * + * @param category The category to rename. + * @param name The new name of the category. + */ + override fun renameCategory(category: String, name: String) { + presenter.renameCategory(category, name) + } + + /** + * Creates a new category with the given name. + * + * @param name The name of the new category. + */ + override fun createCategory(name: String) { + presenter.createCategory(name) + } + + /** + * Called from the presenter when a category with the given name already exists. + */ + fun onCategoryExistsError() { + activity?.toast(R.string.error_category_exists) + } + + /** + * Called from the presenter when a invalid category name is made + */ + fun onCategoryInvalidNameError() { + activity?.toast(R.string.invalid_category_name) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/category/sources/SourceCategoryCreateDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/category/sources/SourceCategoryCreateDialog.kt new file mode 100644 index 000000000..a22cfedda --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/category/sources/SourceCategoryCreateDialog.kt @@ -0,0 +1,50 @@ +package eu.kanade.tachiyomi.ui.category.sources + +import android.app.Dialog +import android.os.Bundle +import com.afollestad.materialdialogs.MaterialDialog +import com.afollestad.materialdialogs.input.input +import com.bluelinelabs.conductor.Controller +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.ui.base.controller.DialogController + +/** + * Dialog to create a new category for the library. + */ +class SourceCategoryCreateDialog(bundle: Bundle? = null) : DialogController(bundle) + where T : Controller, T : SourceCategoryCreateDialog.Listener { + + /** + * Name of the new category. Value updated with each input from the user. + */ + private var currentName = "" + + constructor(target: T) : this() { + targetController = target + } + + /** + * Called when creating the dialog for this controller. + * + * @param savedViewState The saved state of this dialog. + * @return a new dialog instance. + */ + override fun onCreateDialog(savedViewState: Bundle?): Dialog { + return MaterialDialog(activity!!) + .title(R.string.action_add_category) + .negativeButton(android.R.string.cancel) + .input( + hint = resources?.getString(R.string.name), + prefill = currentName + ) { _, input -> + currentName = input.toString() + } + .positiveButton(android.R.string.ok) { + (targetController as? Listener)?.createCategory(currentName) + } + } + + interface Listener { + fun createCategory(name: String) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/category/sources/SourceCategoryHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/category/sources/SourceCategoryHolder.kt new file mode 100644 index 000000000..fb1733451 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/category/sources/SourceCategoryHolder.kt @@ -0,0 +1,26 @@ +package eu.kanade.tachiyomi.ui.category.sources + +import android.view.View +import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder +import eu.kanade.tachiyomi.util.view.gone +import kotlinx.android.synthetic.main.categories_item.reorder +import kotlinx.android.synthetic.main.categories_item.title + +/** + * Holder used to display category items. + * + * @param view The view used by category items. + * @param adapter The adapter containing this holder. + */ +class SourceCategoryHolder(view: View, val adapter: SourceCategoryAdapter) : BaseFlexibleViewHolder(view, adapter) { + /** + * Binds this holder with the given category. + * + * @param category The category to bind. + */ + fun bind(category: String) { + // Set capitalized title. + title.text = category + reorder.gone() + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/category/sources/SourceCategoryItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/category/sources/SourceCategoryItem.kt new file mode 100644 index 000000000..258446651 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/category/sources/SourceCategoryItem.kt @@ -0,0 +1,62 @@ +package eu.kanade.tachiyomi.ui.category.sources + +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 + +/** + * Category item for a recycler view. + */ +class SourceCategoryItem(val category: String) : AbstractFlexibleItem() { + + /** + * Whether this item is currently selected. + */ + var isSelected = false + + /** + * Returns the layout resource for this item. + */ + override fun getLayoutRes(): Int { + return R.layout.categories_item + } + + /** + * Returns a new view holder for this item. + * + * @param view The view of this item. + * @param adapter The adapter of this item. + */ + override fun createViewHolder(view: View, adapter: FlexibleAdapter>): SourceCategoryHolder { + return SourceCategoryHolder(view, adapter as SourceCategoryAdapter) + } + + /** + * Binds the given view holder with this item. + * + * @param adapter The adapter of this item. + * @param holder The holder to bind. + * @param position The position of this item in the adapter. + * @param payloads List of partial changes. + */ + override fun bindViewHolder( + adapter: FlexibleAdapter>, + holder: SourceCategoryHolder, + position: Int, + payloads: List? + ) { + holder.bind(category) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + return false + } + + override fun hashCode(): Int { + return category.hashCode() + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/category/sources/SourceCategoryPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/category/sources/SourceCategoryPresenter.kt new file mode 100644 index 000000000..aef2079bd --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/category/sources/SourceCategoryPresenter.kt @@ -0,0 +1,132 @@ +package eu.kanade.tachiyomi.ui.category.sources + +import android.os.Bundle +import eu.kanade.tachiyomi.data.database.DatabaseHelper +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import rx.Observable +import rx.android.schedulers.AndroidSchedulers +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +/** + * Presenter of [SourceCategoryController]. Used to manage the categories of the library. + */ +class SourceCategoryPresenter( + private val db: DatabaseHelper = Injekt.get() +) : BasePresenter() { + + /** + * List containing categories. + */ + private var categories: List = emptyList() + + val preferences: PreferencesHelper = Injekt.get() + + val scope = CoroutineScope(Job() + Dispatchers.Main) + + /** + * Called when the presenter is created. + * + * @param savedState The saved state of this presenter. + */ + override fun onCreate(savedState: Bundle?) { + super.onCreate(savedState) + + preferences.sourcesTabCategories().asFlow().onEach { categories -> + this.categories = categories.toList().sortedBy { it.toLowerCase() } + + Observable.just(this.categories) + .map { it.map(::SourceCategoryItem) } + .observeOn(AndroidSchedulers.mainThread()) + .subscribeLatestCache(SourceCategoryController::setCategories) + }.launchIn(scope) + } + + /** + * Creates and adds a new category to the database. + * + * @param name The name of the category to create. + */ + fun createCategory(name: String) { + // Do not allow duplicate categories. + if (categoryExists(name)) { + Observable.just(Unit).subscribeFirst({ view, _ -> view.onCategoryExistsError() }) + return + } + + if (name.contains("|")) { + Observable.just(Unit).subscribeFirst({ view, _ -> view.onCategoryInvalidNameError() }) + return + } + + // Create category. + val newCategories = categories.toMutableList() + newCategories += name + + preferences.sourcesTabCategories().set(newCategories.toSet()) + } + + /** + * Deletes the given categories from the database. + * + * @param categories The list of categories to delete. + */ + fun deleteCategories(categories: List) { + var sources = preferences.sourcesTabSourcesInCategories().get().toList() + + sources = sources.map { it.split("|") }.filterNot { it[1] in categories }.map { it[0] + "|" + it[1] } + + preferences.sourcesTabSourcesInCategories().set(sources.toSet()) + preferences.sourcesTabCategories().set( + this.categories.filterNot { it in categories }.toSet() + ) + } + + /** + * Renames a category. + * + * @param category The category to rename. + * @param name The new name of the category. + */ + fun renameCategory(categoryOld: String, categoryNew: String) { + // Do not allow duplicate categories. + if (categoryExists(categoryNew)) { + Observable.just(Unit).subscribeFirst({ view, _ -> view.onCategoryExistsError() }) + return + } + + if (categoryNew.contains("|")) { + Observable.just(Unit).subscribeFirst({ view, _ -> view.onCategoryInvalidNameError() }) + return + } + + val newCategories = categories.filterNot { it in categoryOld }.toMutableList() + newCategories += categoryNew + + var sources = preferences.sourcesTabSourcesInCategories().get().toList() + + sources = sources.map { it.split("|").toMutableList() } + .map { + if (it[1] == categoryOld) { + it[1] = categoryNew + } + it[0] + "|" + it[1] + } + + preferences.sourcesTabSourcesInCategories().set(sources.toSet()) + preferences.sourcesTabCategories().set(newCategories.sorted().toSet()) + } + + /** + * Returns true if a category with the given name already exists. + */ + private fun categoryExists(name: String): Boolean { + return categories.any { it.equals(name, true) } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/category/sources/SourceCategoryRenameDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/category/sources/SourceCategoryRenameDialog.kt new file mode 100644 index 000000000..0c4f03371 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/category/sources/SourceCategoryRenameDialog.kt @@ -0,0 +1,86 @@ +package eu.kanade.tachiyomi.ui.category.sources + +import android.app.Dialog +import android.os.Bundle +import com.afollestad.materialdialogs.MaterialDialog +import com.afollestad.materialdialogs.input.input +import com.bluelinelabs.conductor.Controller +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.ui.base.controller.DialogController + +/** + * Dialog to rename an existing category of the library. + */ +class SourceCategoryRenameDialog(bundle: Bundle? = null) : DialogController(bundle) + where T : Controller, T : SourceCategoryRenameDialog.Listener { + + private var category: String? = null + + /** + * Name of the new category. Value updated with each input from the user. + */ + private var currentName = "" + + constructor(target: T, category: String) : this() { + targetController = target + this.category = category + currentName = category + } + + /** + * Called when creating the dialog for this controller. + * + * @param savedViewState The saved state of this dialog. + * @return a new dialog instance. + */ + override fun onCreateDialog(savedViewState: Bundle?): Dialog { + return MaterialDialog(activity!!) + .title(R.string.action_rename_category) + .negativeButton(android.R.string.cancel) + .input( + hint = resources?.getString(R.string.name), + prefill = currentName + ) { _, input -> + currentName = input.toString() + } + .positiveButton(android.R.string.ok) { onPositive() } + } + + /** + * Called to save this Controller's state in the event that its host Activity is destroyed. + * + * @param outState The Bundle into which data should be saved + */ + override fun onSaveInstanceState(outState: Bundle) { + outState.putSerializable(CATEGORY_KEY, category) + super.onSaveInstanceState(outState) + } + + /** + * Restores data that was saved in the [onSaveInstanceState] method. + * + * @param savedInstanceState The bundle that has data to be restored + */ + override fun onRestoreInstanceState(savedInstanceState: Bundle) { + super.onRestoreInstanceState(savedInstanceState) + category = savedInstanceState.getSerializable(CATEGORY_KEY) as? String + } + + /** + * Called when the positive button of the dialog is clicked. + */ + private fun onPositive() { + val target = targetController as? Listener ?: return + val category = category ?: return + + target.renameCategory(category, currentName) + } + + interface Listener { + fun renameCategory(category: String, name: String) + } + + private companion object { + const val CATEGORY_KEY = "CategoryRenameDialog.category" + } +} 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 150b36a74..99ebe9aa0 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 @@ -4,8 +4,12 @@ import androidx.preference.PreferenceScreen import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.preference.PreferenceKeys as Keys import eu.kanade.tachiyomi.extension.ExtensionUpdateJob +import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction +import eu.kanade.tachiyomi.ui.category.sources.SourceCategoryController import eu.kanade.tachiyomi.util.preference.defaultValue import eu.kanade.tachiyomi.util.preference.onChange +import eu.kanade.tachiyomi.util.preference.onClick +import eu.kanade.tachiyomi.util.preference.preference import eu.kanade.tachiyomi.util.preference.preferenceCategory import eu.kanade.tachiyomi.util.preference.summaryRes import eu.kanade.tachiyomi.util.preference.switchPreference @@ -16,6 +20,21 @@ class SettingsBrowseController : SettingsController() { override fun setupPreferenceScreen(screen: PreferenceScreen) = with(screen) { titleRes = R.string.browse + preferenceCategory { + titleRes = R.string.label_sources + + preference { + titleRes = R.string.action_edit_categories + + val catCount = preferences.sourcesTabCategories().get().count() + summary = context.resources.getQuantityString(R.plurals.num_categories, catCount, catCount) + + onClick { + router.pushController(SourceCategoryController().withFadeTransaction()) + } + } + } + preferenceCategory { titleRes = R.string.latest diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/system/LocaleHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/util/system/LocaleHelper.kt index 5ae9383fa..5748999f7 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/system/LocaleHelper.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/system/LocaleHelper.kt @@ -48,6 +48,9 @@ object LocaleHelper { * Returns Display name of a string language code */ fun getSourceDisplayName(lang: String?, context: Context): String { + if (lang != null && lang.contains("custom|")) { + return lang.split("|")[1] + } return when (lang) { "" -> context.getString(R.string.other_source) SourcePresenter.LAST_USED_KEY -> context.getString(R.string.last_used_source) diff --git a/app/src/main/res/values/strings_extra.xml b/app/src/main/res/values/strings_extra.xml index c7999fe91..fed2c1900 100644 --- a/app/src/main/res/values/strings_extra.xml +++ b/app/src/main/res/values/strings_extra.xml @@ -123,6 +123,8 @@ 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 + No source categories available + Invalid category name See Recommendations