Merge branch 'devel' into feature/kitsu
# Conflicts: # app/src/main/res/values/strings.xmlfeature/kitsu
commit
cb64740349
@ -0,0 +1,11 @@
|
||||
package org.koitharu.kotatsu.core.db.migrations
|
||||
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
|
||||
class Migration15To16 : Migration(15, 16) {
|
||||
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
database.execSQL("ALTER TABLE preferences ADD COLUMN `cf_invert` INTEGER NOT NULL DEFAULT 0")
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,103 @@
|
||||
package org.koitharu.kotatsu.core.network
|
||||
|
||||
import android.util.Log
|
||||
import androidx.collection.ArraySet
|
||||
import coil.intercept.Interceptor
|
||||
import coil.request.ErrorResult
|
||||
import coil.request.ImageResult
|
||||
import coil.request.SuccessResult
|
||||
import coil.size.Dimension
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.util.ext.ensureSuccess
|
||||
import org.koitharu.kotatsu.core.util.ext.isHttpOrHttps
|
||||
import org.koitharu.kotatsu.parsers.util.await
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import java.util.Collections
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class ImageProxyInterceptor @Inject constructor(
|
||||
private val settings: AppSettings,
|
||||
) : Interceptor {
|
||||
|
||||
private val blacklist = Collections.synchronizedSet(ArraySet<String>())
|
||||
|
||||
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
|
||||
val request = chain.request
|
||||
if (!settings.isImagesProxyEnabled) {
|
||||
return chain.proceed(request)
|
||||
}
|
||||
val url: HttpUrl? = when (val data = request.data) {
|
||||
is HttpUrl -> data
|
||||
is String -> data.toHttpUrlOrNull()
|
||||
else -> null
|
||||
}
|
||||
if (url == null || !url.isHttpOrHttps || url.host in blacklist) {
|
||||
return chain.proceed(request)
|
||||
}
|
||||
val newUrl = HttpUrl.Builder()
|
||||
.scheme("https")
|
||||
.host("wsrv.nl")
|
||||
.addQueryParameter("url", url.toString())
|
||||
.addQueryParameter("fit", "outside")
|
||||
.addQueryParameter("we", null)
|
||||
val size = request.sizeResolver.size()
|
||||
(size.height as? Dimension.Pixels)?.let { newUrl.addQueryParameter("h", it.toString()) }
|
||||
(size.width as? Dimension.Pixels)?.let { newUrl.addQueryParameter("w", it.toString()) }
|
||||
|
||||
val newRequest = request.newBuilder()
|
||||
.data(newUrl.build())
|
||||
.build()
|
||||
val result = chain.proceed(newRequest)
|
||||
return if (result is SuccessResult) {
|
||||
result
|
||||
} else {
|
||||
logDebug((result as? ErrorResult)?.throwable)
|
||||
chain.proceed(request).also {
|
||||
if (it is SuccessResult) {
|
||||
blacklist.add(url.host)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun interceptPageRequest(request: Request, okHttp: OkHttpClient): Response {
|
||||
if (!settings.isImagesProxyEnabled) {
|
||||
return okHttp.newCall(request).await()
|
||||
}
|
||||
val sourceUrl = request.url
|
||||
val targetUrl = HttpUrl.Builder()
|
||||
.scheme("https")
|
||||
.host("wsrv.nl")
|
||||
.addQueryParameter("url", sourceUrl.toString())
|
||||
.addQueryParameter("we", null)
|
||||
val newRequest = request.newBuilder()
|
||||
.url(targetUrl.build())
|
||||
.build()
|
||||
return runCatchingCancellable {
|
||||
okHttp.doCall(newRequest)
|
||||
}.recover {
|
||||
logDebug(it)
|
||||
okHttp.doCall(request).also {
|
||||
blacklist.add(sourceUrl.host)
|
||||
}
|
||||
}.getOrThrow()
|
||||
}
|
||||
|
||||
private suspend fun OkHttpClient.doCall(request: Request): Response {
|
||||
return newCall(request).await().ensureSuccess()
|
||||
}
|
||||
|
||||
private fun logDebug(e: Throwable?) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.w("ImageProxy", e.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
package org.koitharu.kotatsu.core.network
|
||||
|
||||
import okhttp3.Authenticator
|
||||
import okhttp3.Credentials
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.Route
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import java.net.PasswordAuthentication
|
||||
import java.net.Proxy
|
||||
|
||||
class ProxyAuthenticator(
|
||||
private val settings: AppSettings,
|
||||
) : Authenticator, java.net.Authenticator() {
|
||||
|
||||
init {
|
||||
setDefault(this)
|
||||
}
|
||||
|
||||
override fun authenticate(route: Route?, response: Response): Request? {
|
||||
if (!isProxyEnabled()) {
|
||||
return null
|
||||
}
|
||||
if (response.request.header(CommonHeaders.PROXY_AUTHORIZATION) != null) {
|
||||
return null
|
||||
}
|
||||
val login = settings.proxyLogin ?: return null
|
||||
val password = settings.proxyPassword ?: return null
|
||||
val credential = Credentials.basic(login, password)
|
||||
return response.request.newBuilder()
|
||||
.header(CommonHeaders.PROXY_AUTHORIZATION, credential)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun getPasswordAuthentication(): PasswordAuthentication? {
|
||||
if (!isProxyEnabled()) {
|
||||
return null
|
||||
}
|
||||
val login = settings.proxyLogin ?: return null
|
||||
val password = settings.proxyPassword ?: return null
|
||||
return PasswordAuthentication(login, password.toCharArray())
|
||||
}
|
||||
|
||||
private fun isProxyEnabled() = settings.proxyType != Proxy.Type.DIRECT
|
||||
}
|
||||
@ -1,30 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.ui.list
|
||||
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
|
||||
class ScrollListenerInvalidationObserver(
|
||||
private val recyclerView: RecyclerView,
|
||||
private val scrollListener: RecyclerView.OnScrollListener,
|
||||
) : RecyclerView.AdapterDataObserver() {
|
||||
|
||||
override fun onChanged() {
|
||||
super.onChanged()
|
||||
invalidateScroll()
|
||||
}
|
||||
|
||||
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
|
||||
super.onItemRangeInserted(positionStart, itemCount)
|
||||
invalidateScroll()
|
||||
}
|
||||
|
||||
override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) {
|
||||
super.onItemRangeRemoved(positionStart, itemCount)
|
||||
invalidateScroll()
|
||||
}
|
||||
|
||||
private fun invalidateScroll() {
|
||||
recyclerView.post {
|
||||
scrollListener.onScrolled(recyclerView, 0, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,123 @@
|
||||
package org.koitharu.kotatsu.core.ui.sheet
|
||||
|
||||
import android.app.Dialog
|
||||
import android.view.View
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||
import com.google.android.material.sidesheet.SideSheetBehavior
|
||||
import com.google.android.material.sidesheet.SideSheetCallback
|
||||
import com.google.android.material.sidesheet.SideSheetDialog
|
||||
import java.util.LinkedList
|
||||
|
||||
sealed class AdaptiveSheetBehavior {
|
||||
|
||||
@JvmField
|
||||
protected val callbacks = LinkedList<AdaptiveSheetCallback>()
|
||||
|
||||
abstract var state: Int
|
||||
|
||||
abstract var isDraggable: Boolean
|
||||
|
||||
open val isHideable: Boolean = true
|
||||
|
||||
fun addCallback(callback: AdaptiveSheetCallback) {
|
||||
callbacks.add(callback)
|
||||
}
|
||||
|
||||
fun removeCallback(callback: AdaptiveSheetCallback) {
|
||||
callbacks.remove(callback)
|
||||
}
|
||||
|
||||
class Bottom(
|
||||
private val delegate: BottomSheetBehavior<*>,
|
||||
) : AdaptiveSheetBehavior() {
|
||||
|
||||
override var state: Int
|
||||
get() = delegate.state
|
||||
set(value) {
|
||||
delegate.state = value
|
||||
}
|
||||
|
||||
override var isDraggable: Boolean
|
||||
get() = delegate.isDraggable
|
||||
set(value) {
|
||||
delegate.isDraggable = value
|
||||
}
|
||||
|
||||
override val isHideable: Boolean
|
||||
get() = delegate.isHideable
|
||||
|
||||
var isFitToContents: Boolean
|
||||
get() = delegate.isFitToContents
|
||||
set(value) {
|
||||
delegate.isFitToContents = value
|
||||
}
|
||||
|
||||
init {
|
||||
delegate.addBottomSheetCallback(
|
||||
object : BottomSheetCallback() {
|
||||
override fun onStateChanged(bottomSheet: View, newState: Int) {
|
||||
callbacks.forEach { it.onStateChanged(bottomSheet, newState) }
|
||||
}
|
||||
|
||||
override fun onSlide(bottomSheet: View, slideOffset: Float) {
|
||||
callbacks.forEach { it.onSlide(bottomSheet, slideOffset) }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class Side(
|
||||
private val delegate: SideSheetBehavior<*>,
|
||||
) : AdaptiveSheetBehavior() {
|
||||
|
||||
override var state: Int
|
||||
get() = delegate.state
|
||||
set(value) {
|
||||
delegate.state = value
|
||||
}
|
||||
|
||||
override var isDraggable: Boolean
|
||||
get() = delegate.isDraggable
|
||||
set(value) {
|
||||
delegate.isDraggable = value
|
||||
}
|
||||
|
||||
init {
|
||||
delegate.addCallback(
|
||||
object : SideSheetCallback() {
|
||||
override fun onStateChanged(sheet: View, newState: Int) {
|
||||
callbacks.forEach { it.onStateChanged(sheet, newState) }
|
||||
}
|
||||
|
||||
override fun onSlide(sheet: View, slideOffset: Float) {
|
||||
callbacks.forEach { it.onSlide(sheet, slideOffset) }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
const val STATE_EXPANDED = SideSheetBehavior.STATE_EXPANDED
|
||||
const val STATE_SETTLING = SideSheetBehavior.STATE_SETTLING
|
||||
const val STATE_DRAGGING = SideSheetBehavior.STATE_DRAGGING
|
||||
const val STATE_HIDDEN = SideSheetBehavior.STATE_HIDDEN
|
||||
|
||||
fun from(dialog: Dialog?): AdaptiveSheetBehavior? = when (dialog) {
|
||||
is BottomSheetDialog -> Bottom(dialog.behavior)
|
||||
is SideSheetDialog -> Side(dialog.behavior)
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun from(lp: CoordinatorLayout.LayoutParams): AdaptiveSheetBehavior? = when (val behavior = lp.behavior) {
|
||||
is BottomSheetBehavior<*> -> Bottom(behavior)
|
||||
is SideSheetBehavior<*> -> Side(behavior)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
package org.koitharu.kotatsu.core.ui.sheet
|
||||
|
||||
import android.view.View
|
||||
|
||||
interface AdaptiveSheetCallback {
|
||||
|
||||
/**
|
||||
* Called when the sheet changes its state.
|
||||
*
|
||||
* @param sheet The sheet view.
|
||||
* @param newState The new state.
|
||||
*/
|
||||
fun onStateChanged(sheet: View, newState: Int)
|
||||
|
||||
/**
|
||||
* Called when the sheet is being dragged.
|
||||
*
|
||||
* @param sheet The sheet view.
|
||||
* @param slideOffset The new offset of this sheet.
|
||||
*/
|
||||
fun onSlide(sheet: View, slideOffset: Float) = Unit
|
||||
}
|
||||
@ -0,0 +1,94 @@
|
||||
package org.koitharu.kotatsu.core.ui.sheet
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.widget.LinearLayout
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.core.content.withStyledAttributes
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.util.ext.parents
|
||||
import org.koitharu.kotatsu.databinding.LayoutSheetHeaderAdaptiveBinding
|
||||
|
||||
class AdaptiveSheetHeaderBar @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
@AttrRes defStyleAttr: Int = 0,
|
||||
) : LinearLayout(context, attrs, defStyleAttr), AdaptiveSheetCallback {
|
||||
|
||||
private val binding = LayoutSheetHeaderAdaptiveBinding.inflate(LayoutInflater.from(context), this)
|
||||
private var sheetBehavior: AdaptiveSheetBehavior? = null
|
||||
|
||||
var title: CharSequence?
|
||||
get() = binding.shTextViewTitle.text
|
||||
set(value) {
|
||||
binding.shTextViewTitle.text = value
|
||||
}
|
||||
|
||||
val isTitleVisible: Boolean
|
||||
get() = binding.shLayoutSidesheet.isVisible
|
||||
|
||||
init {
|
||||
orientation = VERTICAL
|
||||
binding.shButtonClose.setOnClickListener { dismissSheet() }
|
||||
context.withStyledAttributes(
|
||||
attrs,
|
||||
R.styleable.AdaptiveSheetHeaderBar, defStyleAttr,
|
||||
) {
|
||||
title = getText(R.styleable.AdaptiveSheetHeaderBar_title)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAttachedToWindow() {
|
||||
super.onAttachedToWindow()
|
||||
if (isInEditMode) {
|
||||
val isTabled = resources.getBoolean(R.bool.is_tablet)
|
||||
binding.shDragHandle.isGone = isTabled
|
||||
binding.shLayoutSidesheet.isVisible = isTabled
|
||||
} else {
|
||||
setBottomSheetBehavior(findParentSheetBehavior())
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
setBottomSheetBehavior(null)
|
||||
super.onDetachedFromWindow()
|
||||
}
|
||||
|
||||
override fun onStateChanged(sheet: View, newState: Int) {
|
||||
|
||||
}
|
||||
|
||||
fun setTitle(@StringRes resId: Int) {
|
||||
binding.shTextViewTitle.setText(resId)
|
||||
}
|
||||
|
||||
private fun setBottomSheetBehavior(behavior: AdaptiveSheetBehavior?) {
|
||||
binding.shDragHandle.isVisible = behavior is AdaptiveSheetBehavior.Bottom
|
||||
binding.shLayoutSidesheet.isVisible = behavior is AdaptiveSheetBehavior.Side
|
||||
sheetBehavior?.removeCallback(this)
|
||||
sheetBehavior = behavior
|
||||
behavior?.addCallback(this)
|
||||
}
|
||||
|
||||
private fun dismissSheet() {
|
||||
sheetBehavior?.state = AdaptiveSheetBehavior.STATE_HIDDEN
|
||||
}
|
||||
|
||||
private fun findParentSheetBehavior(): AdaptiveSheetBehavior? {
|
||||
for (p in parents) {
|
||||
val layoutParams = (p as? View)?.layoutParams
|
||||
if (layoutParams is CoordinatorLayout.LayoutParams) {
|
||||
AdaptiveSheetBehavior.from(layoutParams)?.let {
|
||||
return it
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,175 @@
|
||||
package org.koitharu.kotatsu.core.ui.sheet
|
||||
|
||||
import android.app.Dialog
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewGroup.LayoutParams
|
||||
import androidx.activity.ComponentDialog
|
||||
import androidx.activity.OnBackPressedDispatcher
|
||||
import androidx.appcompat.app.AppCompatDialogFragment
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.viewbinding.ViewBinding
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||
import com.google.android.material.sidesheet.SideSheetDialog
|
||||
import org.koitharu.kotatsu.R
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
abstract class BaseAdaptiveSheet<B : ViewBinding> : AppCompatDialogFragment() {
|
||||
|
||||
private var waitingForDismissAllowingStateLoss = false
|
||||
private var isFitToContentsDisabled = false
|
||||
|
||||
var viewBinding: B? = null
|
||||
private set
|
||||
|
||||
@Deprecated("", ReplaceWith("requireViewBinding()"))
|
||||
protected val binding: B
|
||||
get() = requireViewBinding()
|
||||
|
||||
protected val behavior: AdaptiveSheetBehavior?
|
||||
get() = AdaptiveSheetBehavior.from(dialog)
|
||||
|
||||
val isExpanded: Boolean
|
||||
get() = behavior?.state == AdaptiveSheetBehavior.STATE_EXPANDED
|
||||
|
||||
val onBackPressedDispatcher: OnBackPressedDispatcher
|
||||
get() = (requireDialog() as ComponentDialog).onBackPressedDispatcher
|
||||
|
||||
final override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?,
|
||||
): View {
|
||||
val binding = onCreateViewBinding(inflater, container)
|
||||
viewBinding = binding
|
||||
return binding.root
|
||||
}
|
||||
|
||||
final override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
val binding = requireViewBinding()
|
||||
onViewBindingCreated(binding, savedInstanceState)
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
viewBinding = null
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val context = requireContext()
|
||||
return if (context.resources.getBoolean(R.bool.is_tablet)) {
|
||||
SideSheetDialog(context, theme)
|
||||
} else {
|
||||
BottomSheetDialog(context, theme)
|
||||
}
|
||||
}
|
||||
|
||||
fun addSheetCallback(callback: AdaptiveSheetCallback) {
|
||||
val b = behavior ?: return
|
||||
b.addCallback(callback)
|
||||
val rootView = dialog?.findViewById<View>(materialR.id.design_bottom_sheet)
|
||||
?: dialog?.findViewById(materialR.id.coordinator)
|
||||
if (rootView != null) {
|
||||
callback.onStateChanged(rootView, b.state)
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): B
|
||||
|
||||
protected open fun onViewBindingCreated(binding: B, savedInstanceState: Bundle?) = Unit
|
||||
|
||||
protected fun setExpanded(isExpanded: Boolean, isLocked: Boolean) {
|
||||
val b = behavior ?: return
|
||||
if (isExpanded) {
|
||||
b.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
}
|
||||
if (b is AdaptiveSheetBehavior.Bottom) {
|
||||
b.isFitToContents = !isFitToContentsDisabled && !isExpanded
|
||||
val rootView = dialog?.findViewById<View>(materialR.id.design_bottom_sheet)
|
||||
rootView?.updateLayoutParams {
|
||||
height = if (isFitToContentsDisabled || isExpanded) {
|
||||
LayoutParams.MATCH_PARENT
|
||||
} else {
|
||||
LayoutParams.WRAP_CONTENT
|
||||
}
|
||||
}
|
||||
}
|
||||
b.isDraggable = !isLocked
|
||||
}
|
||||
|
||||
protected fun disableFitToContents() {
|
||||
isFitToContentsDisabled = true
|
||||
val b = behavior as? AdaptiveSheetBehavior.Bottom ?: return
|
||||
b.isFitToContents = false
|
||||
dialog?.findViewById<View>(materialR.id.design_bottom_sheet)?.updateLayoutParams {
|
||||
height = LayoutParams.MATCH_PARENT
|
||||
}
|
||||
}
|
||||
|
||||
fun requireViewBinding(): B = checkNotNull(viewBinding) {
|
||||
"Fragment $this did not return a ViewBinding from onCreateView() or this was called before onCreateView()."
|
||||
}
|
||||
|
||||
override fun dismiss() {
|
||||
if (!tryDismissWithAnimation(false)) {
|
||||
super.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
override fun dismissAllowingStateLoss() {
|
||||
if (!tryDismissWithAnimation(true)) {
|
||||
super.dismissAllowingStateLoss()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to dismiss the dialog fragment with the bottom sheet animation. Returns true if possible,
|
||||
* false otherwise.
|
||||
*/
|
||||
private fun tryDismissWithAnimation(allowingStateLoss: Boolean): Boolean {
|
||||
val shouldDismissWithAnimation = when (val dialog = dialog) {
|
||||
is BottomSheetDialog -> dialog.dismissWithAnimation
|
||||
is SideSheetDialog -> dialog.isDismissWithSheetAnimationEnabled
|
||||
else -> false
|
||||
}
|
||||
val behavior = behavior ?: return false
|
||||
return if (shouldDismissWithAnimation && behavior.isHideable) {
|
||||
dismissWithAnimation(behavior, allowingStateLoss)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun dismissWithAnimation(behavior: AdaptiveSheetBehavior, allowingStateLoss: Boolean) {
|
||||
waitingForDismissAllowingStateLoss = allowingStateLoss
|
||||
if (behavior.state == AdaptiveSheetBehavior.STATE_HIDDEN) {
|
||||
dismissAfterAnimation()
|
||||
} else {
|
||||
behavior.addCallback(SheetDismissCallback())
|
||||
behavior.state = AdaptiveSheetBehavior.STATE_HIDDEN
|
||||
}
|
||||
}
|
||||
|
||||
private fun dismissAfterAnimation() {
|
||||
if (waitingForDismissAllowingStateLoss) {
|
||||
super.dismissAllowingStateLoss()
|
||||
} else {
|
||||
super.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private inner class SheetDismissCallback : AdaptiveSheetCallback {
|
||||
override fun onStateChanged(sheet: View, newState: Int) {
|
||||
if (newState == BottomSheetBehavior.STATE_HIDDEN) {
|
||||
dismissAfterAnimation()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSlide(sheet: View, slideOffset: Float) {}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
package org.koitharu.kotatsu.core.util
|
||||
|
||||
class CompositeRunnable(
|
||||
private val children: List<Runnable>,
|
||||
) : Runnable, Collection<Runnable> by children {
|
||||
|
||||
override fun run() {
|
||||
for (child in children) {
|
||||
child.run()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
package org.koitharu.kotatsu.core.util.ext
|
||||
|
||||
import android.view.View
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback
|
||||
|
||||
fun BottomSheetBehavior<*>.doOnExpansionsChanged(callback: (isExpanded: Boolean) -> Unit) {
|
||||
var isExpended = state == BottomSheetBehavior.STATE_EXPANDED
|
||||
callback(isExpended)
|
||||
addBottomSheetCallback(
|
||||
object : BottomSheetCallback() {
|
||||
override fun onStateChanged(bottomSheet: View, newState: Int) {
|
||||
val expanded = newState == BottomSheetBehavior.STATE_EXPANDED
|
||||
if (expanded != isExpended) {
|
||||
isExpended = expanded
|
||||
callback(expanded)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSlide(bottomSheet: View, slideOffset: Float) = Unit
|
||||
},
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
package org.koitharu.kotatsu.details.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.view.doOnLayout
|
||||
import androidx.core.widget.NestedScrollView
|
||||
import org.koitharu.kotatsu.core.util.ext.findActivity
|
||||
import java.lang.ref.WeakReference
|
||||
|
||||
class TitleScrollCoordinator(
|
||||
private val titleView: TextView,
|
||||
) : NestedScrollView.OnScrollChangeListener {
|
||||
|
||||
private val location = IntArray(2)
|
||||
private var activityRef: WeakReference<AppCompatActivity>? = null
|
||||
|
||||
override fun onScrollChange(v: NestedScrollView, scrollX: Int, scrollY: Int, oldScrollX: Int, oldScrollY: Int) {
|
||||
val actionBar = getActivity(v.context)?.supportActionBar ?: return
|
||||
titleView.getLocationOnScreen(location)
|
||||
var top = location[1] + titleView.height
|
||||
v.getLocationOnScreen(location)
|
||||
top -= location[1]
|
||||
actionBar.setDisplayShowTitleEnabled(top < 0)
|
||||
}
|
||||
|
||||
fun attach(scrollView: NestedScrollView) {
|
||||
scrollView.setOnScrollChangeListener(this)
|
||||
scrollView.doOnLayout {
|
||||
onScrollChange(scrollView, 0, 0, 0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getActivity(context: Context): AppCompatActivity? {
|
||||
activityRef?.get()?.let {
|
||||
if (!it.isDestroyed) return it
|
||||
}
|
||||
val activity = context.findActivity() as? AppCompatActivity
|
||||
if (activity == null || activity.isDestroyed) {
|
||||
return null
|
||||
}
|
||||
activityRef = WeakReference(activity)
|
||||
return activity
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
package org.koitharu.kotatsu.favourites.domain.model
|
||||
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
|
||||
class Cover(
|
||||
val url: String,
|
||||
val source: String,
|
||||
) {
|
||||
|
||||
val mangaSource: MangaSource?
|
||||
get() = if (source.isEmpty()) null else MangaSource.values().find { it.name == source }
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as Cover
|
||||
|
||||
if (url != other.url) return false
|
||||
return source == other.source
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = url.hashCode()
|
||||
result = 31 * result + source.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return "Cover(url='$url', source=$source)"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
package org.koitharu.kotatsu.favourites.ui.categories.select.adapter
|
||||
|
||||
import android.view.View
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.databinding.ItemCategoriesHeaderBinding
|
||||
import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesActivity
|
||||
import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity
|
||||
import org.koitharu.kotatsu.favourites.ui.categories.select.model.CategoriesHeaderItem
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
|
||||
fun categoriesHeaderAD() = adapterDelegateViewBinding<CategoriesHeaderItem, ListModel, ItemCategoriesHeaderBinding>(
|
||||
{ inflater, parent -> ItemCategoriesHeaderBinding.inflate(inflater, parent, false) },
|
||||
) {
|
||||
|
||||
val onClickListener = View.OnClickListener { v ->
|
||||
val intent = when (v.id) {
|
||||
R.id.button_create -> FavouritesCategoryEditActivity.newIntent(v.context)
|
||||
R.id.button_manage -> FavouriteCategoriesActivity.newIntent(v.context)
|
||||
else -> return@OnClickListener
|
||||
}
|
||||
v.context.startActivity(intent)
|
||||
}
|
||||
|
||||
binding.buttonCreate.setOnClickListener(onClickListener)
|
||||
binding.buttonManage.setOnClickListener(onClickListener)
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
package org.koitharu.kotatsu.favourites.ui.categories.select.model
|
||||
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
|
||||
class CategoriesHeaderItem : ListModel {
|
||||
|
||||
override fun equals(other: Any?): Boolean = other?.javaClass == CategoriesHeaderItem::class.java
|
||||
}
|
||||
@ -1,7 +1,9 @@
|
||||
package org.koitharu.kotatsu.favourites.ui.categories.select.model
|
||||
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
|
||||
data class MangaCategoryItem(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
val isChecked: Boolean
|
||||
)
|
||||
val isChecked: Boolean,
|
||||
) : ListModel
|
||||
|
||||
@ -0,0 +1,46 @@
|
||||
package org.koitharu.kotatsu.filter.ui
|
||||
|
||||
import android.content.Context
|
||||
import androidx.recyclerview.widget.AsyncListDiffer.ListListener
|
||||
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
|
||||
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
|
||||
import org.koitharu.kotatsu.filter.ui.model.FilterItem
|
||||
import org.koitharu.kotatsu.list.ui.adapter.listSimpleHeaderAD
|
||||
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
|
||||
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
|
||||
class FilterAdapter(
|
||||
listener: OnFilterChangedListener,
|
||||
listListener: ListListener<ListModel>,
|
||||
) : AsyncListDifferDelegationAdapter<ListModel>(FilterDiffCallback()), FastScroller.SectionIndexer {
|
||||
|
||||
init {
|
||||
delegatesManager
|
||||
.addDelegate(filterSortDelegate(listener))
|
||||
.addDelegate(filterTagDelegate(listener))
|
||||
.addDelegate(listSimpleHeaderAD())
|
||||
.addDelegate(loadingStateAD())
|
||||
.addDelegate(loadingFooterAD())
|
||||
.addDelegate(filterErrorDelegate())
|
||||
differ.addListListener(listListener)
|
||||
}
|
||||
|
||||
override fun getSectionText(context: Context, position: Int): CharSequence? {
|
||||
val list = items
|
||||
for (i in (0..position).reversed()) {
|
||||
val item = list.getOrNull(i) ?: continue
|
||||
if (item is FilterItem.Tag) {
|
||||
return item.tag.title.firstOrNull()?.toString()
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
const val ITEM_TYPE_HEADER = 0
|
||||
const val ITEM_TYPE_SORT = 1
|
||||
const val ITEM_TYPE_TAG = 2
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,51 @@
|
||||
package org.koitharu.kotatsu.filter.ui
|
||||
|
||||
import android.widget.TextView
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.ui.model.titleRes
|
||||
import org.koitharu.kotatsu.core.util.ext.setChecked
|
||||
import org.koitharu.kotatsu.databinding.ItemCheckableMultipleBinding
|
||||
import org.koitharu.kotatsu.databinding.ItemCheckableSingleBinding
|
||||
import org.koitharu.kotatsu.filter.ui.model.FilterItem
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
|
||||
fun filterSortDelegate(
|
||||
listener: OnFilterChangedListener,
|
||||
) = adapterDelegateViewBinding<FilterItem.Sort, ListModel, ItemCheckableSingleBinding>(
|
||||
{ layoutInflater, parent -> ItemCheckableSingleBinding.inflate(layoutInflater, parent, false) },
|
||||
) {
|
||||
|
||||
itemView.setOnClickListener {
|
||||
listener.onSortItemClick(item)
|
||||
}
|
||||
|
||||
bind { payloads ->
|
||||
binding.root.setText(item.order.titleRes)
|
||||
binding.root.setChecked(item.isSelected, payloads.isNotEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
fun filterTagDelegate(
|
||||
listener: OnFilterChangedListener,
|
||||
) = adapterDelegateViewBinding<FilterItem.Tag, ListModel, ItemCheckableMultipleBinding>(
|
||||
{ layoutInflater, parent -> ItemCheckableMultipleBinding.inflate(layoutInflater, parent, false) },
|
||||
) {
|
||||
|
||||
itemView.setOnClickListener {
|
||||
listener.onTagItemClick(item)
|
||||
}
|
||||
|
||||
bind { payloads ->
|
||||
binding.root.text = item.tag.title
|
||||
binding.root.setChecked(item.isChecked, payloads.isNotEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
fun filterErrorDelegate() = adapterDelegate<FilterItem.Error, ListModel>(R.layout.item_sources_empty) {
|
||||
|
||||
bind {
|
||||
(itemView as TextView).setText(item.textResId)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,52 @@
|
||||
package org.koitharu.kotatsu.filter.ui
|
||||
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import org.koitharu.kotatsu.filter.ui.model.FilterItem
|
||||
import org.koitharu.kotatsu.list.ui.model.ListHeader
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
|
||||
class FilterDiffCallback : DiffUtil.ItemCallback<ListModel>() {
|
||||
|
||||
override fun areItemsTheSame(oldItem: ListModel, newItem: ListModel): Boolean {
|
||||
return when {
|
||||
oldItem === newItem -> true
|
||||
oldItem.javaClass != newItem.javaClass -> false
|
||||
oldItem is ListHeader && newItem is ListHeader -> {
|
||||
oldItem == newItem
|
||||
}
|
||||
|
||||
oldItem is FilterItem.Tag && newItem is FilterItem.Tag -> {
|
||||
oldItem.tag == newItem.tag
|
||||
}
|
||||
|
||||
oldItem is FilterItem.Sort && newItem is FilterItem.Sort -> {
|
||||
oldItem.order == newItem.order
|
||||
}
|
||||
|
||||
oldItem is FilterItem.Error && newItem is FilterItem.Error -> {
|
||||
oldItem.textResId == newItem.textResId
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: ListModel, newItem: ListModel): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
|
||||
override fun getChangePayload(oldItem: ListModel, newItem: ListModel): Any? {
|
||||
val hasPayload = when {
|
||||
oldItem is FilterItem.Tag && newItem is FilterItem.Tag -> {
|
||||
oldItem.isChecked != newItem.isChecked
|
||||
}
|
||||
|
||||
oldItem is FilterItem.Sort && newItem is FilterItem.Sort -> {
|
||||
oldItem.isSelected != newItem.isSelected
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
return if (hasPayload) Unit else super.getChangePayload(oldItem, newItem)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,71 @@
|
||||
package org.koitharu.kotatsu.filter.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.isVisible
|
||||
import com.google.android.material.chip.Chip
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.ui.BaseFragment
|
||||
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
|
||||
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.databinding.FragmentFilterHeaderBinding
|
||||
import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
|
||||
import org.koitharu.kotatsu.filter.ui.model.FilterItem
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
class FilterHeaderFragment : BaseFragment<FragmentFilterHeaderBinding>(), ChipsView.OnChipClickListener {
|
||||
|
||||
private val owner by lazy(LazyThreadSafetyMode.NONE) {
|
||||
FilterOwner.from(requireActivity())
|
||||
}
|
||||
|
||||
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentFilterHeaderBinding {
|
||||
return FragmentFilterHeaderBinding.inflate(inflater, container, false)
|
||||
}
|
||||
|
||||
override fun onViewBindingCreated(binding: FragmentFilterHeaderBinding, savedInstanceState: Bundle?) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
binding.chipsTags.onChipClickListener = this
|
||||
owner.header.observe(viewLifecycleOwner, ::onDataChanged)
|
||||
}
|
||||
|
||||
override fun onWindowInsetsChanged(insets: Insets) = Unit
|
||||
|
||||
override fun onChipClick(chip: Chip, data: Any?) {
|
||||
val tag = data as? MangaTag
|
||||
if (tag == null) {
|
||||
FilterSheetFragment.show(parentFragmentManager)
|
||||
} else {
|
||||
owner.onTagItemClick(FilterItem.Tag(tag, !chip.isChecked))
|
||||
}
|
||||
}
|
||||
|
||||
private fun onDataChanged(header: FilterHeaderModel) {
|
||||
val binding = viewBinding ?: return
|
||||
val chips = header.chips
|
||||
if (chips.isEmpty()) {
|
||||
binding.chipsTags.setChips(emptyList())
|
||||
binding.root.isVisible = false
|
||||
return
|
||||
}
|
||||
if (binding.root.context.isAnimationsEnabled) {
|
||||
binding.scrollView.smoothScrollTo(0, 0)
|
||||
} else {
|
||||
binding.scrollView.scrollTo(0, 0)
|
||||
}
|
||||
binding.chipsTags.setChips(header.chips + moreTagsChip())
|
||||
binding.root.isVisible = true
|
||||
}
|
||||
|
||||
private fun moreTagsChip() = ChipsView.ChipModel(
|
||||
tint = 0,
|
||||
title = getString(R.string.more),
|
||||
icon = materialR.drawable.abc_ic_menu_overflow_material,
|
||||
isCheckable = false,
|
||||
isChecked = false,
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
package org.koitharu.kotatsu.filter.ui
|
||||
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.koitharu.kotatsu.core.util.ext.values
|
||||
import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
|
||||
interface FilterOwner : OnFilterChangedListener {
|
||||
|
||||
val filterItems: StateFlow<List<ListModel>>
|
||||
|
||||
val header: StateFlow<FilterHeaderModel>
|
||||
|
||||
fun applyFilter(tags: Set<MangaTag>)
|
||||
|
||||
companion object {
|
||||
|
||||
fun from(activity: FragmentActivity): FilterOwner {
|
||||
for (f in activity.supportFragmentManager.fragments) {
|
||||
return find(f) ?: continue
|
||||
}
|
||||
error("Cannot find FilterOwner")
|
||||
}
|
||||
|
||||
fun find(fragment: Fragment): FilterOwner? {
|
||||
return fragment.viewModelStore.values.firstNotNullOfOrNull { it as? FilterOwner }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,59 @@
|
||||
package org.koitharu.kotatsu.filter.ui
|
||||
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.recyclerview.widget.AsyncListDiffer
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetBehavior
|
||||
import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetCallback
|
||||
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.databinding.SheetFilterBinding
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
|
||||
class FilterSheetFragment :
|
||||
BaseAdaptiveSheet<SheetFilterBinding>(),
|
||||
AdaptiveSheetCallback,
|
||||
AsyncListDiffer.ListListener<ListModel> {
|
||||
|
||||
private val owner by lazy(LazyThreadSafetyMode.NONE) {
|
||||
FilterOwner.from(requireActivity())
|
||||
}
|
||||
|
||||
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetFilterBinding {
|
||||
return SheetFilterBinding.inflate(inflater, container, false)
|
||||
}
|
||||
|
||||
override fun onViewBindingCreated(binding: SheetFilterBinding, savedInstanceState: Bundle?) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
addSheetCallback(this)
|
||||
val adapter = FilterAdapter(owner, this)
|
||||
binding.recyclerView.adapter = adapter
|
||||
owner.filterItems.observe(viewLifecycleOwner, adapter::setItems)
|
||||
|
||||
if (dialog == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
binding.recyclerView.scrollIndicators = 0
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCurrentListChanged(previousList: MutableList<ListModel>, currentList: MutableList<ListModel>) {
|
||||
if (currentList.size > previousList.size && view != null) {
|
||||
(requireViewBinding().recyclerView.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStateChanged(sheet: View, newState: Int) {
|
||||
viewBinding?.recyclerView?.isFastScrollerEnabled = newState == AdaptiveSheetBehavior.STATE_EXPANDED
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val TAG = "FilterBottomSheet"
|
||||
|
||||
fun show(fm: FragmentManager) = FilterSheetFragment().show(fm, TAG)
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,6 @@
|
||||
package org.koitharu.kotatsu.list.ui.filter
|
||||
package org.koitharu.kotatsu.filter.ui
|
||||
|
||||
import org.koitharu.kotatsu.filter.ui.model.FilterItem
|
||||
|
||||
interface OnFilterChangedListener {
|
||||
|
||||
@ -1,19 +1,23 @@
|
||||
package org.koitharu.kotatsu.list.ui.model
|
||||
package org.koitharu.kotatsu.filter.ui.model
|
||||
|
||||
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
|
||||
class ListHeader2(
|
||||
class FilterHeaderModel(
|
||||
val chips: Collection<ChipsView.ChipModel>,
|
||||
val sortOrder: SortOrder?,
|
||||
val hasSelectedTags: Boolean,
|
||||
) : ListModel {
|
||||
|
||||
val textSummary: String
|
||||
get() = chips.mapNotNull { if (it.isChecked) it.title else null }.joinToString()
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as ListHeader2
|
||||
other as FilterHeaderModel
|
||||
|
||||
if (chips != other.chips) return false
|
||||
return sortOrder == other.sortOrder
|
||||
@ -0,0 +1,71 @@
|
||||
package org.koitharu.kotatsu.filter.ui.model
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
|
||||
sealed interface FilterItem : ListModel {
|
||||
|
||||
class Sort(
|
||||
val order: SortOrder,
|
||||
val isSelected: Boolean,
|
||||
) : FilterItem {
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as Sort
|
||||
|
||||
if (order != other.order) return false
|
||||
return isSelected == other.isSelected
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = order.hashCode()
|
||||
result = 31 * result + isSelected.hashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
class Tag(
|
||||
val tag: MangaTag,
|
||||
val isChecked: Boolean,
|
||||
) : FilterItem {
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as Tag
|
||||
|
||||
if (tag != other.tag) return false
|
||||
return isChecked == other.isChecked
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = tag.hashCode()
|
||||
result = 31 * result + isChecked.hashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
class Error(
|
||||
@StringRes val textResId: Int,
|
||||
) : FilterItem {
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as Error
|
||||
|
||||
return textResId == other.textResId
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return textResId
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
package org.koitharu.kotatsu.list.domain
|
||||
|
||||
import dagger.Reusable
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.history.data.HistoryRepository
|
||||
import org.koitharu.kotatsu.history.data.PROGRESS_NONE
|
||||
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
@Reusable
|
||||
class ListExtraProviderImpl @Inject constructor(
|
||||
private val settings: AppSettings,
|
||||
private val trackingRepository: TrackingRepository,
|
||||
private val historyRepository: HistoryRepository,
|
||||
) : ListExtraProvider {
|
||||
|
||||
override suspend fun getCounter(mangaId: Long): Int {
|
||||
return if (settings.isTrackerEnabled) {
|
||||
trackingRepository.getNewChaptersCount(mangaId)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getProgress(mangaId: Long): Float {
|
||||
return if (settings.isReadingIndicatorsEnabled) {
|
||||
historyRepository.getProgress(mangaId)
|
||||
} else {
|
||||
PROGRESS_NONE
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,21 +0,0 @@
|
||||
package org.koitharu.kotatsu.list.ui.filter
|
||||
|
||||
import androidx.recyclerview.widget.AsyncListDiffer.ListListener
|
||||
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
|
||||
|
||||
class FilterAdapter(
|
||||
listener: OnFilterChangedListener,
|
||||
listListener: ListListener<FilterItem>,
|
||||
) : AsyncListDifferDelegationAdapter<FilterItem>(
|
||||
FilterDiffCallback(),
|
||||
filterSortDelegate(listener),
|
||||
filterTagDelegate(listener),
|
||||
filterHeaderDelegate(),
|
||||
filterLoadingDelegate(),
|
||||
filterErrorDelegate(),
|
||||
) {
|
||||
|
||||
init {
|
||||
differ.addListListener(listListener)
|
||||
}
|
||||
}
|
||||
@ -1,66 +0,0 @@
|
||||
package org.koitharu.kotatsu.list.ui.filter
|
||||
|
||||
import android.widget.TextView
|
||||
import androidx.core.view.isVisible
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.ui.model.titleRes
|
||||
import org.koitharu.kotatsu.databinding.ItemCheckableNewBinding
|
||||
import org.koitharu.kotatsu.databinding.ItemFilterHeaderBinding
|
||||
|
||||
fun filterSortDelegate(
|
||||
listener: OnFilterChangedListener,
|
||||
) = adapterDelegateViewBinding<FilterItem.Sort, FilterItem, ItemCheckableNewBinding>(
|
||||
{ layoutInflater, parent -> ItemCheckableNewBinding.inflate(layoutInflater, parent, false) },
|
||||
) {
|
||||
|
||||
itemView.setOnClickListener {
|
||||
listener.onSortItemClick(item)
|
||||
}
|
||||
|
||||
bind {
|
||||
binding.root.setText(item.order.titleRes)
|
||||
binding.root.isChecked = item.isSelected
|
||||
}
|
||||
}
|
||||
|
||||
fun filterTagDelegate(
|
||||
listener: OnFilterChangedListener,
|
||||
) = adapterDelegateViewBinding<FilterItem.Tag, FilterItem, ItemCheckableNewBinding>(
|
||||
{ layoutInflater, parent -> ItemCheckableNewBinding.inflate(layoutInflater, parent, false) },
|
||||
) {
|
||||
|
||||
itemView.setOnClickListener {
|
||||
listener.onTagItemClick(item)
|
||||
}
|
||||
|
||||
bind {
|
||||
binding.root.text = item.tag.title
|
||||
binding.root.isChecked = item.isChecked
|
||||
}
|
||||
}
|
||||
|
||||
fun filterHeaderDelegate() = adapterDelegateViewBinding<FilterItem.Header, FilterItem, ItemFilterHeaderBinding>(
|
||||
{ layoutInflater, parent -> ItemFilterHeaderBinding.inflate(layoutInflater, parent, false) },
|
||||
) {
|
||||
|
||||
bind {
|
||||
binding.textViewTitle.setText(item.titleResId)
|
||||
binding.badge.isVisible = if (item.counter == 0) {
|
||||
false
|
||||
} else {
|
||||
binding.badge.text = item.counter.toString()
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun filterLoadingDelegate() = adapterDelegate<FilterItem.Loading, FilterItem>(R.layout.item_loading_footer) {}
|
||||
|
||||
fun filterErrorDelegate() = adapterDelegate<FilterItem.Error, FilterItem>(R.layout.item_sources_empty) {
|
||||
|
||||
bind {
|
||||
(itemView as TextView).setText(item.textResId)
|
||||
}
|
||||
}
|
||||
@ -1,91 +0,0 @@
|
||||
package org.koitharu.kotatsu.list.ui.filter
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.widget.SearchView
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.recyclerview.widget.AsyncListDiffer
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.ui.BaseBottomSheet
|
||||
import org.koitharu.kotatsu.core.ui.util.CollapseActionViewCallback
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.parentFragmentViewModels
|
||||
import org.koitharu.kotatsu.databinding.SheetFilterBinding
|
||||
import org.koitharu.kotatsu.remotelist.ui.RemoteListViewModel
|
||||
|
||||
class FilterBottomSheet :
|
||||
BaseBottomSheet<SheetFilterBinding>(),
|
||||
MenuItem.OnActionExpandListener,
|
||||
SearchView.OnQueryTextListener,
|
||||
AsyncListDiffer.ListListener<FilterItem> {
|
||||
|
||||
private val viewModel by parentFragmentViewModels<RemoteListViewModel>()
|
||||
private var collapsibleActionViewCallback: CollapseActionViewCallback? = null
|
||||
|
||||
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetFilterBinding {
|
||||
return SheetFilterBinding.inflate(inflater, container, false)
|
||||
}
|
||||
|
||||
override fun onViewBindingCreated(binding: SheetFilterBinding, savedInstanceState: Bundle?) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
val adapter = FilterAdapter(viewModel, this)
|
||||
binding.recyclerView.adapter = adapter
|
||||
viewModel.filterItems.observe(viewLifecycleOwner, adapter::setItems)
|
||||
initOptionsMenu()
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
collapsibleActionViewCallback = null
|
||||
}
|
||||
|
||||
override fun onMenuItemActionExpand(item: MenuItem): Boolean {
|
||||
setExpanded(isExpanded = true, isLocked = true)
|
||||
collapsibleActionViewCallback?.onMenuItemActionExpand(item)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onMenuItemActionCollapse(item: MenuItem): Boolean {
|
||||
val searchView = (item.actionView as? SearchView) ?: return false
|
||||
searchView.setQuery("", false)
|
||||
searchView.post { setExpanded(isExpanded = false, isLocked = false) }
|
||||
collapsibleActionViewCallback?.onMenuItemActionCollapse(item)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onQueryTextSubmit(query: String?): Boolean = false
|
||||
|
||||
override fun onQueryTextChange(newText: String?): Boolean {
|
||||
viewModel.filterSearch(newText?.trim().orEmpty())
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onCurrentListChanged(previousList: MutableList<FilterItem>, currentList: MutableList<FilterItem>) {
|
||||
if (currentList.size > previousList.size && view != null) {
|
||||
(requireViewBinding().recyclerView.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
private fun initOptionsMenu() {
|
||||
requireViewBinding().headerBar.inflateMenu(R.menu.opt_filter)
|
||||
val searchMenuItem = requireViewBinding().headerBar.menu.findItem(R.id.action_search)
|
||||
searchMenuItem.setOnActionExpandListener(this)
|
||||
val searchView = searchMenuItem.actionView as SearchView
|
||||
searchView.setOnQueryTextListener(this)
|
||||
searchView.setIconifiedByDefault(false)
|
||||
searchView.queryHint = searchMenuItem.title
|
||||
collapsibleActionViewCallback = CollapseActionViewCallback(searchMenuItem).also {
|
||||
onBackPressedDispatcher.addCallback(it)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val TAG = "FilterBottomSheet"
|
||||
|
||||
fun show(fm: FragmentManager) = FilterBottomSheet().show(fm, TAG)
|
||||
}
|
||||
}
|
||||
@ -1,59 +0,0 @@
|
||||
package org.koitharu.kotatsu.list.ui.filter
|
||||
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
|
||||
class FilterDiffCallback : DiffUtil.ItemCallback<FilterItem>() {
|
||||
|
||||
override fun areItemsTheSame(oldItem: FilterItem, newItem: FilterItem): Boolean {
|
||||
return when {
|
||||
oldItem === newItem -> true
|
||||
oldItem.javaClass != newItem.javaClass -> false
|
||||
oldItem is FilterItem.Header && newItem is FilterItem.Header -> {
|
||||
oldItem.titleResId == newItem.titleResId
|
||||
}
|
||||
oldItem is FilterItem.Tag && newItem is FilterItem.Tag -> {
|
||||
oldItem.tag == newItem.tag
|
||||
}
|
||||
oldItem is FilterItem.Sort && newItem is FilterItem.Sort -> {
|
||||
oldItem.order == newItem.order
|
||||
}
|
||||
oldItem is FilterItem.Error && newItem is FilterItem.Error -> {
|
||||
oldItem.textResId == newItem.textResId
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: FilterItem, newItem: FilterItem): Boolean {
|
||||
return when {
|
||||
oldItem == FilterItem.Loading && newItem == FilterItem.Loading -> true
|
||||
oldItem is FilterItem.Header && newItem is FilterItem.Header -> {
|
||||
oldItem.counter == newItem.counter
|
||||
}
|
||||
oldItem is FilterItem.Error && newItem is FilterItem.Error -> true
|
||||
oldItem is FilterItem.Tag && newItem is FilterItem.Tag -> {
|
||||
oldItem.isChecked == newItem.isChecked
|
||||
}
|
||||
oldItem is FilterItem.Sort && newItem is FilterItem.Sort -> {
|
||||
oldItem.isSelected == newItem.isSelected
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
override fun getChangePayload(oldItem: FilterItem, newItem: FilterItem): Any? {
|
||||
val hasPayload = when {
|
||||
oldItem is FilterItem.Tag && newItem is FilterItem.Tag -> {
|
||||
oldItem.isChecked != newItem.isChecked
|
||||
}
|
||||
oldItem is FilterItem.Sort && newItem is FilterItem.Sort -> {
|
||||
oldItem.isSelected != newItem.isSelected
|
||||
}
|
||||
oldItem is FilterItem.Header && newItem is FilterItem.Header -> {
|
||||
oldItem.counter != newItem.counter
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
return if (hasPayload) Unit else super.getChangePayload(oldItem, newItem)
|
||||
}
|
||||
}
|
||||
@ -1,29 +0,0 @@
|
||||
package org.koitharu.kotatsu.list.ui.filter
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
|
||||
sealed interface FilterItem {
|
||||
|
||||
class Header(
|
||||
@StringRes val titleResId: Int,
|
||||
val counter: Int,
|
||||
) : FilterItem
|
||||
|
||||
class Sort(
|
||||
val order: SortOrder,
|
||||
val isSelected: Boolean,
|
||||
) : FilterItem
|
||||
|
||||
class Tag(
|
||||
val tag: MangaTag,
|
||||
val isChecked: Boolean,
|
||||
) : FilterItem
|
||||
|
||||
object Loading : FilterItem
|
||||
|
||||
class Error(
|
||||
@StringRes val textResId: Int,
|
||||
) : FilterItem
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue