mirror of
https://github.com/tachiyomiorg/tachiyomi.git
synced 2024-12-23 21:21:51 +01:00
Extensions now in a bottom sheet on the browse tab
Boy I love bottom sheets guys
This commit is contained in:
parent
1f91ad0a07
commit
e68a1ae48d
@ -13,7 +13,7 @@ import com.bluelinelabs.conductor.ControllerChangeHandler
|
||||
import com.bluelinelabs.conductor.ControllerChangeType
|
||||
import com.bluelinelabs.conductor.RouterTransaction
|
||||
import com.bluelinelabs.conductor.changehandler.FadeChangeHandler
|
||||
import com.jakewharton.rxbinding.support.v7.widget.queryTextChangeEvents
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
||||
import eu.davidea.flexibleadapter.items.IFlexible
|
||||
import eu.kanade.tachiyomi.R
|
||||
@ -26,17 +26,20 @@ import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
|
||||
import eu.kanade.tachiyomi.ui.catalogue.browse.BrowseCatalogueController
|
||||
import eu.kanade.tachiyomi.ui.catalogue.global_search.CatalogueSearchController
|
||||
import eu.kanade.tachiyomi.ui.catalogue.latest.LatestUpdatesController
|
||||
import eu.kanade.tachiyomi.ui.extension.SettingsExtensionsController
|
||||
import eu.kanade.tachiyomi.ui.main.RootSearchInterface
|
||||
import eu.kanade.tachiyomi.ui.setting.SettingsSourcesController
|
||||
import eu.kanade.tachiyomi.util.view.RecyclerWindowInsetsListener
|
||||
import eu.kanade.tachiyomi.util.view.applyWindowInsetsForRootController
|
||||
import eu.kanade.tachiyomi.util.view.scrollViewWith
|
||||
import eu.kanade.tachiyomi.util.view.setOnQueryTextChangeListener
|
||||
import eu.kanade.tachiyomi.widget.preference.SourceLoginDialog
|
||||
import kotlinx.android.parcel.Parcelize
|
||||
import kotlinx.android.synthetic.main.catalogue_main_controller.*
|
||||
import kotlinx.android.synthetic.main.extensions_bottom_sheet.*
|
||||
import kotlinx.android.synthetic.main.main_activity.*
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import kotlin.math.max
|
||||
|
||||
/**
|
||||
* This controller shows and manages the different catalogues enabled by the user.
|
||||
@ -62,6 +65,13 @@ class CatalogueController : NucleusController<CataloguePresenter>(),
|
||||
*/
|
||||
private var adapter: CatalogueAdapter? = null
|
||||
|
||||
var extQuery = ""
|
||||
private set
|
||||
|
||||
var headerHeight = 0
|
||||
|
||||
var customTitle = ""
|
||||
|
||||
/**
|
||||
* Called when controller is initialized.
|
||||
*/
|
||||
@ -76,7 +86,9 @@ class CatalogueController : NucleusController<CataloguePresenter>(),
|
||||
* @return title.
|
||||
*/
|
||||
override fun getTitle(): String? {
|
||||
return applicationContext?.getString(R.string.label_catalogues)
|
||||
return if (ext_bottom_sheet.sheetBehavior?.state == BottomSheetBehavior.STATE_EXPANDED)
|
||||
applicationContext?.getString(R.string.label_extensions)
|
||||
else applicationContext?.getString(R.string.label_catalogues)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -114,11 +126,41 @@ class CatalogueController : NucleusController<CataloguePresenter>(),
|
||||
recycler.layoutManager = androidx.recyclerview.widget.LinearLayoutManager(view.context)
|
||||
recycler.adapter = adapter
|
||||
recycler.addItemDecoration(SourceDividerItemDecoration(view.context))
|
||||
recycler.setOnApplyWindowInsetsListener(RecyclerWindowInsetsListener)
|
||||
|
||||
scrollViewWith(recycler)
|
||||
val attrsArray = intArrayOf(android.R.attr.actionBarSize)
|
||||
val array = view.context.obtainStyledAttributes(attrsArray)
|
||||
val appBarHeight = array.getDimensionPixelSize(0, 0)
|
||||
array.recycle()
|
||||
scrollViewWith(recycler) {
|
||||
headerHeight = it.systemWindowInsetTop + appBarHeight
|
||||
}
|
||||
|
||||
requestPermissionsSafe(arrayOf(WRITE_EXTERNAL_STORAGE), 301)
|
||||
ext_bottom_sheet.onCreate(this)
|
||||
|
||||
ext_bottom_sheet.sheetBehavior?.addBottomSheetCallback(object : BottomSheetBehavior
|
||||
.BottomSheetCallback() {
|
||||
override fun onSlide(bottomSheet: View, progress: Float) {
|
||||
shadow2.alpha = (1 - max(0f, progress)) * 0.25f
|
||||
sheet_layout.alpha = 1 - progress
|
||||
activity?.appbar?.y = max(activity!!.appbar.y, -headerHeight * (1 - progress))
|
||||
}
|
||||
|
||||
override fun onStateChanged(p0: View, state: Int) {
|
||||
if (state == BottomSheetBehavior.STATE_EXPANDED) activity?.appbar?.y = 0f
|
||||
if (state == BottomSheetBehavior.STATE_EXPANDED ||
|
||||
state == BottomSheetBehavior.STATE_COLLAPSED)
|
||||
sheet_layout.alpha =
|
||||
if (state == BottomSheetBehavior.STATE_COLLAPSED) 1f else 0f
|
||||
|
||||
retainViewMode = if (state == BottomSheetBehavior.STATE_EXPANDED)
|
||||
RetainViewMode.RETAIN_DETACH else RetainViewMode.RELEASE_DETACH
|
||||
activity?.invalidateOptionsMenu()
|
||||
setTitle()
|
||||
sheet_layout.isClickable = state == BottomSheetBehavior.STATE_COLLAPSED
|
||||
sheet_layout.isFocusable = state == BottomSheetBehavior.STATE_COLLAPSED
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
override fun onDestroyView(view: View) {
|
||||
@ -129,6 +171,7 @@ class CatalogueController : NucleusController<CataloguePresenter>(),
|
||||
override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) {
|
||||
super.onChangeStarted(handler, type)
|
||||
if (!type.isPush && handler is SettingsSourcesFadeChangeHandler) {
|
||||
ext_bottom_sheet.updateExtTitle()
|
||||
presenter.updateSources()
|
||||
}
|
||||
}
|
||||
@ -192,20 +235,41 @@ class CatalogueController : NucleusController<CataloguePresenter>(),
|
||||
* @param inflater used to load the menu xml.
|
||||
*/
|
||||
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||
// Inflate menu
|
||||
inflater.inflate(R.menu.catalogue_main, menu)
|
||||
if (ext_bottom_sheet.sheetBehavior?.state == BottomSheetBehavior.STATE_EXPANDED) {
|
||||
// Inflate menu
|
||||
inflater.inflate(R.menu.extension_main, menu)
|
||||
|
||||
// Initialize search option.
|
||||
val searchItem = menu.findItem(R.id.action_search)
|
||||
val searchView = searchItem.actionView as SearchView
|
||||
// Initialize search option.
|
||||
val searchItem = menu.findItem(R.id.action_search)
|
||||
val searchView = searchItem.actionView as SearchView
|
||||
|
||||
// Change hint to show global search.
|
||||
searchView.queryHint = applicationContext?.getString(R.string.action_global_search_hint)
|
||||
// Change hint to show global search.
|
||||
searchView.queryHint = applicationContext?.getString(R.string.search_extensions)
|
||||
|
||||
// Create query listener which opens the global search view.
|
||||
searchView.queryTextChangeEvents()
|
||||
.filter { it.isSubmitted }
|
||||
.subscribeUntilDestroy { performGlobalSearch(it.queryText().toString()) }
|
||||
// Create query listener which opens the global search view.
|
||||
setOnQueryTextChangeListener(searchView) {
|
||||
extQuery = it ?: ""
|
||||
ext_bottom_sheet.drawExtensions()
|
||||
true
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Inflate menu
|
||||
inflater.inflate(R.menu.catalogue_main, menu)
|
||||
|
||||
// Initialize search option.
|
||||
val searchItem = menu.findItem(R.id.action_search)
|
||||
val searchView = searchItem.actionView as SearchView
|
||||
|
||||
// Change hint to show global search.
|
||||
searchView.queryHint = applicationContext?.getString(R.string.action_global_search_hint)
|
||||
|
||||
// Create query listener which opens the global search view.
|
||||
setOnQueryTextChangeListener(searchView, true) {
|
||||
if (!it.isNullOrBlank()) performGlobalSearch(it)
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun performGlobalSearch(query: String){
|
||||
@ -222,9 +286,18 @@ class CatalogueController : NucleusController<CataloguePresenter>(),
|
||||
when (item.itemId) {
|
||||
// Initialize option to open catalogue settings.
|
||||
R.id.action_filter -> {
|
||||
router.pushController((RouterTransaction.with(SettingsSourcesController()))
|
||||
.popChangeHandler(SettingsSourcesFadeChangeHandler())
|
||||
.pushChangeHandler(FadeChangeHandler()))
|
||||
val controller =
|
||||
if (ext_bottom_sheet.sheetBehavior?.state == BottomSheetBehavior.STATE_EXPANDED)
|
||||
SettingsExtensionsController()
|
||||
else SettingsSourcesController()
|
||||
router.pushController(
|
||||
(RouterTransaction.with(controller)).popChangeHandler(
|
||||
SettingsSourcesFadeChangeHandler()
|
||||
).pushChangeHandler(FadeChangeHandler())
|
||||
)
|
||||
}
|
||||
R.id.action_dismiss -> {
|
||||
ext_bottom_sheet.sheetBehavior?.state = BottomSheetBehavior.STATE_COLLAPSED
|
||||
}
|
||||
else -> return super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
@ -12,7 +12,7 @@ import rx.Subscription
|
||||
import rx.android.schedulers.AndroidSchedulers
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.util.*
|
||||
import java.util.TreeMap
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
|
@ -3,17 +3,19 @@ package eu.kanade.tachiyomi.ui.extension
|
||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
||||
import eu.davidea.flexibleadapter.items.IFlexible
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.ui.extension.ExtensionAdapter.OnButtonClickListener
|
||||
import eu.kanade.tachiyomi.util.system.getResourceColor
|
||||
|
||||
/**
|
||||
* Adapter that holds the catalogue cards.
|
||||
*
|
||||
* @param controller instance of [ExtensionController].
|
||||
* @param listener instance of [OnButtonClickListener].
|
||||
*/
|
||||
class ExtensionAdapter(val controller: ExtensionController) :
|
||||
FlexibleAdapter<IFlexible<*>>(null, controller, true) {
|
||||
class ExtensionAdapter(val listener: OnButtonClickListener) :
|
||||
FlexibleAdapter<IFlexible<*>>(null, listener, true) {
|
||||
|
||||
val cardBackground = controller.activity!!.getResourceColor(R.attr.background_card)
|
||||
val cardBackground = (listener as ExtensionBottomSheet).context.getResourceColor(R.attr
|
||||
.background_card)
|
||||
|
||||
init {
|
||||
setDisplayHeadersAtStartUp(true)
|
||||
@ -22,7 +24,7 @@ class ExtensionAdapter(val controller: ExtensionController) :
|
||||
/**
|
||||
* Listener for browse item clicks.
|
||||
*/
|
||||
val buttonClickListener: ExtensionAdapter.OnButtonClickListener = controller
|
||||
val buttonClickListener: ExtensionAdapter.OnButtonClickListener = listener
|
||||
|
||||
interface OnButtonClickListener {
|
||||
fun onButtonClick(position: Int)
|
||||
|
@ -0,0 +1,153 @@
|
||||
package eu.kanade.tachiyomi.ui.extension
|
||||
|
||||
import android.app.Application
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
import eu.kanade.tachiyomi.extension.ExtensionManager
|
||||
import eu.kanade.tachiyomi.extension.model.Extension
|
||||
import eu.kanade.tachiyomi.extension.model.InstallStep
|
||||
import eu.kanade.tachiyomi.util.system.LocaleHelper
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import rx.Observable
|
||||
import rx.Subscription
|
||||
import rx.android.schedulers.AndroidSchedulers
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
/**
|
||||
* Presenter of [ExtensionController].
|
||||
*/
|
||||
open class ExtensionBottomPresenter(
|
||||
private val bottomSheet: ExtensionBottomSheet,
|
||||
private val extensionManager: ExtensionManager = Injekt.get(),
|
||||
private val preferences: PreferencesHelper = Injekt.get()
|
||||
) : CoroutineScope {
|
||||
|
||||
override var coroutineContext: CoroutineContext = Job() + Dispatchers.Default
|
||||
|
||||
private var extensions = emptyList<ExtensionItem>()
|
||||
|
||||
private var currentDownloads = hashMapOf<String, InstallStep>()
|
||||
|
||||
fun onCreate() {
|
||||
extensionManager.findAvailableExtensions()
|
||||
bindToExtensionsObservable()
|
||||
}
|
||||
|
||||
private fun bindToExtensionsObservable(): Subscription {
|
||||
val installedObservable = extensionManager.getInstalledExtensionsObservable()
|
||||
val untrustedObservable = extensionManager.getUntrustedExtensionsObservable()
|
||||
val availableObservable = extensionManager.getAvailableExtensionsObservable()
|
||||
.startWith(emptyList<Extension.Available>())
|
||||
|
||||
return Observable.combineLatest(installedObservable, untrustedObservable, availableObservable)
|
||||
{ installed, untrusted, available -> Triple(installed, untrusted, available) }
|
||||
.debounce(100, TimeUnit.MILLISECONDS)
|
||||
.map(::toItems)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe {
|
||||
bottomSheet.setExtensions(extensions)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun toItems(tuple: ExtensionTuple): List<ExtensionItem> {
|
||||
val context = Injekt.get<Application>()
|
||||
val activeLangs = preferences.enabledLanguages().getOrDefault()
|
||||
|
||||
val (installed, untrusted, available) = tuple
|
||||
|
||||
val items = mutableListOf<ExtensionItem>()
|
||||
|
||||
val installedSorted = installed.sortedWith(compareBy({ !it.hasUpdate }, { !it.isObsolete }, { it.pkgName }))
|
||||
val untrustedSorted = untrusted.sortedBy { it.pkgName }
|
||||
val availableSorted = available
|
||||
// Filter out already installed extensions and disabled languages
|
||||
.filter { avail -> installed.none { it.pkgName == avail.pkgName }
|
||||
&& untrusted.none { it.pkgName == avail.pkgName }
|
||||
&& (avail.lang in activeLangs || avail.lang == "all")}
|
||||
.sortedBy { it.pkgName }
|
||||
|
||||
if (installedSorted.isNotEmpty() || untrustedSorted.isNotEmpty()) {
|
||||
val header = ExtensionGroupItem(context.getString(R.string.ext_installed), installedSorted.size + untrustedSorted.size)
|
||||
items += installedSorted.map { extension ->
|
||||
ExtensionItem(extension, header, currentDownloads[extension.pkgName])
|
||||
}
|
||||
items += untrustedSorted.map { extension ->
|
||||
ExtensionItem(extension, header)
|
||||
}
|
||||
}
|
||||
if (availableSorted.isNotEmpty()) {
|
||||
val availableGroupedByLang = availableSorted
|
||||
.groupBy { LocaleHelper.getDisplayName(it.lang, context) }
|
||||
.toSortedMap()
|
||||
|
||||
availableGroupedByLang
|
||||
.forEach {
|
||||
val header = ExtensionGroupItem(it.key, it.value.size)
|
||||
items += it.value.map { extension ->
|
||||
ExtensionItem(extension, header, currentDownloads[extension.pkgName])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.extensions = items
|
||||
return items
|
||||
}
|
||||
|
||||
fun getExtensionUpdateCount():Int = preferences.extensionUpdatesCount().getOrDefault()
|
||||
fun getAutoCheckPref() = preferences.automaticExtUpdates()
|
||||
|
||||
@Synchronized
|
||||
private fun updateInstallStep(extension: Extension, state: InstallStep): ExtensionItem? {
|
||||
val extensions = extensions.toMutableList()
|
||||
val position = extensions.indexOfFirst { it.extension.pkgName == extension.pkgName }
|
||||
|
||||
return if (position != -1) {
|
||||
val item = extensions[position].copy(installStep = state)
|
||||
extensions[position] = item
|
||||
|
||||
this.extensions = extensions
|
||||
item
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun installExtension(extension: Extension.Available) {
|
||||
extensionManager.installExtension(extension).subscribeToInstallUpdate(extension)
|
||||
}
|
||||
|
||||
fun updateExtension(extension: Extension.Installed) {
|
||||
extensionManager.updateExtension(extension).subscribeToInstallUpdate(extension)
|
||||
}
|
||||
|
||||
private fun Observable<InstallStep>.subscribeToInstallUpdate(extension: Extension) {
|
||||
this.doOnNext { currentDownloads[extension.pkgName] = it }
|
||||
.doOnUnsubscribe { currentDownloads.remove(extension.pkgName) }
|
||||
.map { state -> updateInstallStep(extension, state) }
|
||||
.subscribe { item ->
|
||||
if (item != null) {
|
||||
bottomSheet.downloadUpdate(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun uninstallExtension(pkgName: String) {
|
||||
extensionManager.uninstallExtension(pkgName)
|
||||
}
|
||||
|
||||
fun findAvailableExtensions() {
|
||||
extensionManager.findAvailableExtensions()
|
||||
}
|
||||
|
||||
fun trustSignature(signatureHash: String) {
|
||||
extensionManager.trustSignature(signatureHash)
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,224 @@
|
||||
package eu.kanade.tachiyomi.ui.extension
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.widget.CheckBox
|
||||
import android.widget.CompoundButton
|
||||
import android.widget.LinearLayout
|
||||
import com.f2prateek.rx.preferences.Preference
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
||||
import eu.davidea.flexibleadapter.items.AbstractHeaderItem
|
||||
import eu.davidea.flexibleadapter.items.IFlexible
|
||||
import eu.davidea.viewholders.FlexibleViewHolder
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
import eu.kanade.tachiyomi.extension.model.Extension
|
||||
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
|
||||
import eu.kanade.tachiyomi.ui.catalogue.CatalogueController
|
||||
import eu.kanade.tachiyomi.util.view.RecyclerWindowInsetsListener
|
||||
import eu.kanade.tachiyomi.util.view.doOnApplyWindowInsets
|
||||
import eu.kanade.tachiyomi.util.view.updateLayoutParams
|
||||
import kotlinx.android.synthetic.main.extensions_bottom_sheet.view.*
|
||||
|
||||
class ExtensionBottomSheet @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null)
|
||||
: LinearLayout(context, attrs),
|
||||
ExtensionAdapter.OnButtonClickListener,
|
||||
FlexibleAdapter.OnItemClickListener,
|
||||
FlexibleAdapter.OnItemLongClickListener,
|
||||
ExtensionTrustDialog.Listener {
|
||||
|
||||
var sheetBehavior: BottomSheetBehavior<*>? = null
|
||||
lateinit var autoCheckItem:AutoCheckItem
|
||||
|
||||
/**
|
||||
* Adapter containing the list of manga from the catalogue.
|
||||
*/
|
||||
private var adapter: FlexibleAdapter<IFlexible<*>>? = null
|
||||
|
||||
val presenter = ExtensionBottomPresenter(this)
|
||||
|
||||
private var extensions: List<ExtensionItem> = emptyList()
|
||||
|
||||
lateinit var controller: CatalogueController
|
||||
|
||||
fun onCreate(controller: CatalogueController) {
|
||||
// Initialize adapter, scroll listener and recycler views
|
||||
autoCheckItem = AutoCheckItem(presenter.getAutoCheckPref())
|
||||
adapter = ExtensionAdapter(this)
|
||||
sheetBehavior = BottomSheetBehavior.from(this)
|
||||
// Create recycler and set adapter.
|
||||
ext_recycler.layoutManager = androidx.recyclerview.widget.LinearLayoutManager(context)
|
||||
ext_recycler.adapter = adapter
|
||||
ext_recycler.addItemDecoration(ExtensionDividerItemDecoration(context))
|
||||
ext_recycler.setOnApplyWindowInsetsListener(RecyclerWindowInsetsListener)
|
||||
// scrollViewWith(ext_recycler, true, ext_swipe_refresh)
|
||||
this.controller = controller
|
||||
//ext_swipe_refresh.refreshes().subscribeUntilDestroy {
|
||||
// presenter.findAvailableExtensions()
|
||||
presenter.onCreate()
|
||||
updateExtTitle()
|
||||
|
||||
val attrsArray = intArrayOf(android.R.attr.actionBarSize)
|
||||
val array = context.obtainStyledAttributes(attrsArray)
|
||||
val headerHeight = array.getDimensionPixelSize(0, 0)
|
||||
array.recycle()
|
||||
ext_recycler.doOnApplyWindowInsets { _, windowInsets, _ ->
|
||||
ext_recycler.updateLayoutParams<LayoutParams> {
|
||||
topMargin = windowInsets.systemWindowInsetTop + headerHeight -
|
||||
(sheet_layout.height)
|
||||
}
|
||||
}
|
||||
sheet_layout.setOnClickListener {
|
||||
if (sheetBehavior?.state != BottomSheetBehavior.STATE_EXPANDED) {
|
||||
sheetBehavior?.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
} else {
|
||||
sheetBehavior?.state = BottomSheetBehavior.STATE_COLLAPSED
|
||||
}
|
||||
}
|
||||
presenter.getExtensionUpdateCount()
|
||||
}
|
||||
|
||||
fun updateExtTitle() {
|
||||
val extCount = presenter.getExtensionUpdateCount()
|
||||
title_text.text = if (extCount == 0) context.getString(R.string.label_extensions)
|
||||
else resources.getQuantityString(R.plurals.extensions_updates_available, extCount,
|
||||
extCount)
|
||||
}
|
||||
|
||||
override fun onButtonClick(position: Int) {
|
||||
val extension = (adapter?.getItem(position) as? ExtensionItem)?.extension ?: return
|
||||
when (extension) {
|
||||
is Extension.Installed -> {
|
||||
if (!extension.hasUpdate) {
|
||||
openDetails(extension)
|
||||
} else {
|
||||
presenter.updateExtension(extension)
|
||||
}
|
||||
}
|
||||
is Extension.Available -> {
|
||||
presenter.installExtension(extension)
|
||||
}
|
||||
is Extension.Untrusted -> {
|
||||
openTrustDialog(extension)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onItemClick(view: View?, position: Int): Boolean {
|
||||
val extension = (adapter?.getItem(position) as? ExtensionItem)?.extension ?: return false
|
||||
if (extension is Extension.Installed) {
|
||||
openDetails(extension)
|
||||
} else if (extension is Extension.Untrusted) {
|
||||
openTrustDialog(extension)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onItemLongClick(position: Int) {
|
||||
val extension = (adapter?.getItem(position) as? ExtensionItem)?.extension ?: return
|
||||
if (extension is Extension.Installed || extension is Extension.Untrusted) {
|
||||
uninstallExtension(extension.pkgName)
|
||||
}
|
||||
}
|
||||
|
||||
private fun openDetails(extension: Extension.Installed) {
|
||||
val controller = ExtensionDetailsController(extension.pkgName)
|
||||
this.controller.router.pushController(controller.withFadeTransaction())
|
||||
}
|
||||
|
||||
private fun openTrustDialog(extension: Extension.Untrusted) {
|
||||
ExtensionTrustDialog(this, extension.signatureHash, extension.pkgName)
|
||||
.showDialog(controller.router)
|
||||
}
|
||||
|
||||
fun setExtensions(extensions: List<ExtensionItem>) {
|
||||
//ext_swipe_refresh?.isRefreshing = false
|
||||
this.extensions = extensions
|
||||
controller.presenter.updateSources()
|
||||
drawExtensions()
|
||||
}
|
||||
|
||||
fun drawExtensions() {
|
||||
if (!controller.extQuery.isBlank()) {
|
||||
adapter?.updateDataSet(
|
||||
extensions.filter {
|
||||
it.extension.name.contains(controller.extQuery, ignoreCase = true)
|
||||
})
|
||||
} else {
|
||||
adapter?.updateDataSet(extensions)
|
||||
}
|
||||
updateExtTitle()
|
||||
setLastUsedSource()
|
||||
}
|
||||
|
||||
/**
|
||||
* Called to set the last used catalogue at the top of the view.
|
||||
*/
|
||||
private fun setLastUsedSource() {
|
||||
adapter?.removeAllScrollableHeaders()
|
||||
adapter?.addScrollableHeader(autoCheckItem)
|
||||
}
|
||||
|
||||
fun downloadUpdate(item: ExtensionItem) {
|
||||
adapter?.updateItem(item, item.installStep)
|
||||
}
|
||||
|
||||
override fun trustSignature(signatureHash: String) {
|
||||
presenter.trustSignature(signatureHash)
|
||||
}
|
||||
|
||||
override fun uninstallExtension(pkgName: String) {
|
||||
presenter.uninstallExtension(pkgName)
|
||||
}
|
||||
}
|
||||
|
||||
class AutoCheckItem(private val autoCheck: Preference<Boolean>) : AbstractHeaderItem<AutoCheckItem.AutoCheckHolder>() {
|
||||
|
||||
override fun getLayoutRes(): Int {
|
||||
return R.layout.auto_ext_checkbox
|
||||
}
|
||||
|
||||
override fun createViewHolder(
|
||||
view: View, adapter: FlexibleAdapter<IFlexible<*>>
|
||||
): AutoCheckHolder {
|
||||
return AutoCheckHolder(view, adapter, autoCheck)
|
||||
}
|
||||
|
||||
override fun bindViewHolder(
|
||||
adapter: FlexibleAdapter<IFlexible<*>>,
|
||||
holder: AutoCheckHolder,
|
||||
position: Int,
|
||||
payloads: MutableList<Any?>?
|
||||
) {
|
||||
//holder.bind(autoCheck.getOrDefault())
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
return (this === other)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return -1
|
||||
}
|
||||
|
||||
class AutoCheckHolder(val view: View, private val adapter: FlexibleAdapter<IFlexible<*>>,
|
||||
autoCheck: Preference<Boolean>) :
|
||||
FlexibleViewHolder(view, adapter, true) {
|
||||
private val autoCheckbox: CheckBox = view.findViewById(R.id.auto_checkbox)
|
||||
|
||||
init {
|
||||
autoCheckbox.bindToPreference(autoCheck)
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds a checkbox or switch view with a boolean preference.
|
||||
*/
|
||||
private fun CompoundButton.bindToPreference(pref: Preference<Boolean>) {
|
||||
isChecked = pref.getOrDefault()
|
||||
setOnCheckedChangeListener { _, isChecked -> pref.set(isChecked) }
|
||||
}
|
||||
}
|
||||
}
|
@ -17,7 +17,7 @@ import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
private typealias ExtensionTuple
|
||||
typealias ExtensionTuple
|
||||
= Triple<List<Extension.Installed>, List<Extension.Untrusted>, List<Extension.Available>>
|
||||
|
||||
/**
|
||||
|
@ -3,18 +3,18 @@ package eu.kanade.tachiyomi.ui.extension
|
||||
import android.app.Dialog
|
||||
import android.os.Bundle
|
||||
import com.afollestad.materialdialogs.MaterialDialog
|
||||
import com.bluelinelabs.conductor.Controller
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.ui.base.controller.DialogController
|
||||
|
||||
class ExtensionTrustDialog<T>(bundle: Bundle? = null) : DialogController(bundle)
|
||||
where T : Controller, T: ExtensionTrustDialog.Listener {
|
||||
where T: ExtensionTrustDialog.Listener {
|
||||
|
||||
lateinit var listener: Listener
|
||||
constructor(target: T, signatureHash: String, pkgName: String) : this(Bundle().apply {
|
||||
putString(SIGNATURE_KEY, signatureHash)
|
||||
putString(PKGNAME_KEY, pkgName)
|
||||
}) {
|
||||
targetController = target
|
||||
listener = target
|
||||
}
|
||||
|
||||
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
|
||||
@ -22,10 +22,10 @@ class ExtensionTrustDialog<T>(bundle: Bundle? = null) : DialogController(bundle)
|
||||
.title(R.string.untrusted_extension)
|
||||
.message(R.string.untrusted_extension_message)
|
||||
.positiveButton(R.string.ext_trust) {
|
||||
(targetController as? Listener)?.trustSignature(args.getString(SIGNATURE_KEY)!!)
|
||||
listener.trustSignature(args.getString(SIGNATURE_KEY)!!)
|
||||
}
|
||||
.negativeButton(R.string.ext_uninstall) {
|
||||
(targetController as? Listener)?.uninstallExtension(args.getString(PKGNAME_KEY)!!)
|
||||
listener.uninstallExtension(args.getString(PKGNAME_KEY)!!)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -8,7 +8,6 @@ import com.bluelinelabs.conductor.Controller
|
||||
import eu.kanade.tachiyomi.BuildConfig
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
|
||||
import eu.kanade.tachiyomi.ui.extension.ExtensionController
|
||||
import eu.kanade.tachiyomi.ui.migration.MigrationController
|
||||
import eu.kanade.tachiyomi.util.system.getResourceColor
|
||||
import eu.kanade.tachiyomi.util.system.openInBrowser
|
||||
@ -24,13 +23,6 @@ class SettingsMainController : SettingsController() {
|
||||
|
||||
val tintColor = context.getResourceColor(R.attr.colorAccent)
|
||||
|
||||
extensionPreference {
|
||||
iconRes = R.drawable.ic_extension_black_24dp
|
||||
iconTint = tintColor
|
||||
titleRes = R.string.label_extensions
|
||||
onClick { navigateTo(ExtensionController()) }
|
||||
}
|
||||
|
||||
preference {
|
||||
iconRes = R.drawable.ic_tune_white_24dp
|
||||
iconTint = tintColor
|
||||
|
@ -294,10 +294,12 @@ data class ViewPaddingState(
|
||||
)
|
||||
|
||||
|
||||
fun Controller.setOnQueryTextChangeListener(searchView: SearchView, f: (text: String?) -> Boolean) {
|
||||
fun Controller.setOnQueryTextChangeListener(searchView: SearchView, onlyOnSubmit:Boolean = false,
|
||||
f: (text: String?) -> Boolean) {
|
||||
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
|
||||
override fun onQueryTextChange(newText: String?): Boolean {
|
||||
if (router.backstack.lastOrNull()?.controller() == this@setOnQueryTextChangeListener) {
|
||||
if (!onlyOnSubmit && router.backstack.lastOrNull()?.controller() ==
|
||||
this@setOnQueryTextChangeListener) {
|
||||
return f(newText)
|
||||
}
|
||||
return false
|
||||
@ -324,10 +326,10 @@ fun Controller.scrollViewWith(recycler: RecyclerView,
|
||||
val headerHeight = insets.systemWindowInsetTop + array.getDimensionPixelSize(0, 0)
|
||||
view.updatePaddingRelative(
|
||||
top = headerHeight,
|
||||
bottom = if (padBottom) insets.systemWindowInsetBottom else 0
|
||||
bottom = if (padBottom) insets.systemWindowInsetBottom else view.paddingBottom
|
||||
)
|
||||
swipeRefreshLayout?.setProgressViewOffset(false, headerHeight + (-60).dpToPx,
|
||||
headerHeight + 10.dpToPx)
|
||||
headerHeight)
|
||||
statusBarHeight = insets.systemWindowInsetTop
|
||||
array.recycle()
|
||||
f?.invoke(insets)
|
||||
|
@ -28,7 +28,7 @@ class ExtensionPreference @JvmOverloads constructor(context: Context, attrs: Att
|
||||
val updates = Injekt.get<PreferencesHelper>().extensionUpdatesCount().getOrDefault()
|
||||
if (updates > 0) {
|
||||
extUpdateText.text = context.resources.getQuantityString(R.plurals
|
||||
.extensions_updates_available, updates, updates)
|
||||
.updates_available, updates, updates)
|
||||
extUpdateText.visible()
|
||||
}
|
||||
else {
|
||||
|
6
app/src/main/res/color/btn_bg_primary_selector.xml
Normal file
6
app/src/main/res/color/btn_bg_primary_selector.xml
Normal file
@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_enabled="false"
|
||||
android:alpha="0.12" android:color="?attr/colorOnSurface" />
|
||||
<item android:color="?colorPrimary" />
|
||||
</selector>
|
@ -2,6 +2,7 @@
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:tint="?actionBarTintColor"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
|
9
app/src/main/res/layout/auto_ext_checkbox.xml
Normal file
9
app/src/main/res/layout/auto_ext_checkbox.xml
Normal file
@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<com.google.android.material.checkbox.MaterialCheckBox
|
||||
android:id="@+id/auto_checkbox"
|
||||
android:layout_marginStart="8dp"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/action_auto_check_extensions">
|
||||
|
||||
</com.google.android.material.checkbox.MaterialCheckBox>
|
@ -1,15 +1,44 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
android:layout_height="match_parent"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recycler"
|
||||
<FrameLayout
|
||||
android:id="@+id/frame_layout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:clipToPadding="false"
|
||||
tools:listitem="@layout/catalogue_main_controller_card" />
|
||||
android:layout_height="match_parent"
|
||||
android:background="?android:attr/colorBackground">
|
||||
|
||||
</FrameLayout>
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recycler"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:clipToPadding="false"
|
||||
android:layout_marginBottom="30dp"
|
||||
android:paddingBottom="20dp"
|
||||
tools:listitem="@layout/catalogue_main_controller_card" />
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
<View
|
||||
android:id="@+id/shadow"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="24dp"
|
||||
android:alpha="0.5"
|
||||
android:background="@drawable/shape_gradient_top_shadow"
|
||||
android:paddingBottom="10dp"
|
||||
app:layout_anchorGravity="top"
|
||||
app:layout_anchor="@id/ext_bottom_sheet" />
|
||||
<!-- Adding bottom sheet after main content -->
|
||||
<include layout="@layout/extensions_bottom_sheet"/>
|
||||
|
||||
<View
|
||||
android:id="@+id/shadow2"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="8dp"
|
||||
android:layout_gravity="bottom"
|
||||
android:alpha="0.25"
|
||||
android:background="@drawable/shape_gradient_top_shadow" />
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
|
@ -0,0 +1,43 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout
|
||||
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">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:id="@+id/card"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="@dimen/material_component_lists_two_line_height"
|
||||
android:background="?attr/selectable_list_drawable">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/title"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:layout_marginStart="12dp"
|
||||
android:paddingStart="0dp"
|
||||
android:paddingEnd="8dp"
|
||||
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@id/source_browse"
|
||||
tools:text="Source title" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/source_browse"
|
||||
style="@style/Theme.Widget.Button.Borderless.Small"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/update_check_look_for_updates"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"/>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
</FrameLayout>
|
@ -7,10 +7,10 @@
|
||||
android:id="@+id/ext_swipe_refresh">
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/ext_recycler"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:clipToPadding="false"
|
||||
tools:listitem="@layout/extension_card_header"/>
|
||||
android:id="@+id/ext_recycler"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:clipToPadding="false"
|
||||
tools:listitem="@layout/extension_card_header"/>
|
||||
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||
|
64
app/src/main/res/layout/extensions_bottom_sheet.xml
Normal file
64
app/src/main/res/layout/extensions_bottom_sheet.xml
Normal file
@ -0,0 +1,64 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<eu.kanade.tachiyomi.ui.extension.ExtensionBottomSheet 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:id="@+id/ext_bottom_sheet"
|
||||
style="@style/BottomSheetDialogTheme"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@drawable/bg_bottom_sheet_dialog_fragment"
|
||||
android:backgroundTint="?android:attr/colorBackground"
|
||||
android:orientation="vertical"
|
||||
app:behavior_peekHeight="48sp"
|
||||
app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/sheet_layout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:background="@drawable/bg_bottom_sheet_dialog_fragment"
|
||||
android:backgroundTint="?android:attr/colorPrimary"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/pill"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:layout_marginTop="5dp"
|
||||
android:alpha="0.25"
|
||||
android:contentDescription="@string/drag_handle"
|
||||
android:src="@drawable/draggable_pill"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<com.google.android.material.textview.MaterialTextView
|
||||
android:id="@+id/title_text"
|
||||
style="@style/TextAppearance.AppCompat.Widget.ActionBar.Title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:layout_marginBottom="10dp"
|
||||
android:layout_marginTop="6dp"
|
||||
android:ellipsize="end"
|
||||
android:gravity="center"
|
||||
android:maxLines="1"
|
||||
android:textAlignment="center"
|
||||
android:textColor="?actionBarTintColor"
|
||||
android:textSize="18sp"
|
||||
tools:text="Extensions" />
|
||||
</LinearLayout>
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/ext_recycler"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="?android:attr/colorBackground"
|
||||
android:clipToPadding="false"
|
||||
tools:listitem="@layout/extension_card_header" />
|
||||
</eu.kanade.tachiyomi.ui.extension.ExtensionBottomSheet>
|
@ -4,6 +4,7 @@
|
||||
<item
|
||||
android:id="@+id/action_search"
|
||||
android:icon="@drawable/ic_search_white_24dp"
|
||||
android:visible="false"
|
||||
android:title="@string/action_search"
|
||||
app:actionViewClass="androidx.appcompat.widget.SearchView"
|
||||
app:showAsAction="collapseActionView|ifRoom" />
|
||||
@ -12,13 +13,20 @@
|
||||
android:id="@+id/action_filter"
|
||||
android:title="@string/action_filter"
|
||||
android:icon="@drawable/ic_filter_list_white_24dp"
|
||||
app:showAsAction="ifRoom"/>
|
||||
app:showAsAction="always"/>
|
||||
|
||||
<item
|
||||
android:id="@+id/action_auto_check"
|
||||
android:title="@string/action_auto_check_extensions"
|
||||
android:icon="@drawable/ic_filter_list_white_24dp"
|
||||
android:icon="@drawable/ic_sync_black_24dp"
|
||||
android:visible="false"
|
||||
android:checkable="true"
|
||||
app:showAsAction="never"/>
|
||||
|
||||
<item
|
||||
android:id="@+id/action_dismiss"
|
||||
android:title="@string/action_dismiss"
|
||||
android:icon="@drawable/ic_close_white_24dp"
|
||||
app:showAsAction="always"/>
|
||||
|
||||
</menu>
|
||||
|
@ -27,10 +27,14 @@
|
||||
<string name="label_help">Help</string>
|
||||
<string name="unlock">Unlock</string>
|
||||
<string name="unlock_library">Unlock to access Library</string>
|
||||
<plurals name="extensions_updates_available">
|
||||
<plurals name="updates_available">
|
||||
<item quantity="one">Update available</item>
|
||||
<item quantity="other">%d updates available</item>
|
||||
</plurals>
|
||||
<plurals name="extensions_updates_available">
|
||||
<item quantity="one">Extension update available</item>
|
||||
<item quantity="other">%d extension updates available</item>
|
||||
</plurals>
|
||||
<plurals name="downloads_pending">
|
||||
<item quantity="one">1 in queue</item>
|
||||
<item quantity="other">%d in queue</item>
|
||||
@ -139,6 +143,7 @@
|
||||
<string name="action_sort_by">Sort category by…</string>
|
||||
<string name="action_switch">Switch</string>
|
||||
<string name="action_bug_report">Report a Bug</string>
|
||||
<string name="action_dismiss">Dismiss</string>
|
||||
|
||||
<!-- Operations -->
|
||||
<string name="loading">Loading…</string>
|
||||
@ -474,6 +479,7 @@
|
||||
<string name="latest">Latest</string>
|
||||
<string name="browse">Browse</string>
|
||||
<string name="in_library">In Library</string>
|
||||
<string name="search_extensions">Search extensions…</string>
|
||||
|
||||
<!-- Manga activity -->
|
||||
<string name="manga_not_in_db">This manga has been removed from the database.</string>
|
||||
|
Loading…
Reference in New Issue
Block a user