Merge branch 'devel' into java.time
commit
6eef279861
@ -1,25 +1,44 @@
|
|||||||
package org.koitharu.kotatsu.core.backup
|
package org.koitharu.kotatsu.core.backup
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineStart
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.runInterruptible
|
import kotlinx.coroutines.runInterruptible
|
||||||
import okio.Closeable
|
import okio.Closeable
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.util.EnumSet
|
||||||
import java.util.zip.ZipFile
|
import java.util.zip.ZipFile
|
||||||
|
|
||||||
class BackupZipInput(val file: File) : Closeable {
|
class BackupZipInput(val file: File) : Closeable {
|
||||||
|
|
||||||
private val zipFile = ZipFile(file)
|
private val zipFile = ZipFile(file)
|
||||||
|
|
||||||
suspend fun getEntry(name: String): BackupEntry? = runInterruptible(Dispatchers.IO) {
|
suspend fun getEntry(name: BackupEntry.Name): BackupEntry? = runInterruptible(Dispatchers.IO) {
|
||||||
val entry = zipFile.getEntry(name) ?: return@runInterruptible null
|
val entry = zipFile.getEntry(name.key) ?: return@runInterruptible null
|
||||||
val json = zipFile.getInputStream(entry).use {
|
val json = zipFile.getInputStream(entry).use {
|
||||||
JSONArray(it.bufferedReader().readText())
|
JSONArray(it.bufferedReader().readText())
|
||||||
}
|
}
|
||||||
BackupEntry(name, json)
|
BackupEntry(name, json)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun entries(): Set<BackupEntry.Name> = runInterruptible(Dispatchers.IO) {
|
||||||
|
zipFile.entries().toList().mapNotNullTo(EnumSet.noneOf(BackupEntry.Name::class.java)) { ze ->
|
||||||
|
BackupEntry.Name.entries.find { it.key == ze.name }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun close() {
|
override fun close() {
|
||||||
zipFile.close()
|
zipFile.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun cleanupAsync() {
|
||||||
|
processLifecycleScope.launch(Dispatchers.IO, CoroutineStart.ATOMIC) {
|
||||||
|
runCatching {
|
||||||
|
close()
|
||||||
|
file.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,11 @@
|
|||||||
|
package org.koitharu.kotatsu.core.db.migrations
|
||||||
|
|
||||||
|
import androidx.room.migration.Migration
|
||||||
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
|
|
||||||
|
class Migration17To18 : Migration(17, 18) {
|
||||||
|
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL("ALTER TABLE preferences ADD COLUMN `cf_grayscale` INTEGER NOT NULL DEFAULT 0")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,8 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.exceptions
|
|
||||||
|
|
||||||
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
|
|
||||||
|
|
||||||
class CompositeException(val errors: Collection<Throwable>) : Exception() {
|
|
||||||
|
|
||||||
override val message: String = errors.mapNotNullToSet { it.message }.joinToString()
|
|
||||||
}
|
|
||||||
@ -0,0 +1,75 @@
|
|||||||
|
package org.koitharu.kotatsu.core.ui
|
||||||
|
|
||||||
|
import androidx.recyclerview.widget.AsyncListDiffer.ListListener
|
||||||
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
|
import com.hannesdorfmann.adapterdelegates4.AdapterDelegate
|
||||||
|
import com.hannesdorfmann.adapterdelegates4.ListDelegationAdapter
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.FlowCollector
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
||||||
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
|
import java.util.Collections
|
||||||
|
import java.util.LinkedList
|
||||||
|
|
||||||
|
open class ReorderableListAdapter<T : ListModel> : ListDelegationAdapter<List<T>>(), FlowCollector<List<T>?> {
|
||||||
|
|
||||||
|
private val listListeners = LinkedList<ListListener<T>>()
|
||||||
|
|
||||||
|
override suspend fun emit(value: List<T>?) {
|
||||||
|
val oldList = items.orEmpty()
|
||||||
|
val newList = value.orEmpty()
|
||||||
|
val diffResult = withContext(Dispatchers.Default) {
|
||||||
|
val diffCallback = DiffCallback(oldList, newList)
|
||||||
|
DiffUtil.calculateDiff(diffCallback)
|
||||||
|
}
|
||||||
|
super.setItems(newList)
|
||||||
|
diffResult.dispatchUpdatesTo(this)
|
||||||
|
listListeners.forEach { it.onCurrentListChanged(oldList, newList) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Deprecated("Use emit() to dispatch list updates", level = DeprecationLevel.ERROR)
|
||||||
|
override fun setItems(items: List<T>?) {
|
||||||
|
super.setItems(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reorderItems(oldPos: Int, newPos: Int) {
|
||||||
|
Collections.swap(items ?: return, oldPos, newPos)
|
||||||
|
notifyItemMoved(oldPos, newPos)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addDelegate(type: ListItemType, delegate: AdapterDelegate<List<T>>): ReorderableListAdapter<T> {
|
||||||
|
delegatesManager.addDelegate(type.ordinal, delegate)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addListListener(listListener: ListListener<T>) {
|
||||||
|
listListeners.add(listListener)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeListListener(listListener: ListListener<T>) {
|
||||||
|
listListeners.remove(listListener)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected class DiffCallback<T : ListModel>(
|
||||||
|
val oldList: List<T>,
|
||||||
|
val newList: List<T>,
|
||||||
|
) : DiffUtil.Callback() {
|
||||||
|
|
||||||
|
override fun getOldListSize(): Int = oldList.size
|
||||||
|
|
||||||
|
override fun getNewListSize(): Int = newList.size
|
||||||
|
|
||||||
|
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
|
||||||
|
val oldItem = oldList[oldItemPosition]
|
||||||
|
val newItem = newList[newItemPosition]
|
||||||
|
return newItem.areItemsTheSame(oldItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
|
||||||
|
val oldItem = oldList[oldItemPosition]
|
||||||
|
val newItem = newList[newItemPosition]
|
||||||
|
return newItem == oldItem
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,100 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.ui.drawable
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.content.Context
|
|
||||||
import android.graphics.Canvas
|
|
||||||
import android.graphics.Color
|
|
||||||
import android.graphics.ColorFilter
|
|
||||||
import android.graphics.Paint
|
|
||||||
import android.graphics.PixelFormat
|
|
||||||
import android.graphics.Typeface
|
|
||||||
import android.graphics.drawable.Drawable
|
|
||||||
import android.os.Build
|
|
||||||
import android.text.Layout
|
|
||||||
import android.text.StaticLayout
|
|
||||||
import android.text.TextPaint
|
|
||||||
import androidx.annotation.ColorInt
|
|
||||||
import androidx.annotation.Px
|
|
||||||
import androidx.annotation.StyleRes
|
|
||||||
import androidx.core.graphics.withTranslation
|
|
||||||
import com.google.android.material.resources.TextAppearance
|
|
||||||
import com.google.android.material.resources.TextAppearanceFontCallback
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
|
||||||
|
|
||||||
class TextDrawable(
|
|
||||||
val text: CharSequence,
|
|
||||||
) : Drawable() {
|
|
||||||
|
|
||||||
private val paint = TextPaint(Paint.ANTI_ALIAS_FLAG)
|
|
||||||
private var cachedLayout: StaticLayout? = null
|
|
||||||
|
|
||||||
@SuppressLint("RestrictedApi")
|
|
||||||
constructor(context: Context, text: CharSequence, @StyleRes textAppearanceId: Int) : this(text) {
|
|
||||||
val ta = TextAppearance(context, textAppearanceId)
|
|
||||||
paint.color = ta.textColor?.defaultColor ?: context.getThemeColor(android.R.attr.textColorPrimary, Color.BLACK)
|
|
||||||
paint.typeface = ta.fallbackFont
|
|
||||||
ta.getFontAsync(
|
|
||||||
context, paint,
|
|
||||||
object : TextAppearanceFontCallback() {
|
|
||||||
override fun onFontRetrieved(typeface: Typeface?, fontResolvedSynchronously: Boolean) = Unit
|
|
||||||
override fun onFontRetrievalFailed(reason: Int) = Unit
|
|
||||||
},
|
|
||||||
)
|
|
||||||
paint.letterSpacing = ta.letterSpacing
|
|
||||||
}
|
|
||||||
|
|
||||||
var alignment = Layout.Alignment.ALIGN_NORMAL
|
|
||||||
|
|
||||||
var lineSpacingMultiplier = 1f
|
|
||||||
|
|
||||||
@Px
|
|
||||||
var lineSpacingExtra = 0f
|
|
||||||
|
|
||||||
@get:ColorInt
|
|
||||||
var textColor: Int
|
|
||||||
get() = paint.color
|
|
||||||
set(@ColorInt value) {
|
|
||||||
paint.color = value
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun draw(canvas: Canvas) {
|
|
||||||
val b = bounds
|
|
||||||
if (b.isEmpty) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
canvas.withTranslation(x = b.left.toFloat(), y = b.top.toFloat()) {
|
|
||||||
obtainLayout().draw(canvas)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun setAlpha(alpha: Int) {
|
|
||||||
paint.alpha = alpha
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun setColorFilter(colorFilter: ColorFilter?) {
|
|
||||||
paint.setColorFilter(colorFilter)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suppress("DeprecatedCallableAddReplaceWith")
|
|
||||||
@Deprecated("Deprecated in Java")
|
|
||||||
override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
|
|
||||||
|
|
||||||
private fun obtainLayout(): StaticLayout {
|
|
||||||
val width = bounds.width()
|
|
||||||
cachedLayout?.let {
|
|
||||||
if (it.width == width) {
|
|
||||||
return it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
|
||||||
StaticLayout.Builder.obtain(text, 0, text.length, paint, width)
|
|
||||||
.setAlignment(alignment)
|
|
||||||
.setLineSpacing(lineSpacingExtra, lineSpacingMultiplier)
|
|
||||||
.setIncludePad(true)
|
|
||||||
.build()
|
|
||||||
} else {
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
StaticLayout(text, paint, width, alignment, lineSpacingMultiplier, lineSpacingExtra, true)
|
|
||||||
}.also { cachedLayout = it }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,64 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.ui.list
|
|
||||||
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.os.Parcelable
|
|
||||||
import android.util.SparseArray
|
|
||||||
import androidx.core.os.BundleCompat
|
|
||||||
import androidx.core.view.doOnNextLayout
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import java.util.Collections
|
|
||||||
import java.util.WeakHashMap
|
|
||||||
|
|
||||||
class NestedScrollStateHandle(
|
|
||||||
savedInstanceState: Bundle?,
|
|
||||||
private val key: String,
|
|
||||||
) {
|
|
||||||
|
|
||||||
private val storage: SparseArray<Parcelable?> = savedInstanceState?.let {
|
|
||||||
BundleCompat.getSparseParcelableArray(it, key, Parcelable::class.java)
|
|
||||||
} ?: SparseArray<Parcelable?>()
|
|
||||||
private val controllers = Collections.newSetFromMap<Controller>(WeakHashMap())
|
|
||||||
|
|
||||||
fun attach(recycler: RecyclerView) = Controller(recycler).also(controllers::add)
|
|
||||||
|
|
||||||
fun onSaveInstanceState(outState: Bundle) {
|
|
||||||
controllers.forEach {
|
|
||||||
it.saveState()
|
|
||||||
}
|
|
||||||
outState.putSparseParcelableArray(key, storage)
|
|
||||||
}
|
|
||||||
|
|
||||||
inner class Controller(
|
|
||||||
private val recycler: RecyclerView
|
|
||||||
) {
|
|
||||||
|
|
||||||
private var lastPosition: Int = -1
|
|
||||||
|
|
||||||
fun onBind(position: Int) {
|
|
||||||
if (position != lastPosition) {
|
|
||||||
saveState()
|
|
||||||
lastPosition = position
|
|
||||||
storage[position]?.let {
|
|
||||||
restoreState(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onRecycled() {
|
|
||||||
saveState()
|
|
||||||
lastPosition = -1
|
|
||||||
}
|
|
||||||
|
|
||||||
fun saveState() {
|
|
||||||
if (lastPosition != -1) {
|
|
||||||
storage[lastPosition] = recycler.layoutManager?.onSaveInstanceState()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun restoreState(state: Parcelable) {
|
|
||||||
recycler.doOnNextLayout {
|
|
||||||
recycler.layoutManager?.onRestoreInstanceState(state)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.util.progress
|
|
||||||
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
|
|
||||||
open class ProgressJob<P>(
|
|
||||||
private val job: Job,
|
|
||||||
private val progress: StateFlow<P>,
|
|
||||||
) : Job by job {
|
|
||||||
|
|
||||||
val progressValue: P
|
|
||||||
get() = progress.value
|
|
||||||
|
|
||||||
fun progressAsFlow(): Flow<P> = progress
|
|
||||||
}
|
|
||||||
@ -1,39 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.details.ui.adapter
|
|
||||||
|
|
||||||
import android.graphics.Color
|
|
||||||
import android.text.Spannable
|
|
||||||
import android.text.style.ForegroundColorSpan
|
|
||||||
import android.text.style.RelativeSizeSpan
|
|
||||||
import androidx.core.text.buildSpannedString
|
|
||||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
|
|
||||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
|
||||||
import org.koitharu.kotatsu.databinding.ItemCheckableNewBinding
|
|
||||||
import org.koitharu.kotatsu.details.ui.model.MangaBranch
|
|
||||||
|
|
||||||
fun branchAD(
|
|
||||||
clickListener: OnListItemClickListener<MangaBranch>,
|
|
||||||
) = adapterDelegateViewBinding<MangaBranch, MangaBranch, ItemCheckableNewBinding>(
|
|
||||||
{ inflater, parent -> ItemCheckableNewBinding.inflate(inflater, parent, false) },
|
|
||||||
) {
|
|
||||||
|
|
||||||
val clickAdapter = AdapterDelegateClickListenerAdapter(this, clickListener)
|
|
||||||
itemView.setOnClickListener(clickAdapter)
|
|
||||||
val counterColorSpan = ForegroundColorSpan(context.getThemeColor(android.R.attr.textColorSecondary, Color.LTGRAY))
|
|
||||||
val counterSizeSpan = RelativeSizeSpan(0.86f)
|
|
||||||
|
|
||||||
bind {
|
|
||||||
binding.root.text = buildSpannedString {
|
|
||||||
append(item.name ?: getString(R.string.system_default))
|
|
||||||
append(' ')
|
|
||||||
append(' ')
|
|
||||||
val start = length
|
|
||||||
append(item.count.toString())
|
|
||||||
setSpan(counterColorSpan, start, length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
|
||||||
setSpan(counterSizeSpan, start, length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
|
||||||
}
|
|
||||||
binding.root.isChecked = item.isSelected
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.filter.ui
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.recyclerview.widget.AsyncListDiffer.ListListener
|
|
||||||
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
|
||||||
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
|
|
||||||
import org.koitharu.kotatsu.filter.ui.model.FilterItem
|
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD
|
|
||||||
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>,
|
|
||||||
) : BaseListAdapter<ListModel>(), FastScroller.SectionIndexer {
|
|
||||||
|
|
||||||
init {
|
|
||||||
addDelegate(ListItemType.FILTER_SORT, filterSortDelegate(listener))
|
|
||||||
addDelegate(ListItemType.FILTER_TAG, filterTagDelegate(listener))
|
|
||||||
addDelegate(ListItemType.FILTER_TAG_MULTI, filterTagMultipleDelegate(listener))
|
|
||||||
addDelegate(ListItemType.FILTER_STATE, filterStateDelegate(listener))
|
|
||||||
addDelegate(ListItemType.HEADER, listHeaderAD(listener))
|
|
||||||
addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
|
|
||||||
addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())
|
|
||||||
addDelegate(ListItemType.FOOTER_ERROR, 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,86 +0,0 @@
|
|||||||
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.model.titleResId
|
|
||||||
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 filterStateDelegate(
|
|
||||||
listener: OnFilterChangedListener,
|
|
||||||
) = adapterDelegateViewBinding<FilterItem.State, ListModel, ItemCheckableMultipleBinding>(
|
|
||||||
{ layoutInflater, parent -> ItemCheckableMultipleBinding.inflate(layoutInflater, parent, false) },
|
|
||||||
) {
|
|
||||||
|
|
||||||
itemView.setOnClickListener {
|
|
||||||
listener.onStateItemClick(item)
|
|
||||||
}
|
|
||||||
|
|
||||||
bind { payloads ->
|
|
||||||
binding.root.setText(item.state.titleResId)
|
|
||||||
binding.root.setChecked(item.isChecked, payloads.isNotEmpty())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun filterTagDelegate(
|
|
||||||
listener: OnFilterChangedListener,
|
|
||||||
) = adapterDelegateViewBinding<FilterItem.Tag, ListModel, ItemCheckableSingleBinding>(
|
|
||||||
{ layoutInflater, parent -> ItemCheckableSingleBinding.inflate(layoutInflater, parent, false) },
|
|
||||||
on = { item, _, _ -> item is FilterItem.Tag && !item.isMultiple },
|
|
||||||
) {
|
|
||||||
|
|
||||||
itemView.setOnClickListener {
|
|
||||||
listener.onTagItemClick(item, isFromChip = false)
|
|
||||||
}
|
|
||||||
|
|
||||||
bind { payloads ->
|
|
||||||
binding.root.text = item.tag.title
|
|
||||||
binding.root.setChecked(item.isChecked, payloads.isNotEmpty())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun filterTagMultipleDelegate(
|
|
||||||
listener: OnFilterChangedListener,
|
|
||||||
) = adapterDelegateViewBinding<FilterItem.Tag, ListModel, ItemCheckableMultipleBinding>(
|
|
||||||
{ layoutInflater, parent -> ItemCheckableMultipleBinding.inflate(layoutInflater, parent, false) },
|
|
||||||
on = { item, _, _ -> item is FilterItem.Tag && item.isMultiple },
|
|
||||||
) {
|
|
||||||
|
|
||||||
itemView.setOnClickListener {
|
|
||||||
listener.onTagItemClick(item, isFromChip = false)
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,59 +0,0 @@
|
|||||||
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.core.util.ext.showDistinct
|
|
||||||
import org.koitharu.kotatsu.databinding.SheetFilterBinding
|
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
|
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
|
||||||
|
|
||||||
class FilterSheetFragment :
|
|
||||||
BaseAdaptiveSheet<SheetFilterBinding>(),
|
|
||||||
AdaptiveSheetCallback,
|
|
||||||
AsyncListDiffer.ListListener<ListModel> {
|
|
||||||
|
|
||||||
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 filter = (requireActivity() as FilterOwner).filter
|
|
||||||
addSheetCallback(this)
|
|
||||||
val adapter = FilterAdapter(filter, this)
|
|
||||||
binding.recyclerView.adapter = adapter
|
|
||||||
filter.filterItems.observe(viewLifecycleOwner, adapter)
|
|
||||||
binding.recyclerView.addItemDecoration(TypedListSpacingDecoration(binding.root.context, true))
|
|
||||||
|
|
||||||
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().showDistinct(fm, TAG)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,13 +1,18 @@
|
|||||||
package org.koitharu.kotatsu.filter.ui
|
package org.koitharu.kotatsu.filter.ui
|
||||||
|
|
||||||
import org.koitharu.kotatsu.filter.ui.model.FilterItem
|
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener
|
import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||||
|
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
interface OnFilterChangedListener : ListHeaderClickListener {
|
interface OnFilterChangedListener : ListHeaderClickListener {
|
||||||
|
|
||||||
fun onSortItemClick(item: FilterItem.Sort)
|
fun setSortOrder(value: SortOrder)
|
||||||
|
|
||||||
fun onTagItemClick(item: FilterItem.Tag, isFromChip: Boolean)
|
fun setLanguage(value: Locale?)
|
||||||
|
|
||||||
fun onStateItemClick(item: FilterItem.State)
|
fun setTag(value: MangaTag, addOrRemove: Boolean)
|
||||||
|
|
||||||
|
fun setState(value: MangaState, addOrRemove: Boolean)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,75 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.filter.ui.model
|
|
||||||
|
|
||||||
import androidx.annotation.StringRes
|
|
||||||
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
|
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
|
||||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
|
||||||
|
|
||||||
sealed interface FilterItem : ListModel {
|
|
||||||
|
|
||||||
data class Sort(
|
|
||||||
val order: SortOrder,
|
|
||||||
val isSelected: Boolean,
|
|
||||||
) : FilterItem {
|
|
||||||
|
|
||||||
override fun areItemsTheSame(other: ListModel): Boolean {
|
|
||||||
return other is Sort && other.order == order
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getChangePayload(previousState: ListModel): Any? {
|
|
||||||
return if (previousState is Sort && previousState.isSelected != isSelected) {
|
|
||||||
ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED
|
|
||||||
} else {
|
|
||||||
super.getChangePayload(previousState)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class Tag(
|
|
||||||
val tag: MangaTag,
|
|
||||||
val isMultiple: Boolean,
|
|
||||||
val isChecked: Boolean,
|
|
||||||
) : FilterItem {
|
|
||||||
|
|
||||||
override fun areItemsTheSame(other: ListModel): Boolean {
|
|
||||||
return other is Tag && other.isMultiple == isMultiple && other.tag == tag
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getChangePayload(previousState: ListModel): Any? {
|
|
||||||
return if (previousState is Tag && previousState.isChecked != isChecked) {
|
|
||||||
ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED
|
|
||||||
} else {
|
|
||||||
super.getChangePayload(previousState)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class State(
|
|
||||||
val state: MangaState,
|
|
||||||
val isChecked: Boolean
|
|
||||||
) : FilterItem {
|
|
||||||
|
|
||||||
override fun areItemsTheSame(other: ListModel): Boolean {
|
|
||||||
return other is State && other.state == state
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getChangePayload(previousState: ListModel): Any? {
|
|
||||||
return if (previousState is State && previousState.isChecked != isChecked) {
|
|
||||||
ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED
|
|
||||||
} else {
|
|
||||||
super.getChangePayload(previousState)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class Error(
|
|
||||||
@StringRes val textResId: Int,
|
|
||||||
) : FilterItem {
|
|
||||||
|
|
||||||
override fun areItemsTheSame(other: ListModel): Boolean {
|
|
||||||
return other is Error && textResId == other.textResId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
package org.koitharu.kotatsu.filter.ui.model
|
||||||
|
|
||||||
|
data class FilterProperty<T>(
|
||||||
|
val availableItems: List<T>,
|
||||||
|
val selectedItems: Set<T>,
|
||||||
|
val isLoading: Boolean,
|
||||||
|
val error: Throwable?,
|
||||||
|
) {
|
||||||
|
|
||||||
|
fun isEmpty(): Boolean = availableItems.isEmpty()
|
||||||
|
}
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
package org.koitharu.kotatsu.filter.ui.model
|
||||||
|
|
||||||
|
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
|
||||||
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||||
|
|
||||||
|
data class TagCatalogItem(
|
||||||
|
val tag: MangaTag,
|
||||||
|
val isChecked: Boolean,
|
||||||
|
) : ListModel {
|
||||||
|
|
||||||
|
override fun areItemsTheSame(other: ListModel): Boolean {
|
||||||
|
return other is TagCatalogItem && other.tag == tag
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getChangePayload(previousState: ListModel): Any? {
|
||||||
|
return if (previousState is TagCatalogItem && previousState.isChecked != isChecked) {
|
||||||
|
ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED
|
||||||
|
} else {
|
||||||
|
super.getChangePayload(previousState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,197 @@
|
|||||||
|
package org.koitharu.kotatsu.filter.ui.sheet
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.AdapterView
|
||||||
|
import android.widget.ArrayAdapter
|
||||||
|
import androidx.core.view.isGone
|
||||||
|
import androidx.core.view.updatePadding
|
||||||
|
import androidx.fragment.app.FragmentManager
|
||||||
|
import com.google.android.material.chip.Chip
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.model.titleResId
|
||||||
|
import org.koitharu.kotatsu.core.ui.model.titleRes
|
||||||
|
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
|
||||||
|
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.observe
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.showDistinct
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.textAndVisible
|
||||||
|
import org.koitharu.kotatsu.databinding.SheetFilterBinding
|
||||||
|
import org.koitharu.kotatsu.filter.ui.FilterOwner
|
||||||
|
import org.koitharu.kotatsu.filter.ui.model.FilterProperty
|
||||||
|
import org.koitharu.kotatsu.filter.ui.tags.TagsCatalogSheet
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||||
|
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||||
|
import org.koitharu.kotatsu.parsers.util.toTitleCase
|
||||||
|
import java.util.Locale
|
||||||
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
|
class FilterSheetFragment :
|
||||||
|
BaseAdaptiveSheet<SheetFilterBinding>(), AdapterView.OnItemSelectedListener, ChipsView.OnChipClickListener {
|
||||||
|
|
||||||
|
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)
|
||||||
|
if (dialog == null) {
|
||||||
|
binding.layoutBody.updatePadding(top = binding.layoutBody.paddingBottom)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
|
binding.scrollView.scrollIndicators = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val filter = requireFilter()
|
||||||
|
filter.filterSortOrder.observe(viewLifecycleOwner, this::onSortOrderChanged)
|
||||||
|
filter.filterLocale.observe(viewLifecycleOwner, this::onLocaleChanged)
|
||||||
|
filter.filterTags.observe(viewLifecycleOwner, this::onTagsChanged)
|
||||||
|
filter.filterState.observe(viewLifecycleOwner, this::onStateChanged)
|
||||||
|
|
||||||
|
binding.spinnerLocale.onItemSelectedListener = this
|
||||||
|
binding.spinnerOrder.onItemSelectedListener = this
|
||||||
|
binding.chipsState.onChipClickListener = this
|
||||||
|
binding.chipsGenres.onChipClickListener = this
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) {
|
||||||
|
val filter = requireFilter()
|
||||||
|
when (parent.id) {
|
||||||
|
R.id.spinner_order -> filter.setSortOrder(filter.filterSortOrder.value.availableItems[position])
|
||||||
|
R.id.spinner_locale -> filter.setLanguage(filter.filterLocale.value.availableItems[position])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
|
||||||
|
|
||||||
|
override fun onChipClick(chip: Chip, data: Any?) {
|
||||||
|
val filter = requireFilter()
|
||||||
|
when (data) {
|
||||||
|
is MangaState -> filter.setState(data, chip.isChecked)
|
||||||
|
is MangaTag -> filter.setTag(data, chip.isChecked)
|
||||||
|
null -> TagsCatalogSheet.show(childFragmentManager)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onSortOrderChanged(value: FilterProperty<SortOrder>) {
|
||||||
|
val b = viewBinding ?: return
|
||||||
|
b.textViewOrderTitle.isGone = value.isEmpty()
|
||||||
|
b.cardOrder.isGone = value.isEmpty()
|
||||||
|
if (value.isEmpty()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val selected = value.selectedItems.single()
|
||||||
|
b.spinnerOrder.adapter = ArrayAdapter(
|
||||||
|
b.spinnerOrder.context,
|
||||||
|
android.R.layout.simple_spinner_dropdown_item,
|
||||||
|
android.R.id.text1,
|
||||||
|
value.availableItems.map { b.spinnerOrder.context.getString(it.titleRes) },
|
||||||
|
)
|
||||||
|
val selectedIndex = value.availableItems.indexOf(selected)
|
||||||
|
if (selectedIndex >= 0) {
|
||||||
|
b.spinnerOrder.setSelection(selectedIndex, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onLocaleChanged(value: FilterProperty<Locale?>) {
|
||||||
|
val b = viewBinding ?: return
|
||||||
|
b.textViewLocaleTitle.isGone = value.isEmpty()
|
||||||
|
b.cardLocale.isGone = value.isEmpty()
|
||||||
|
if (value.isEmpty()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val selected = value.selectedItems.singleOrNull()
|
||||||
|
b.spinnerLocale.adapter = ArrayAdapter(
|
||||||
|
b.spinnerLocale.context,
|
||||||
|
android.R.layout.simple_spinner_dropdown_item,
|
||||||
|
android.R.id.text1,
|
||||||
|
value.availableItems.map {
|
||||||
|
it?.getDisplayLanguage(it)?.toTitleCase(it)
|
||||||
|
?: b.spinnerLocale.context.getString(R.string.various_languages)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
val selectedIndex = value.availableItems.indexOf(selected)
|
||||||
|
if (selectedIndex >= 0) {
|
||||||
|
b.spinnerLocale.setSelection(selectedIndex, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onTagsChanged(value: FilterProperty<MangaTag>) {
|
||||||
|
val b = viewBinding ?: return
|
||||||
|
b.textViewGenresTitle.isGone = value.isEmpty()
|
||||||
|
b.chipsGenres.isGone = value.isEmpty()
|
||||||
|
b.textViewGenresHint.textAndVisible = value.error?.getDisplayMessage(resources)
|
||||||
|
if (value.isEmpty()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val chips = ArrayList<ChipsView.ChipModel>(value.selectedItems.size + value.availableItems.size + 1)
|
||||||
|
value.selectedItems.mapTo(chips) { tag ->
|
||||||
|
ChipsView.ChipModel(
|
||||||
|
tint = 0,
|
||||||
|
title = tag.title,
|
||||||
|
icon = 0,
|
||||||
|
isCheckable = true,
|
||||||
|
isChecked = true,
|
||||||
|
data = tag,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
value.availableItems.mapNotNullTo(chips) { tag ->
|
||||||
|
if (tag !in value.selectedItems) {
|
||||||
|
ChipsView.ChipModel(
|
||||||
|
tint = 0,
|
||||||
|
title = tag.title,
|
||||||
|
icon = 0,
|
||||||
|
isCheckable = true,
|
||||||
|
isChecked = false,
|
||||||
|
data = tag,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chips.add(
|
||||||
|
ChipsView.ChipModel(
|
||||||
|
tint = 0,
|
||||||
|
title = getString(R.string.more),
|
||||||
|
icon = materialR.drawable.abc_ic_menu_overflow_material,
|
||||||
|
isCheckable = false,
|
||||||
|
isChecked = false,
|
||||||
|
data = null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
b.chipsGenres.setChips(chips)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onStateChanged(value: FilterProperty<MangaState>) {
|
||||||
|
val b = viewBinding ?: return
|
||||||
|
b.textViewStateTitle.isGone = value.isEmpty()
|
||||||
|
b.chipsState.isGone = value.isEmpty()
|
||||||
|
if (value.isEmpty()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val chips = value.availableItems.map { state ->
|
||||||
|
ChipsView.ChipModel(
|
||||||
|
tint = 0,
|
||||||
|
title = getString(state.titleResId),
|
||||||
|
icon = 0,
|
||||||
|
isCheckable = true,
|
||||||
|
isChecked = state in value.selectedItems,
|
||||||
|
data = state,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
b.chipsState.setChips(chips)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requireFilter() = (requireActivity() as FilterOwner).filter
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
private const val TAG = "FilterSheet"
|
||||||
|
|
||||||
|
fun show(fm: FragmentManager) = FilterSheetFragment().showDistinct(fm, TAG)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,48 @@
|
|||||||
|
package org.koitharu.kotatsu.filter.ui.tags
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||||
|
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
||||||
|
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||||
|
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.setChecked
|
||||||
|
import org.koitharu.kotatsu.databinding.ItemCheckableNewBinding
|
||||||
|
import org.koitharu.kotatsu.filter.ui.model.TagCatalogItem
|
||||||
|
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
|
||||||
|
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
||||||
|
import org.koitharu.kotatsu.list.ui.adapter.errorFooterAD
|
||||||
|
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 TagsCatalogAdapter(
|
||||||
|
listener: OnListItemClickListener<TagCatalogItem>,
|
||||||
|
) : BaseListAdapter<ListModel>(), FastScroller.SectionIndexer {
|
||||||
|
|
||||||
|
init {
|
||||||
|
addDelegate(ListItemType.FILTER_TAG, tagCatalogDelegate(listener))
|
||||||
|
addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
|
||||||
|
addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())
|
||||||
|
addDelegate(ListItemType.FOOTER_ERROR, errorFooterAD(null))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getSectionText(context: Context, position: Int): CharSequence? {
|
||||||
|
return (items.getOrNull(position) as? TagCatalogItem)?.tag?.title?.firstOrNull()?.uppercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tagCatalogDelegate(
|
||||||
|
listener: OnListItemClickListener<TagCatalogItem>,
|
||||||
|
) = adapterDelegateViewBinding<TagCatalogItem, ListModel, ItemCheckableNewBinding>(
|
||||||
|
{ layoutInflater, parent -> ItemCheckableNewBinding.inflate(layoutInflater, parent, false) },
|
||||||
|
) {
|
||||||
|
|
||||||
|
itemView.setOnClickListener {
|
||||||
|
listener.onItemClick(item, itemView)
|
||||||
|
}
|
||||||
|
|
||||||
|
bind { payloads ->
|
||||||
|
binding.root.text = item.tag.title
|
||||||
|
binding.root.setChecked(item.isChecked, ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED in payloads)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,96 @@
|
|||||||
|
package org.koitharu.kotatsu.filter.ui.tags
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.text.Editable
|
||||||
|
import android.text.TextWatcher
|
||||||
|
import android.view.KeyEvent
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.view.inputmethod.EditorInfo
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.fragment.app.FragmentManager
|
||||||
|
import androidx.fragment.app.viewModels
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import dagger.hilt.android.lifecycle.withCreationCallback
|
||||||
|
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||||
|
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.core.util.ext.showDistinct
|
||||||
|
import org.koitharu.kotatsu.databinding.SheetTagsBinding
|
||||||
|
import org.koitharu.kotatsu.filter.ui.FilterOwner
|
||||||
|
import org.koitharu.kotatsu.filter.ui.model.TagCatalogItem
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class TagsCatalogSheet : BaseAdaptiveSheet<SheetTagsBinding>(), OnListItemClickListener<TagCatalogItem>, TextWatcher,
|
||||||
|
AdaptiveSheetCallback, View.OnFocusChangeListener, TextView.OnEditorActionListener {
|
||||||
|
|
||||||
|
private val viewModel by viewModels<TagsCatalogViewModel>(
|
||||||
|
extrasProducer = {
|
||||||
|
defaultViewModelCreationExtras.withCreationCallback<TagsCatalogViewModel.Factory> { factory ->
|
||||||
|
factory.create((requireActivity() as FilterOwner).filter)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetTagsBinding {
|
||||||
|
return SheetTagsBinding.inflate(inflater, container, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewBindingCreated(binding: SheetTagsBinding, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewBindingCreated(binding, savedInstanceState)
|
||||||
|
val adapter = TagsCatalogAdapter(this)
|
||||||
|
binding.recyclerView.adapter = adapter
|
||||||
|
binding.recyclerView.setHasFixedSize(true)
|
||||||
|
binding.editSearch.setText(viewModel.searchQuery.value)
|
||||||
|
binding.editSearch.addTextChangedListener(this)
|
||||||
|
binding.editSearch.onFocusChangeListener = this
|
||||||
|
binding.editSearch.setOnEditorActionListener(this)
|
||||||
|
viewModel.content.observe(viewLifecycleOwner, adapter)
|
||||||
|
addSheetCallback(this)
|
||||||
|
disableFitToContents()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onItemClick(item: TagCatalogItem, view: View) {
|
||||||
|
val filter = (requireActivity() as FilterOwner).filter
|
||||||
|
filter.setTag(item.tag, !item.isChecked)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFocusChange(v: View?, hasFocus: Boolean) {
|
||||||
|
setExpanded(
|
||||||
|
isExpanded = hasFocus || isExpanded,
|
||||||
|
isLocked = hasFocus,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onEditorAction(v: TextView, actionId: Int, event: KeyEvent?): Boolean {
|
||||||
|
return if (actionId == EditorInfo.IME_ACTION_SEARCH) {
|
||||||
|
v.clearFocus()
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
|
||||||
|
|
||||||
|
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
|
||||||
|
|
||||||
|
override fun afterTextChanged(s: Editable?) {
|
||||||
|
val q = s?.toString().orEmpty()
|
||||||
|
viewModel.searchQuery.value = q
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStateChanged(sheet: View, newState: Int) {
|
||||||
|
viewBinding?.recyclerView?.isFastScrollerEnabled = newState == AdaptiveSheetBehavior.STATE_EXPANDED
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
private const val TAG = "TagsCatalogSheet"
|
||||||
|
|
||||||
|
fun show(fm: FragmentManager) = TagsCatalogSheet().showDistinct(fm, TAG)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,60 @@
|
|||||||
|
package org.koitharu.kotatsu.filter.ui.tags
|
||||||
|
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.assisted.Assisted
|
||||||
|
import dagger.assisted.AssistedFactory
|
||||||
|
import dagger.assisted.AssistedInject
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.plus
|
||||||
|
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||||
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
|
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||||
|
import org.koitharu.kotatsu.filter.ui.MangaFilter
|
||||||
|
import org.koitharu.kotatsu.filter.ui.model.TagCatalogItem
|
||||||
|
import org.koitharu.kotatsu.list.ui.model.LoadingState
|
||||||
|
|
||||||
|
@HiltViewModel(assistedFactory = TagsCatalogViewModel.Factory::class)
|
||||||
|
class TagsCatalogViewModel @AssistedInject constructor(
|
||||||
|
@Assisted filter: MangaFilter,
|
||||||
|
mangaRepositoryFactory: MangaRepository.Factory,
|
||||||
|
dataRepository: MangaDataRepository,
|
||||||
|
) : BaseViewModel() {
|
||||||
|
|
||||||
|
val searchQuery = MutableStateFlow("")
|
||||||
|
|
||||||
|
private val tags = combine(
|
||||||
|
filter.allTags,
|
||||||
|
filter.filterTags.map { it.selectedItems },
|
||||||
|
) { all, selected ->
|
||||||
|
all.map { x ->
|
||||||
|
if (x is TagCatalogItem) {
|
||||||
|
val checked = x.tag in selected
|
||||||
|
if (x.isChecked == checked) {
|
||||||
|
x
|
||||||
|
} else {
|
||||||
|
x.copy(isChecked = checked)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
x
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, filter.allTags.value)
|
||||||
|
|
||||||
|
val content = combine(tags, searchQuery) { raw, query ->
|
||||||
|
raw.filter { x ->
|
||||||
|
x !is TagCatalogItem || x.tag.title.contains(query, ignoreCase = true)
|
||||||
|
}
|
||||||
|
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, listOf(LoadingState))
|
||||||
|
|
||||||
|
@AssistedFactory
|
||||||
|
interface Factory {
|
||||||
|
fun create(filter: MangaFilter): TagsCatalogViewModel
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,133 @@
|
|||||||
|
package org.koitharu.kotatsu.main.ui.welcome
|
||||||
|
|
||||||
|
import android.accounts.AccountManager
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.activity.result.ActivityResultCallback
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.core.view.isGone
|
||||||
|
import androidx.fragment.app.FragmentManager
|
||||||
|
import androidx.fragment.app.viewModels
|
||||||
|
import com.google.android.material.chip.Chip
|
||||||
|
import com.google.android.material.snackbar.Snackbar
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.model.titleResId
|
||||||
|
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
|
||||||
|
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.observe
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.showDistinct
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.tryLaunch
|
||||||
|
import org.koitharu.kotatsu.databinding.SheetWelcomeBinding
|
||||||
|
import org.koitharu.kotatsu.filter.ui.model.FilterProperty
|
||||||
|
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||||
|
import org.koitharu.kotatsu.parsers.util.toTitleCase
|
||||||
|
import org.koitharu.kotatsu.settings.backup.RestoreDialogFragment
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class WelcomeSheet : BaseAdaptiveSheet<SheetWelcomeBinding>(), ChipsView.OnChipClickListener, View.OnClickListener,
|
||||||
|
ActivityResultCallback<Uri?> {
|
||||||
|
|
||||||
|
private val viewModel by viewModels<WelcomeViewModel>()
|
||||||
|
|
||||||
|
private val backupSelectCall = registerForActivityResult(
|
||||||
|
ActivityResultContracts.OpenDocument(),
|
||||||
|
this,
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetWelcomeBinding {
|
||||||
|
return SheetWelcomeBinding.inflate(inflater, container, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewBindingCreated(binding: SheetWelcomeBinding, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewBindingCreated(binding, savedInstanceState)
|
||||||
|
binding.textViewWelcomeTitle.isGone = resources.getBoolean(R.bool.is_tablet)
|
||||||
|
binding.chipsLocales.onChipClickListener = this
|
||||||
|
binding.chipsType.onChipClickListener = this
|
||||||
|
binding.chipBackup.setOnClickListener(this)
|
||||||
|
binding.chipSync.setOnClickListener(this)
|
||||||
|
|
||||||
|
viewModel.locales.observe(viewLifecycleOwner, ::onLocalesChanged)
|
||||||
|
viewModel.types.observe(viewLifecycleOwner, ::onTypesChanged)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onChipClick(chip: Chip, data: Any?) {
|
||||||
|
when (data) {
|
||||||
|
is ContentType -> viewModel.setTypeChecked(data, chip.isChecked)
|
||||||
|
is Locale? -> viewModel.setLocaleChecked(data, chip.isChecked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClick(v: View) {
|
||||||
|
when (v.id) {
|
||||||
|
R.id.chip_backup -> {
|
||||||
|
if (!backupSelectCall.tryLaunch(arrayOf("*/*"))) {
|
||||||
|
Snackbar.make(
|
||||||
|
v, R.string.operation_not_supported, Snackbar.LENGTH_SHORT,
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
R.id.chip_sync -> {
|
||||||
|
val am = AccountManager.get(v.context)
|
||||||
|
val accountType = getString(R.string.account_type_sync)
|
||||||
|
am.addAccount(accountType, accountType, null, null, requireActivity(), null, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onActivityResult(result: Uri?) {
|
||||||
|
if (result != null) {
|
||||||
|
RestoreDialogFragment.show(parentFragmentManager, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onLocalesChanged(value: FilterProperty<Locale?>) {
|
||||||
|
val chips = viewBinding?.chipsLocales ?: return
|
||||||
|
chips.setChips(
|
||||||
|
value.availableItems.map {
|
||||||
|
ChipsView.ChipModel(
|
||||||
|
tint = 0,
|
||||||
|
title = it?.getDisplayLanguage(it)?.toTitleCase(it) ?: getString(R.string.various_languages),
|
||||||
|
icon = 0,
|
||||||
|
isCheckable = true,
|
||||||
|
isChecked = it in value.selectedItems,
|
||||||
|
data = it,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onTypesChanged(value: FilterProperty<ContentType>) {
|
||||||
|
val chips = viewBinding?.chipsType ?: return
|
||||||
|
chips.setChips(
|
||||||
|
value.availableItems.map {
|
||||||
|
ChipsView.ChipModel(
|
||||||
|
tint = 0,
|
||||||
|
title = getString(it.titleResId),
|
||||||
|
icon = 0,
|
||||||
|
isCheckable = true,
|
||||||
|
isChecked = it in value.selectedItems,
|
||||||
|
data = it,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
private const val TAG = "WelcomeSheet"
|
||||||
|
|
||||||
|
fun show(fm: FragmentManager) = WelcomeSheet().showDistinct(fm, TAG)
|
||||||
|
|
||||||
|
fun dismiss(fm: FragmentManager): Boolean {
|
||||||
|
val sheet = fm.findFragmentByTag(TAG) as? WelcomeSheet ?: return false
|
||||||
|
sheet.dismissAllowingStateLoss()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,107 @@
|
|||||||
|
package org.koitharu.kotatsu.main.ui.welcome
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.core.os.ConfigurationCompat
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||||
|
import org.koitharu.kotatsu.core.util.LocaleComparator
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.sortedWithSafe
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.toList
|
||||||
|
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
|
||||||
|
import org.koitharu.kotatsu.filter.ui.model.FilterProperty
|
||||||
|
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||||
|
import java.util.EnumSet
|
||||||
|
import java.util.Locale
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class WelcomeViewModel @Inject constructor(
|
||||||
|
private val repository: MangaSourcesRepository,
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
) : BaseViewModel() {
|
||||||
|
|
||||||
|
private val allSources = repository.allMangaSources
|
||||||
|
private val localesGroups by lazy { allSources.groupBy { it.locale?.let { x -> Locale(x) } } }
|
||||||
|
|
||||||
|
private var updateJob: Job
|
||||||
|
|
||||||
|
val locales = MutableStateFlow(
|
||||||
|
FilterProperty<Locale?>(
|
||||||
|
availableItems = listOf(null),
|
||||||
|
selectedItems = setOf(null),
|
||||||
|
isLoading = true,
|
||||||
|
error = null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val types = MutableStateFlow(
|
||||||
|
FilterProperty(
|
||||||
|
availableItems = ContentType.entries.toList(),
|
||||||
|
selectedItems = setOf(ContentType.MANGA),
|
||||||
|
isLoading = false,
|
||||||
|
error = null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
init {
|
||||||
|
updateJob = launchJob(Dispatchers.Default) {
|
||||||
|
val languages = localesGroups.keys.associateBy { x -> x?.language }
|
||||||
|
val selectedLocales = HashSet<Locale?>(2)
|
||||||
|
selectedLocales += ConfigurationCompat.getLocales(context.resources.configuration).toList()
|
||||||
|
.firstNotNullOfOrNull { lc -> languages[lc.language] }
|
||||||
|
selectedLocales += null
|
||||||
|
locales.value = locales.value.copy(
|
||||||
|
availableItems = localesGroups.keys.sortedWithSafe(nullsFirst(LocaleComparator())),
|
||||||
|
selectedItems = selectedLocales,
|
||||||
|
isLoading = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setLocaleChecked(locale: Locale?, isChecked: Boolean) {
|
||||||
|
val snapshot = locales.value
|
||||||
|
locales.value = snapshot.copy(
|
||||||
|
selectedItems = if (isChecked) {
|
||||||
|
snapshot.selectedItems + locale
|
||||||
|
} else {
|
||||||
|
snapshot.selectedItems - locale
|
||||||
|
},
|
||||||
|
)
|
||||||
|
val prevJob = updateJob
|
||||||
|
updateJob = launchJob(Dispatchers.Default) {
|
||||||
|
prevJob.join()
|
||||||
|
commit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setTypeChecked(type: ContentType, isChecked: Boolean) {
|
||||||
|
val snapshot = types.value
|
||||||
|
types.value = snapshot.copy(
|
||||||
|
selectedItems = if (isChecked) {
|
||||||
|
snapshot.selectedItems + type
|
||||||
|
} else {
|
||||||
|
snapshot.selectedItems - type
|
||||||
|
},
|
||||||
|
)
|
||||||
|
val prevJob = updateJob
|
||||||
|
updateJob = launchJob(Dispatchers.Default) {
|
||||||
|
prevJob.join()
|
||||||
|
commit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun commit() {
|
||||||
|
val languages = locales.value.selectedItems.mapToSet { it?.language }
|
||||||
|
val types = types.value.selectedItems
|
||||||
|
val enabledSources = allSources.filterTo(EnumSet.noneOf(MangaSource::class.java)) { x ->
|
||||||
|
x.contentType in types && x.locale in languages
|
||||||
|
}
|
||||||
|
repository.setSourcesEnabledExclusive(enabledSources)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,29 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.reader.ui.pager.standard
|
|
||||||
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import androidx.viewpager2.widget.ViewPager2
|
|
||||||
import org.koitharu.kotatsu.reader.ui.pager.OnBoundsScrollListener
|
|
||||||
|
|
||||||
class PagerPaginationListener(
|
|
||||||
private val adapter: RecyclerView.Adapter<*>,
|
|
||||||
private val offset: Int,
|
|
||||||
private val listener: OnBoundsScrollListener
|
|
||||||
) : ViewPager2.OnPageChangeCallback() {
|
|
||||||
|
|
||||||
private var firstItemId: Long = 0
|
|
||||||
private var lastItemId: Long = 0
|
|
||||||
|
|
||||||
override fun onPageSelected(position: Int) {
|
|
||||||
val itemCount = adapter.itemCount
|
|
||||||
if (itemCount == 0) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (position <= offset && adapter.getItemId(0) != firstItemId) {
|
|
||||||
firstItemId = adapter.getItemId(0)
|
|
||||||
listener.onScrolledToStart()
|
|
||||||
} else if (position >= itemCount - offset && adapter.getItemId(itemCount - 1) != lastItemId) {
|
|
||||||
lastItemId = adapter.getItemId(itemCount - 1)
|
|
||||||
listener.onScrolledToEnd()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,32 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.reader.ui.pager.webtoon
|
|
||||||
|
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import org.koitharu.kotatsu.reader.ui.pager.OnBoundsScrollListener
|
|
||||||
|
|
||||||
class ListPaginationListener(
|
|
||||||
private val offset: Int,
|
|
||||||
private val listener: OnBoundsScrollListener
|
|
||||||
) : RecyclerView.OnScrollListener() {
|
|
||||||
|
|
||||||
private var firstItemId: Long = 0
|
|
||||||
private var lastItemId: Long = 0
|
|
||||||
|
|
||||||
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
|
|
||||||
val adapter = recyclerView.adapter ?: return
|
|
||||||
val layoutManager = (recyclerView.layoutManager as? LinearLayoutManager) ?: return
|
|
||||||
val firstVisiblePosition = layoutManager.findFirstVisibleItemPosition()
|
|
||||||
val lastVisiblePosition = layoutManager.findLastVisibleItemPosition()
|
|
||||||
val itemCount = adapter.itemCount
|
|
||||||
if (itemCount == 0) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (lastVisiblePosition >= itemCount - offset && adapter.getItemId(itemCount - 1) != lastItemId) {
|
|
||||||
lastItemId = adapter.getItemId(itemCount - 1)
|
|
||||||
listener.onScrolledToEnd()
|
|
||||||
} else if (firstVisiblePosition <= offset && adapter.getItemId(0) != firstItemId) {
|
|
||||||
firstItemId = adapter.getItemId(0)
|
|
||||||
listener.onScrolledToStart()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.reader.ui.thumbnails.adapter
|
|
||||||
|
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
|
|
||||||
import org.koitharu.kotatsu.reader.ui.thumbnails.PageThumbnail
|
|
||||||
|
|
||||||
class TargetScrollObserver(
|
|
||||||
private val recyclerView: RecyclerView,
|
|
||||||
) : RecyclerView.AdapterDataObserver() {
|
|
||||||
|
|
||||||
private var isScrollToCurrentPending = true
|
|
||||||
|
|
||||||
private val layoutManager: LinearLayoutManager
|
|
||||||
get() = recyclerView.layoutManager as LinearLayoutManager
|
|
||||||
|
|
||||||
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
|
|
||||||
if (isScrollToCurrentPending) {
|
|
||||||
postScroll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun postScroll() {
|
|
||||||
recyclerView.post {
|
|
||||||
scrollToTarget()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun scrollToTarget() {
|
|
||||||
val adapter = recyclerView.adapter ?: return
|
|
||||||
if (recyclerView.computeVerticalScrollRange() == 0) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val snapshot = (adapter as? AsyncListDifferDelegationAdapter<*>)?.items ?: return
|
|
||||||
val target = snapshot.indexOfFirst { it is PageThumbnail && it.isCurrent }
|
|
||||||
if (target in snapshot.indices) {
|
|
||||||
layoutManager.scrollToPositionWithOffset(target, 0)
|
|
||||||
isScrollToCurrentPending = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue